first init
This commit is contained in:
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
import { Server } from 'socket.io';
|
||||
export declare function setupSocketHandlers(io: Server): void;
|
||||
Vendored
+306
@@ -0,0 +1,306 @@
|
||||
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()));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
import { FieldLockInfo } from '../types/index.js';
|
||||
export declare class LockManager {
|
||||
private static locks;
|
||||
private static LOCK_EXPIRATION_MS;
|
||||
private static getLockKey;
|
||||
/**
|
||||
* Attempt to acquire or refresh a lock.
|
||||
* Returns { success: boolean, lock?: FieldLockInfo, currentLock?: FieldLockInfo }
|
||||
*/
|
||||
static acquireLock(ticketId: string, field: 'title' | 'description', socketId: string, userId: string, userName: string): {
|
||||
success: boolean;
|
||||
lock?: FieldLockInfo;
|
||||
currentHolder?: FieldLockInfo;
|
||||
};
|
||||
/**
|
||||
* Release a lock by ticketId and field.
|
||||
*/
|
||||
static releaseLock(ticketId: string, field: 'title' | 'description', socketId?: string): FieldLockInfo | null;
|
||||
/**
|
||||
* Release all locks held by a disconnected socket.
|
||||
*/
|
||||
static releaseSocketLocks(socketId: string): FieldLockInfo[];
|
||||
/**
|
||||
* Get all active (non-expired) locks for a board/tickets.
|
||||
*/
|
||||
static getActiveLocks(): FieldLockInfo[];
|
||||
}
|
||||
Vendored
+89
@@ -0,0 +1,89 @@
|
||||
export class LockManager {
|
||||
// Map of lockKey (`${ticketId}:${field}`) -> FieldLockInfo
|
||||
static locks = new Map();
|
||||
// Auto-expire locks after 15 seconds of inactivity to prevent ghost locks
|
||||
static LOCK_EXPIRATION_MS = 15000;
|
||||
static getLockKey(ticketId, field) {
|
||||
return `${ticketId}:${field}`;
|
||||
}
|
||||
/**
|
||||
* Attempt to acquire or refresh a lock.
|
||||
* Returns { success: boolean, lock?: FieldLockInfo, currentLock?: FieldLockInfo }
|
||||
*/
|
||||
static acquireLock(ticketId, field, socketId, userId, userName) {
|
||||
const lockKey = this.getLockKey(ticketId, field);
|
||||
const existing = this.locks.get(lockKey);
|
||||
const now = Date.now();
|
||||
// If lock exists and hasn't expired
|
||||
if (existing && now - existing.lockedAt < this.LOCK_EXPIRATION_MS) {
|
||||
// If the current socket or user holds it, refresh the timestamp
|
||||
if (existing.socketId === socketId || existing.userId === userId) {
|
||||
existing.lockedAt = now;
|
||||
existing.userName = userName;
|
||||
return { success: true, lock: existing };
|
||||
}
|
||||
// Held by another user
|
||||
return { success: false, currentHolder: existing };
|
||||
}
|
||||
// Lock is either free or expired, acquire it
|
||||
const newLock = {
|
||||
ticketId,
|
||||
field,
|
||||
socketId,
|
||||
userId,
|
||||
userName,
|
||||
lockedAt: now,
|
||||
};
|
||||
this.locks.set(lockKey, newLock);
|
||||
return { success: true, lock: newLock };
|
||||
}
|
||||
/**
|
||||
* Release a lock by ticketId and field.
|
||||
*/
|
||||
static releaseLock(ticketId, field, socketId) {
|
||||
const lockKey = this.getLockKey(ticketId, field);
|
||||
const existing = this.locks.get(lockKey);
|
||||
if (!existing)
|
||||
return null;
|
||||
// If socketId specified, verify ownership
|
||||
if (socketId && existing.socketId !== socketId) {
|
||||
// If it has expired, allow release
|
||||
if (Date.now() - existing.lockedAt >= this.LOCK_EXPIRATION_MS) {
|
||||
this.locks.delete(lockKey);
|
||||
return existing;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
this.locks.delete(lockKey);
|
||||
return existing;
|
||||
}
|
||||
/**
|
||||
* Release all locks held by a disconnected socket.
|
||||
*/
|
||||
static releaseSocketLocks(socketId) {
|
||||
const released = [];
|
||||
for (const [key, lock] of this.locks.entries()) {
|
||||
if (lock.socketId === socketId) {
|
||||
released.push(lock);
|
||||
this.locks.delete(key);
|
||||
}
|
||||
}
|
||||
return released;
|
||||
}
|
||||
/**
|
||||
* Get all active (non-expired) locks for a board/tickets.
|
||||
*/
|
||||
static getActiveLocks() {
|
||||
const now = Date.now();
|
||||
const active = [];
|
||||
for (const [key, lock] of this.locks.entries()) {
|
||||
if (now - lock.lockedAt < this.LOCK_EXPIRATION_MS) {
|
||||
active.push(lock);
|
||||
}
|
||||
else {
|
||||
this.locks.delete(key);
|
||||
}
|
||||
}
|
||||
return active;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user