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())); } } }); }); }