307 lines
14 KiB
JavaScript
307 lines
14 KiB
JavaScript
import { BoardService } from '../services/boardService.js';
|
|
import { KanbanService } from '../services/kanbanService.js';
|
|
import { LockManager } from './lockManager.js';
|
|
const activeUsersPerBoard = new Map();
|
|
const RETRO_COLORS = ['#f87171', '#fbbf24', '#34d399', '#60a5fa', '#a78bfa', '#f472b6', '#38bdf8', '#fb923c'];
|
|
function getRandomColor(seed) {
|
|
let hash = 0;
|
|
for (let i = 0; i < seed.length; i++) {
|
|
hash = seed.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
return RETRO_COLORS[Math.abs(hash) % RETRO_COLORS.length];
|
|
}
|
|
export function setupSocketHandlers(io) {
|
|
// Authentication & Handshake Middleware
|
|
io.use(async (socket, next) => {
|
|
try {
|
|
const auth = socket.handshake.auth;
|
|
const boardId = auth.boardId || socket.handshake.query.boardId;
|
|
const key = auth.key || socket.handshake.query.key;
|
|
const userName = auth.userName?.trim() || `User_${Math.floor(1000 + Math.random() * 9000)}`;
|
|
const userId = auth.userId || socket.id;
|
|
if (!boardId || !key) {
|
|
return next(new Error('AUTHENTICATION_REQUIRED: Missing boardId or key'));
|
|
}
|
|
const authResult = await BoardService.authenticateKey(boardId, key);
|
|
if (!authResult) {
|
|
return next(new Error('AUTHENTICATION_FAILED: Invalid key for board'));
|
|
}
|
|
const color = getRandomColor(userId);
|
|
socket.data = {
|
|
boardId,
|
|
role: authResult.role,
|
|
userId,
|
|
userName,
|
|
color,
|
|
};
|
|
next();
|
|
}
|
|
catch (err) {
|
|
console.error('[Socket Auth Error]:', err);
|
|
next(new Error('INTERNAL_SERVER_ERROR'));
|
|
}
|
|
});
|
|
io.on('connection', (socket) => {
|
|
const data = socket.data;
|
|
const { boardId, role, userId, userName, color } = data;
|
|
const roomName = `board:${boardId}`;
|
|
socket.join(roomName);
|
|
console.log(`[User Joined] ${userName} (${role}) joined board room ${boardId}`);
|
|
// Track active presence
|
|
if (!activeUsersPerBoard.has(boardId)) {
|
|
activeUsersPerBoard.set(boardId, new Map());
|
|
}
|
|
const boardUsers = activeUsersPerBoard.get(boardId);
|
|
boardUsers.set(socket.id, { socketId: socket.id, userId, userName, color, role });
|
|
// Broadcast updated presence list
|
|
io.to(roomName).emit('presence_update', Array.from(boardUsers.values()));
|
|
// Send active field locks
|
|
const activeLocks = LockManager.getActiveLocks();
|
|
socket.emit('active_locks', activeLocks);
|
|
// ==========================================
|
|
// 1. DRAG & DROP / CARD MOVED WITH DB LOGGING
|
|
// ==========================================
|
|
socket.on('card_moved', async (payload) => {
|
|
try {
|
|
const { ticketId, targetColumnId, newOrder, ticketIdsInTargetColumn } = payload;
|
|
// Broadcast instantly to other clients
|
|
socket.to(roomName).emit('card_moved', {
|
|
ticketId,
|
|
targetColumnId,
|
|
newOrder,
|
|
ticketIdsInTargetColumn,
|
|
movedBy: { userId, userName },
|
|
});
|
|
// Persist movement and log to database
|
|
const activity = await KanbanService.moveTicket(ticketId, targetColumnId, newOrder, ticketIdsInTargetColumn, userName);
|
|
if (activity) {
|
|
io.to(roomName).emit('activity_logged', { activity });
|
|
}
|
|
}
|
|
catch (err) {
|
|
console.error('[card_moved DB error]:', err);
|
|
socket.emit('card_move_failed', {
|
|
ticketId: payload.ticketId,
|
|
error: err.message || 'Failed to move ticket',
|
|
});
|
|
const freshBoard = await BoardService.getBoardDetails(boardId, role);
|
|
if (freshBoard) {
|
|
io.to(roomName).emit('board_refreshed', freshBoard);
|
|
}
|
|
}
|
|
});
|
|
// ==========================================
|
|
// 2. FIELD-LEVEL LOCKING & TEXT EDITING
|
|
// ==========================================
|
|
socket.on('user_typing_field', (payload) => {
|
|
const { ticketId, field } = payload;
|
|
const result = LockManager.acquireLock(ticketId, field, socket.id, userId, userName);
|
|
if (result.success && result.lock) {
|
|
socket.to(roomName).emit('field_locked', {
|
|
ticketId,
|
|
field,
|
|
userId,
|
|
userName,
|
|
color,
|
|
lockedAt: result.lock.lockedAt,
|
|
});
|
|
}
|
|
else if (!result.success && result.currentHolder) {
|
|
socket.emit('field_lock_rejected', {
|
|
ticketId,
|
|
field,
|
|
holder: result.currentHolder,
|
|
});
|
|
}
|
|
});
|
|
socket.on('update_ticket_text', async (payload) => {
|
|
try {
|
|
const { ticketId, field, text } = payload;
|
|
const updates = {};
|
|
if (field === 'title')
|
|
updates.title = text;
|
|
if (field === 'description')
|
|
updates.description = text;
|
|
const { ticket: updatedTicket, activity } = await KanbanService.updateTicket(ticketId, updates, userName);
|
|
LockManager.releaseLock(ticketId, field, socket.id);
|
|
io.to(roomName).emit('field_unlocked', {
|
|
ticketId,
|
|
field,
|
|
updatedTicket,
|
|
});
|
|
io.to(roomName).emit('ticket_updated', { ticket: updatedTicket });
|
|
if (activity) {
|
|
io.to(roomName).emit('activity_logged', { activity });
|
|
}
|
|
}
|
|
catch (err) {
|
|
console.error('[update_ticket_text error]:', err);
|
|
LockManager.releaseLock(payload.ticketId, payload.field, socket.id);
|
|
io.to(roomName).emit('field_unlocked', { ticketId: payload.ticketId, field: payload.field });
|
|
}
|
|
});
|
|
socket.on('cancel_typing_field', (payload) => {
|
|
const { ticketId, field } = payload;
|
|
LockManager.releaseLock(ticketId, field, socket.id);
|
|
io.to(roomName).emit('field_unlocked', { ticketId, field });
|
|
});
|
|
// ==========================================
|
|
// 3. TICKET CRUD WITH DUE DATE, SUBTASKS, BLOCKED
|
|
// ==========================================
|
|
socket.on('create_ticket', async (payload) => {
|
|
try {
|
|
const { ticket, activity } = await KanbanService.createTicket(payload.columnId, payload.title, payload.description, payload.priority, payload.tags, payload.assignee, payload.dueDate, payload.subtasks, payload.isBlocked, payload.blockedReason, userName);
|
|
io.to(roomName).emit('ticket_created', { ticket, createdBy: { userId, userName } });
|
|
if (activity) {
|
|
io.to(roomName).emit('activity_logged', { activity });
|
|
}
|
|
}
|
|
catch (err) {
|
|
console.error('[create_ticket error]:', err);
|
|
socket.emit('operation_error', { message: 'Failed to create ticket' });
|
|
}
|
|
});
|
|
socket.on('update_ticket', async (payload) => {
|
|
try {
|
|
const { ticket, activity } = await KanbanService.updateTicket(payload.ticketId, payload.updates, userName);
|
|
io.to(roomName).emit('ticket_updated', { ticket, updatedBy: { userId, userName } });
|
|
if (activity) {
|
|
io.to(roomName).emit('activity_logged', { activity });
|
|
}
|
|
}
|
|
catch (err) {
|
|
console.error('[update_ticket error]:', err);
|
|
socket.emit('operation_error', { message: 'Failed to update ticket' });
|
|
}
|
|
});
|
|
socket.on('delete_ticket', async (payload) => {
|
|
try {
|
|
const { activity } = await KanbanService.deleteTicket(payload.ticketId, userName);
|
|
io.to(roomName).emit('ticket_deleted', {
|
|
ticketId: payload.ticketId,
|
|
columnId: payload.columnId,
|
|
deletedBy: { userId, userName },
|
|
});
|
|
if (activity) {
|
|
io.to(roomName).emit('activity_logged', { activity });
|
|
}
|
|
}
|
|
catch (err) {
|
|
console.error('[delete_ticket error]:', err);
|
|
socket.emit('operation_error', { message: 'Failed to delete ticket' });
|
|
}
|
|
});
|
|
// ==========================================
|
|
// 4. COLUMN CRUD & WIP LIMITS
|
|
// ==========================================
|
|
socket.on('create_column', async (payload) => {
|
|
try {
|
|
if (role !== 'admin') {
|
|
socket.emit('operation_error', { message: 'Only superadmins can create columns' });
|
|
return;
|
|
}
|
|
const column = await KanbanService.createColumn(boardId, payload.title, payload.maxWipLimit || 0, payload.autoStaleHours || 0);
|
|
io.to(roomName).emit('column_created', { column });
|
|
}
|
|
catch (err) {
|
|
console.error('[create_column error]:', err);
|
|
socket.emit('operation_error', { message: 'Failed to create column' });
|
|
}
|
|
});
|
|
socket.on('update_column', async (payload) => {
|
|
try {
|
|
if (role !== 'admin') {
|
|
socket.emit('operation_error', { message: 'Only superadmins can update columns' });
|
|
return;
|
|
}
|
|
const updatedCol = await KanbanService.updateColumn(payload.columnId, payload.updates);
|
|
io.to(roomName).emit('column_updated', { column: updatedCol });
|
|
}
|
|
catch (err) {
|
|
console.error('[update_column error]:', err);
|
|
socket.emit('operation_error', { message: 'Failed to update column' });
|
|
}
|
|
});
|
|
socket.on('delete_column', async (payload) => {
|
|
try {
|
|
if (role !== 'admin') {
|
|
socket.emit('operation_error', { message: 'Only superadmins can delete columns' });
|
|
return;
|
|
}
|
|
await KanbanService.deleteColumn(payload.columnId);
|
|
io.to(roomName).emit('column_deleted', { columnId: payload.columnId });
|
|
}
|
|
catch (err) {
|
|
console.error('[delete_column error]:', err);
|
|
socket.emit('operation_error', { message: 'Failed to delete column' });
|
|
}
|
|
});
|
|
socket.on('reorder_columns', async (payload) => {
|
|
try {
|
|
if (role !== 'admin') {
|
|
socket.emit('operation_error', { message: 'Only superadmins can reorder columns' });
|
|
return;
|
|
}
|
|
socket.to(roomName).emit('columns_reordered', { columnIdsInOrder: payload.columnIdsInOrder });
|
|
await KanbanService.reorderColumns(boardId, payload.columnIdsInOrder);
|
|
}
|
|
catch (err) {
|
|
console.error('[reorder_columns error]:', err);
|
|
socket.emit('operation_error', { message: 'Failed to reorder columns' });
|
|
}
|
|
});
|
|
// Update Board Settings (autoMoveStale)
|
|
socket.on('update_board_settings', async (payload) => {
|
|
try {
|
|
if (role !== 'admin') {
|
|
socket.emit('operation_error', { message: 'Only superadmins can change board settings' });
|
|
return;
|
|
}
|
|
await BoardService.updateBoard(boardId, { autoMoveStale: payload.autoMoveStale });
|
|
io.to(roomName).emit('board_settings_updated', { autoMoveStale: payload.autoMoveStale });
|
|
}
|
|
catch (err) {
|
|
console.error('[update_board_settings error]:', err);
|
|
}
|
|
});
|
|
// ==========================================
|
|
// 5. MEMBER KEY ROTATION BROADCAST
|
|
// ==========================================
|
|
socket.on('member_key_rotated', () => {
|
|
if (role === 'admin') {
|
|
for (const [sId, u] of boardUsers.entries()) {
|
|
if (u.role === 'member') {
|
|
const memberSocket = io.sockets.sockets.get(sId);
|
|
if (memberSocket) {
|
|
memberSocket.emit('member_session_revoked', {
|
|
message: 'The superadmin regenerated the member invite link. Your session has been revoked.',
|
|
});
|
|
memberSocket.disconnect(true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
// ==========================================
|
|
// 6. DISCONNECT & CLEANUP
|
|
// ==========================================
|
|
socket.on('disconnect', () => {
|
|
console.log(`[User Left] ${userName} disconnected from board ${boardId}`);
|
|
const releasedLocks = LockManager.releaseSocketLocks(socket.id);
|
|
for (const lock of releasedLocks) {
|
|
io.to(roomName).emit('field_unlocked', { ticketId: lock.ticketId, field: lock.field });
|
|
}
|
|
const bUsers = activeUsersPerBoard.get(boardId);
|
|
if (bUsers) {
|
|
bUsers.delete(socket.id);
|
|
if (bUsers.size === 0) {
|
|
activeUsersPerBoard.delete(boardId);
|
|
}
|
|
else {
|
|
io.to(roomName).emit('presence_update', Array.from(bUsers.values()));
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|