first init

This commit is contained in:
Power BI Dev
2026-08-17 10:52:09 +07:00
commit 88c7c653c2
9281 changed files with 2156575 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
import { Server } from 'socket.io';
export declare function setupSocketHandlers(io: Server): void;
+306
View File
@@ -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()));
}
}
});
});
}
+27
View File
@@ -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[];
}
+89
View File
@@ -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;
}
}