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 { Express } from 'express';
export declare function createApp(): Express;
+58
View File
@@ -0,0 +1,58 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import boardRoutes from './routes/boardRoutes.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export function createApp() {
const app = express();
// CORS configuration
app.use(cors({
origin: (origin, callback) => {
callback(null, true);
},
credentials: true,
}));
app.use(express.json());
// Health check endpoint
app.get('/health', (_req, res) => {
res.json({
status: 'ok',
service: 'retro-kanban-server',
timestamp: new Date().toISOString(),
});
});
// API Routes
app.use('/api/boards', boardRoutes);
// Serve static frontend assets in production (when client build exists)
const clientDistPaths = [
path.join(__dirname, '../public'),
path.join(__dirname, '../../client/dist'),
path.join(__dirname, '../client/dist'),
path.join(process.cwd(), 'client/dist'),
path.join(process.cwd(), 'public'),
];
let staticPath = clientDistPaths.find((p) => fs.existsSync(p));
if (staticPath) {
console.log(`📁 [Static Assets] Serving frontend from: ${staticPath}`);
app.use(express.static(staticPath));
// SPA fallback: any non-API route serves index.html
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api') || req.path.startsWith('/socket.io')) {
return next();
}
res.sendFile(path.join(staticPath, 'index.html'));
});
}
// Global Error Handler
app.use((err, _req, res, _next) => {
console.error('Unhandled server error:', err);
res.status(500).json({
error: 'Internal Server Error',
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
});
});
return app;
}
+7
View File
@@ -0,0 +1,7 @@
export declare const config: {
port: number;
nodeEnv: string;
clientOrigin: string;
databaseUrl: string;
rateLimitMaxBoardsPerHour: number;
};
+9
View File
@@ -0,0 +1,9 @@
import dotenv from 'dotenv';
dotenv.config();
export const config = {
port: parseInt(process.env.PORT || '4000', 10),
nodeEnv: process.env.NODE_ENV || 'development',
clientOrigin: process.env.CLIENT_ORIGIN || 'http://localhost:5173',
databaseUrl: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/retro_kanban?schema=public',
rateLimitMaxBoardsPerHour: parseInt(process.env.RATE_LIMIT_MAX_BOARDS_PER_HOUR || '5', 10),
};
+39
View File
@@ -0,0 +1,39 @@
import { Request, Response } from 'express';
import { AuthenticatedRequest } from '../middleware/auth.js';
export declare class BoardController {
/**
* POST /api/boards
* Create a new board with rate-limiting.
*/
static createBoard(req: Request, res: Response): Promise<void>;
/**
* GET /api/boards/:id
* Fetch board data using authorization key.
*/
static getBoard(req: AuthenticatedRequest, res: Response): Promise<void>;
/**
* GET /api/boards/:id/activity
* Fetch recent activity / movement logs for board.
*/
static getBoardActivity(req: AuthenticatedRequest, res: Response): Promise<void>;
/**
* GET /api/boards/:id/tickets/:ticketId/activity
* Fetch movement logs for specific ticket.
*/
static getTicketActivity(req: AuthenticatedRequest, res: Response): Promise<void>;
/**
* POST /api/boards/:id/rotate-member-key
* Rotate member key (Admin only).
*/
static rotateMemberKey(req: AuthenticatedRequest, res: Response): Promise<void>;
/**
* DELETE /api/boards/:id
* Delete board (Admin only).
*/
static deleteBoard(req: AuthenticatedRequest, res: Response): Promise<void>;
/**
* PATCH /api/boards/:id
* Update board title (Admin only).
*/
static updateBoard(req: AuthenticatedRequest, res: Response): Promise<void>;
}
+120
View File
@@ -0,0 +1,120 @@
import { BoardService } from '../services/boardService.js';
import { KanbanService } from '../services/kanbanService.js';
export class BoardController {
/**
* POST /api/boards
* Create a new board with rate-limiting.
*/
static async createBoard(req, res) {
try {
const { title } = req.body;
const result = await BoardService.createBoard(title);
res.status(201).json(result);
}
catch (error) {
console.error('Error creating board:', error);
res.status(500).json({ error: 'Failed to create board' });
}
}
/**
* GET /api/boards/:id
* Fetch board data using authorization key.
*/
static async getBoard(req, res) {
try {
const { boardId, role } = req.boardAuth;
const board = await BoardService.getBoardDetails(boardId, role);
if (!board) {
res.status(404).json({ error: 'Board not found' });
return;
}
res.json(board);
}
catch (error) {
console.error('Error getting board:', error);
res.status(500).json({ error: 'Failed to fetch board' });
}
}
/**
* GET /api/boards/:id/activity
* Fetch recent activity / movement logs for board.
*/
static async getBoardActivity(req, res) {
try {
const { boardId } = req.boardAuth;
const limit = parseInt(req.query.limit, 10) || 50;
const logs = await KanbanService.getBoardActivityLogs(boardId, limit);
res.json(logs);
}
catch (error) {
console.error('Error getting board activity:', error);
res.status(500).json({ error: 'Failed to fetch board activity logs' });
}
}
/**
* GET /api/boards/:id/tickets/:ticketId/activity
* Fetch movement logs for specific ticket.
*/
static async getTicketActivity(req, res) {
try {
const { ticketId } = req.params;
const limit = parseInt(req.query.limit, 10) || 30;
const logs = await KanbanService.getTicketActivityLogs(ticketId, limit);
res.json(logs);
}
catch (error) {
console.error('Error getting ticket activity:', error);
res.status(500).json({ error: 'Failed to fetch ticket activity logs' });
}
}
/**
* POST /api/boards/:id/rotate-member-key
* Rotate member key (Admin only).
*/
static async rotateMemberKey(req, res) {
try {
const { boardId } = req.boardAuth;
const result = await BoardService.rotateMemberKey(boardId);
res.json(result);
}
catch (error) {
console.error('Error rotating member key:', error);
res.status(500).json({ error: 'Failed to rotate member key' });
}
}
/**
* DELETE /api/boards/:id
* Delete board (Admin only).
*/
static async deleteBoard(req, res) {
try {
const { boardId } = req.boardAuth;
await BoardService.deleteBoard(boardId);
res.json({ success: true, message: 'Board deleted successfully' });
}
catch (error) {
console.error('Error deleting board:', error);
res.status(500).json({ error: 'Failed to delete board' });
}
}
/**
* PATCH /api/boards/:id
* Update board title (Admin only).
*/
static async updateBoard(req, res) {
try {
const { boardId } = req.boardAuth;
const { title } = req.body;
if (!title || !title.trim()) {
res.status(400).json({ error: 'Title is required' });
return;
}
await BoardService.updateBoard(boardId, { title });
res.json({ success: true, title: title.trim() });
}
catch (error) {
console.error('Error updating board:', error);
res.status(500).json({ error: 'Failed to update board title' });
}
}
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Clean up inactive boards that have not been touched/updated in the last 30 days.
* Cascades to delete all associated columns and tickets.
*/
export declare function cleanupInactiveBoards(daysInactive?: number): Promise<number>;
/**
* Schedule periodic TTL cleanup in the server process (runs every 24 hours).
*/
export declare function startTtlCleanupSchedule(intervalHours?: number): NodeJS.Timeout;
+51
View File
@@ -0,0 +1,51 @@
import { prisma } from '../prisma.js';
/**
* Clean up inactive boards that have not been touched/updated in the last 30 days.
* Cascades to delete all associated columns and tickets.
*/
export async function cleanupInactiveBoards(daysInactive = 30) {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysInactive);
console.log(`🧹 [TTL Cleanup] Checking for boards inactive since ${cutoffDate.toISOString()}...`);
try {
const result = await prisma.board.deleteMany({
where: {
updatedAt: {
lt: cutoffDate,
},
},
});
console.log(`🧹 [TTL Cleanup] Removed ${result.count} inactive boards (> ${daysInactive} days old).`);
return result.count;
}
catch (error) {
console.error('❌ [TTL Cleanup Error]:', error);
return 0;
}
}
/**
* Schedule periodic TTL cleanup in the server process (runs every 24 hours).
*/
export function startTtlCleanupSchedule(intervalHours = 24) {
console.log(`⏰ [TTL Schedule] Inactive board cleaner registered (Interval: every ${intervalHours}h)`);
// Run once after 1 minute on startup
setTimeout(() => {
cleanupInactiveBoards().catch(console.error);
}, 60000);
// Then run every intervalHours
return setInterval(() => {
cleanupInactiveBoards().catch(console.error);
}, intervalHours * 60 * 60 * 1000);
}
// Standalone execution support: `tsx src/cron/cleanup.ts`
if (process.argv[1]?.includes('cleanup.ts') || process.argv[1]?.includes('cleanup.js')) {
cleanupInactiveBoards()
.then((count) => {
console.log(`Done. Cleaned ${count} boards.`);
process.exit(0);
})
.catch((err) => {
console.error(err);
process.exit(1);
});
}
+1
View File
@@ -0,0 +1 @@
export {};
+22
View File
@@ -0,0 +1,22 @@
import http from 'http';
import { Server as SocketIOServer } from 'socket.io';
import { createApp } from './app.js';
import { config } from './config.js';
import { setupSocketHandlers } from './socket/index.js';
import { startTtlCleanupSchedule } from './cron/cleanup.js';
const app = createApp();
const server = http.createServer(app);
const io = new SocketIOServer(server, {
cors: {
origin: '*',
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
},
});
// Setup real-time Socket.io handlers & authorization
setupSocketHandlers(io);
// Start periodic 30-day TTL cleanup (runs every 24 hours)
startTtlCleanupSchedule(24);
server.listen(config.port, () => {
console.log(`🎮 [Retro Kanban Server] Running on http://localhost:${config.port}`);
console.log(`🛡️ Environment: ${config.nodeEnv}`);
});
+15
View File
@@ -0,0 +1,15 @@
import { Request, Response, NextFunction } from 'express';
import { Role } from '../types/index.js';
export interface AuthenticatedRequest extends Request {
boardAuth?: {
boardId: string;
role: Role;
board: any;
};
}
/**
* Middleware to require board authorization.
* Role parameter defaults to allowing both 'admin' and 'member'.
* Pass 'admin' to restrict to superadmin only.
*/
export declare function requireBoardAuth(requiredRole?: Role): (req: AuthenticatedRequest, res: Response, next: NextFunction) => Promise<void>;
+55
View File
@@ -0,0 +1,55 @@
import { BoardService } from '../services/boardService.js';
/**
* Middleware to require board authorization.
* Role parameter defaults to allowing both 'admin' and 'member'.
* Pass 'admin' to restrict to superadmin only.
*/
export function requireBoardAuth(requiredRole) {
return async (req, res, next) => {
try {
const boardId = (req.params.boardId || req.params.id || req.body.boardId || req.query.boardId);
if (!boardId) {
res.status(400).json({ error: 'Missing board ID' });
return;
}
// Extract key from header, query, or body
let key = '';
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
key = authHeader.substring(7).trim();
}
else if (req.headers['x-board-key']) {
key = String(req.headers['x-board-key']).trim();
}
else if (req.query.key) {
key = String(req.query.key).trim();
}
else if (req.body.key) {
key = String(req.body.key).trim();
}
if (!key) {
res.status(401).json({ error: 'Unauthorized: Key is missing in request headers' });
return;
}
const authResult = await BoardService.authenticateKey(boardId, key);
if (!authResult) {
res.status(403).json({ error: 'Forbidden: Invalid key for this board' });
return;
}
if (requiredRole && requiredRole === 'admin' && authResult.role !== 'admin') {
res.status(403).json({ error: 'Forbidden: Superadmin access required' });
return;
}
req.boardAuth = {
boardId,
role: authResult.role,
board: authResult.board,
};
next();
}
catch (error) {
console.error('Error in auth middleware:', error);
res.status(500).json({ error: 'Internal Server Error during authentication' });
}
};
}
+1
View File
@@ -0,0 +1 @@
export declare const createBoardLimiter: import("express-rate-limit").RateLimitRequestHandler;
+12
View File
@@ -0,0 +1,12 @@
import rateLimit from 'express-rate-limit';
import { config } from '../config.js';
export const createBoardLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour window
max: config.rateLimitMaxBoardsPerHour, // max 5 boards per IP per hour
standardHeaders: true,
legacyHeaders: false,
message: {
error: 'Rate Limit Exceeded',
message: `You have reached the limit of ${config.rateLimitMaxBoardsPerHour} boards per hour. Please wait before creating another board.`,
},
});
+2
View File
@@ -0,0 +1,2 @@
import { PrismaClient } from '@prisma/client';
export declare const prisma: PrismaClient<import(".prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
+8
View File
@@ -0,0 +1,8 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global;
export const prisma = globalForPrisma.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
});
if (process.env.NODE_ENV !== 'production')
globalForPrisma.prisma = prisma;
+2
View File
@@ -0,0 +1,2 @@
declare const router: import("express-serve-static-core").Router;
export default router;
+20
View File
@@ -0,0 +1,20 @@
import { Router } from 'express';
import { BoardController } from '../controllers/boardController.js';
import { createBoardLimiter } from '../middleware/rateLimit.js';
import { requireBoardAuth } from '../middleware/auth.js';
const router = Router();
// Create new board (Rate limited: 5 boards/hour/IP)
router.post('/', createBoardLimiter, BoardController.createBoard);
// Get board details (Requires admin or member key)
router.get('/:id', requireBoardAuth(), BoardController.getBoard);
// Get board activity logs
router.get('/:id/activity', requireBoardAuth(), BoardController.getBoardActivity);
// Get ticket activity logs
router.get('/:id/tickets/:ticketId/activity', requireBoardAuth(), BoardController.getTicketActivity);
// Update board title (Requires admin key)
router.patch('/:id', requireBoardAuth('admin'), BoardController.updateBoard);
// Rotate member key (Requires admin key)
router.post('/:id/rotate-member-key', requireBoardAuth('admin'), BoardController.rotateMemberKey);
// Delete board (Requires admin key)
router.delete('/:id', requireBoardAuth('admin'), BoardController.deleteBoard);
export default router;
+36
View File
@@ -0,0 +1,36 @@
import { BoardCreationResult, BoardPayload, Role } from '../types/index.js';
export declare class BoardService {
/**
* Create a new Kanban board with admin and member keys and standard agile columns with WIP limits.
*/
static createBoard(title?: string): Promise<BoardCreationResult>;
/**
* Authenticate a key against a board ID. Returns the role ('admin' | 'member') or null if invalid.
*/
static authenticateKey(boardId: string, key: string): Promise<{
role: Role;
board: any;
} | null>;
/**
* Fetch full board data with columns and tickets ordered properly.
*/
static getBoardDetails(boardId: string, role: Role): Promise<BoardPayload | null>;
/**
* Regenerate member key (Key Rotation). Only superadmin can perform this.
*/
static rotateMemberKey(boardId: string): Promise<{
newMemberKey: string;
memberUrlFragment: string;
}>;
/**
* Delete a board. Only superadmin can perform this.
*/
static deleteBoard(boardId: string): Promise<void>;
/**
* Update board title & settings.
*/
static updateBoard(boardId: string, updates: {
title?: string;
autoMoveStale?: boolean;
}): Promise<void>;
}
+169
View File
@@ -0,0 +1,169 @@
import { prisma } from '../prisma.js';
import { generateSecureKey, hashKey, verifyKeyHash } from '../utils/crypto.js';
export class BoardService {
/**
* Create a new Kanban board with admin and member keys and standard agile columns with WIP limits.
*/
static async createBoard(title = 'Agile Kanban Board') {
const adminKey = generateSecureKey(32);
const memberKey = generateSecureKey(32);
const adminKeyHash = hashKey(adminKey);
const memberKeyHash = hashKey(memberKey);
const board = await prisma.board.create({
data: {
title: title.trim() || 'Agile Kanban Board',
adminKeyHash,
memberKeyHash,
autoMoveStale: false,
columns: {
create: [
{ title: '📋 Backlog', order: 0, maxWipLimit: 0, autoStaleHours: 0 },
{ title: '📝 To Do', order: 1, maxWipLimit: 0, autoStaleHours: 0 },
{ title: '⚡ In Progress', order: 2, maxWipLimit: 3, autoStaleHours: 24 }, // Standard 3-card WIP limit & 24h stale
{ title: '🔍 Review', order: 3, maxWipLimit: 4, autoStaleHours: 24 },
{ title: '✅ Done', order: 4, maxWipLimit: 0, autoStaleHours: 0 },
],
},
},
});
return {
id: board.id,
title: board.title,
adminKey,
memberKey,
adminUrlFragment: `/b/${board.id}#admin=${adminKey}`,
memberUrlFragment: `/b/${board.id}#member=${memberKey}`,
createdAt: board.createdAt.toISOString(),
};
}
/**
* Authenticate a key against a board ID. Returns the role ('admin' | 'member') or null if invalid.
*/
static async authenticateKey(boardId, key) {
if (!boardId || !key)
return null;
const board = await prisma.board.findUnique({
where: { id: boardId },
});
if (!board)
return null;
if (verifyKeyHash(key, board.adminKeyHash)) {
return { role: 'admin', board };
}
if (verifyKeyHash(key, board.memberKeyHash)) {
return { role: 'member', board };
}
return null;
}
/**
* Fetch full board data with columns and tickets ordered properly.
*/
static async getBoardDetails(boardId, role) {
const board = await prisma.board.findUnique({
where: { id: boardId },
include: {
columns: {
orderBy: { order: 'asc' },
include: {
tickets: {
orderBy: { order: 'asc' },
},
},
},
},
});
if (!board)
return null;
const columns = board.columns.map((col) => ({
id: col.id,
boardId: col.boardId,
title: col.title,
order: col.order,
maxWipLimit: col.maxWipLimit || 0,
autoStaleHours: col.autoStaleHours || 0,
createdAt: col.createdAt.toISOString(),
updatedAt: col.updatedAt.toISOString(),
tickets: col.tickets.map((t) => {
let parsedTags = [];
let parsedSubtasks = [];
try {
parsedTags = JSON.parse(t.tags || '[]');
}
catch {
parsedTags = [];
}
try {
parsedSubtasks = JSON.parse(t.subtasks || '[]');
}
catch {
parsedSubtasks = [];
}
return {
id: t.id,
columnId: t.columnId,
title: t.title,
description: t.description,
order: t.order,
priority: t.priority,
tags: parsedTags,
subtasks: parsedSubtasks,
dueDate: t.dueDate || null,
isBlocked: t.isBlocked || false,
blockedReason: t.blockedReason || null,
assignee: t.assignee,
movedAt: t.movedAt ? t.movedAt.toISOString() : t.createdAt.toISOString(),
createdAt: t.createdAt.toISOString(),
updatedAt: t.updatedAt.toISOString(),
};
}),
}));
return {
id: board.id,
title: board.title,
role,
autoMoveStale: board.autoMoveStale || false,
columns,
createdAt: board.createdAt.toISOString(),
updatedAt: board.updatedAt.toISOString(),
};
}
/**
* Regenerate member key (Key Rotation). Only superadmin can perform this.
*/
static async rotateMemberKey(boardId) {
const newMemberKey = generateSecureKey(32);
const newMemberKeyHash = hashKey(newMemberKey);
await prisma.board.update({
where: { id: boardId },
data: {
memberKeyHash: newMemberKeyHash,
},
});
return {
newMemberKey,
memberUrlFragment: `/b/${boardId}#member=${newMemberKey}`,
};
}
/**
* Delete a board. Only superadmin can perform this.
*/
static async deleteBoard(boardId) {
await prisma.board.delete({
where: { id: boardId },
});
}
/**
* Update board title & settings.
*/
static async updateBoard(boardId, updates) {
const data = {};
if (updates.title !== undefined)
data.title = updates.title.trim() || 'Agile Kanban Board';
if (updates.autoMoveStale !== undefined)
data.autoMoveStale = updates.autoMoveStale;
await prisma.board.update({
where: { id: boardId },
data,
});
}
}
+73
View File
@@ -0,0 +1,73 @@
import { Priority, TicketPayload, ColumnPayload, Subtask, ActivityLogPayload } from '../types/index.js';
export declare class KanbanService {
/**
* Create a new column in a board.
*/
static createColumn(boardId: string, title: string, maxWipLimit?: number, autoStaleHours?: number): Promise<ColumnPayload>;
/**
* Update a column (title, maxWipLimit, autoStaleHours).
*/
static updateColumn(columnId: string, updates: {
title?: string;
maxWipLimit?: number;
autoStaleHours?: number;
}): Promise<ColumnPayload>;
/**
* Delete a column and its tickets.
*/
static deleteColumn(columnId: string): Promise<string>;
/**
* Create a new ticket in a column and record activity log.
*/
static createTicket(columnId: string, title: string, description?: string, priority?: Priority, tags?: string[], assignee?: string, dueDate?: string, subtasks?: Subtask[], isBlocked?: boolean, blockedReason?: string, userName?: string): Promise<{
ticket: TicketPayload;
activity?: ActivityLogPayload;
}>;
/**
* Update ticket fields.
*/
static updateTicket(ticketId: string, updates: {
title?: string;
description?: string;
priority?: Priority;
tags?: string[];
subtasks?: Subtask[];
dueDate?: string | null;
isBlocked?: boolean;
blockedReason?: string | null;
assignee?: string | null;
}, userName?: string): Promise<{
ticket: TicketPayload;
activity?: ActivityLogPayload;
}>;
/**
* Delete a ticket.
*/
static deleteTicket(ticketId: string, userName?: string): Promise<{
boardId: string;
activity?: ActivityLogPayload;
}>;
/**
* Move ticket: changes column and/or reorders within a column.
* Updates orders in batch transaction, resets movedAt timestamp, and records movement log in DB.
*/
static moveTicket(ticketId: string, targetColumnId: string, newOrder: number, ticketIdsInTargetColumn: string[], movedBy?: string): Promise<ActivityLogPayload | null>;
/**
* Get recent activity logs for a board.
*/
static getBoardActivityLogs(boardId: string, limit?: number): Promise<ActivityLogPayload[]>;
/**
* Get activity logs for a specific ticket.
*/
static getTicketActivityLogs(ticketId: string, limit?: number): Promise<ActivityLogPayload[]>;
/**
* Reorder columns in a board.
*/
static reorderColumns(boardId: string, columnIdsInOrder: string[]): Promise<void>;
/**
* Check for stale tickets in active columns and auto-move them to Backlog if enabled.
*/
static processStaleTickets(boardId: string): Promise<{
movedTicketIds: string[];
}>;
}
+520
View File
@@ -0,0 +1,520 @@
import { prisma } from '../prisma.js';
export class KanbanService {
/**
* Create a new column in a board.
*/
static async createColumn(boardId, title, maxWipLimit = 0, autoStaleHours = 0) {
const lastCol = await prisma.column.findFirst({
where: { boardId },
orderBy: { order: 'desc' },
});
const nextOrder = lastCol ? lastCol.order + 1 : 0;
const col = await prisma.column.create({
data: {
boardId,
title: title.trim() || 'New Column',
order: nextOrder,
maxWipLimit: Math.max(0, maxWipLimit),
autoStaleHours: Math.max(0, autoStaleHours),
},
});
// Touch board updatedAt
await prisma.board.update({
where: { id: boardId },
data: { updatedAt: new Date() },
});
return {
id: col.id,
boardId: col.boardId,
title: col.title,
order: col.order,
maxWipLimit: col.maxWipLimit,
autoStaleHours: col.autoStaleHours,
tickets: [],
createdAt: col.createdAt.toISOString(),
updatedAt: col.updatedAt.toISOString(),
};
}
/**
* Update a column (title, maxWipLimit, autoStaleHours).
*/
static async updateColumn(columnId, updates) {
const data = {};
if (updates.title !== undefined)
data.title = updates.title.trim();
if (updates.maxWipLimit !== undefined)
data.maxWipLimit = Math.max(0, updates.maxWipLimit);
if (updates.autoStaleHours !== undefined)
data.autoStaleHours = Math.max(0, updates.autoStaleHours);
const col = await prisma.column.update({
where: { id: columnId },
data,
include: {
tickets: {
orderBy: { order: 'asc' },
},
},
});
await prisma.board.update({
where: { id: col.boardId },
data: { updatedAt: new Date() },
});
return {
id: col.id,
boardId: col.boardId,
title: col.title,
order: col.order,
maxWipLimit: col.maxWipLimit,
autoStaleHours: col.autoStaleHours,
tickets: col.tickets.map((t) => ({
id: t.id,
columnId: t.columnId,
title: t.title,
description: t.description,
order: t.order,
priority: t.priority,
tags: JSON.parse(t.tags || '[]'),
subtasks: JSON.parse(t.subtasks || '[]'),
dueDate: t.dueDate,
isBlocked: t.isBlocked,
blockedReason: t.blockedReason,
assignee: t.assignee,
movedAt: t.movedAt.toISOString(),
createdAt: t.createdAt.toISOString(),
updatedAt: t.updatedAt.toISOString(),
})),
createdAt: col.createdAt.toISOString(),
updatedAt: col.updatedAt.toISOString(),
};
}
/**
* Delete a column and its tickets.
*/
static async deleteColumn(columnId) {
const col = await prisma.column.delete({
where: { id: columnId },
});
await prisma.board.update({
where: { id: col.boardId },
data: { updatedAt: new Date() },
});
return col.boardId;
}
/**
* Create a new ticket in a column and record activity log.
*/
static async createTicket(columnId, title, description = '', priority = 'MEDIUM', tags = [], assignee = '', dueDate = '', subtasks = [], isBlocked = false, blockedReason = '', userName) {
const lastTicket = await prisma.ticket.findFirst({
where: { columnId },
orderBy: { order: 'desc' },
});
const nextOrder = lastTicket ? lastTicket.order + 1 : 0;
const ticket = await prisma.ticket.create({
data: {
columnId,
title: title.trim() || 'New Task',
description: description || '',
order: nextOrder,
priority,
tags: JSON.stringify(tags),
subtasks: JSON.stringify(subtasks),
dueDate: dueDate || null,
isBlocked: isBlocked || false,
blockedReason: blockedReason || null,
assignee: assignee || null,
movedAt: new Date(),
},
include: {
column: true,
},
});
// Create activity log in DB
let activityPayload;
try {
const act = await prisma.activityLog.create({
data: {
boardId: ticket.column.boardId,
ticketId: ticket.id,
ticketTitle: ticket.title,
action: 'CREATED',
toColumnTitle: ticket.column.title,
userName: userName || 'User',
details: `Tugas "${ticket.title}" dibuat di kolom "${ticket.column.title}"`,
},
});
activityPayload = {
id: act.id,
boardId: act.boardId,
ticketId: act.ticketId,
ticketTitle: act.ticketTitle,
action: act.action,
fromColumnTitle: act.fromColumnTitle,
toColumnTitle: act.toColumnTitle,
userName: act.userName,
details: act.details,
createdAt: act.createdAt.toISOString(),
};
}
catch (err) {
console.error('Failed to log ticket creation activity:', err);
}
// Touch board updatedAt
await prisma.board.update({
where: { id: ticket.column.boardId },
data: { updatedAt: new Date() },
});
return {
ticket: {
id: ticket.id,
columnId: ticket.columnId,
title: ticket.title,
description: ticket.description,
order: ticket.order,
priority: ticket.priority,
tags,
subtasks,
dueDate: ticket.dueDate,
isBlocked: ticket.isBlocked,
blockedReason: ticket.blockedReason,
assignee: ticket.assignee,
movedAt: ticket.movedAt.toISOString(),
createdAt: ticket.createdAt.toISOString(),
updatedAt: ticket.updatedAt.toISOString(),
},
activity: activityPayload,
};
}
/**
* Update ticket fields.
*/
static async updateTicket(ticketId, updates, userName) {
const data = {};
if (updates.title !== undefined)
data.title = updates.title;
if (updates.description !== undefined)
data.description = updates.description;
if (updates.priority !== undefined)
data.priority = updates.priority;
if (updates.tags !== undefined)
data.tags = JSON.stringify(updates.tags);
if (updates.subtasks !== undefined)
data.subtasks = JSON.stringify(updates.subtasks);
if (updates.dueDate !== undefined)
data.dueDate = updates.dueDate;
if (updates.isBlocked !== undefined)
data.isBlocked = updates.isBlocked;
if (updates.blockedReason !== undefined)
data.blockedReason = updates.blockedReason;
if (updates.assignee !== undefined)
data.assignee = updates.assignee;
const existingTicket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: { column: true },
});
if (!existingTicket)
throw new Error('Ticket not found');
const ticket = await prisma.ticket.update({
where: { id: ticketId },
data,
include: { column: true },
});
// Touch board updatedAt
await prisma.board.update({
where: { id: ticket.column.boardId },
data: { updatedAt: new Date() },
});
let activityPayload;
if (updates.isBlocked !== undefined && updates.isBlocked !== existingTicket.isBlocked) {
try {
const act = await prisma.activityLog.create({
data: {
boardId: ticket.column.boardId,
ticketId: ticket.id,
ticketTitle: ticket.title,
action: updates.isBlocked ? 'BLOCKED' : 'UNBLOCKED',
toColumnTitle: ticket.column.title,
userName: userName || 'User',
details: updates.isBlocked
? `Status tugas ditandai MACET / BLOCKED (${updates.blockedReason || 'Tanpa keterangan'})`
: 'Status macet diatasi (Unblocked)',
},
});
activityPayload = {
id: act.id,
boardId: act.boardId,
ticketId: act.ticketId,
ticketTitle: act.ticketTitle,
action: act.action,
fromColumnTitle: act.fromColumnTitle,
toColumnTitle: act.toColumnTitle,
userName: act.userName,
details: act.details,
createdAt: act.createdAt.toISOString(),
};
}
catch (err) {
console.error('Failed to log blocked status activity:', err);
}
}
let parsedTags = [];
let parsedSubtasks = [];
try {
parsedTags = JSON.parse(ticket.tags || '[]');
}
catch {
parsedTags = [];
}
try {
parsedSubtasks = JSON.parse(ticket.subtasks || '[]');
}
catch {
parsedSubtasks = [];
}
return {
ticket: {
id: ticket.id,
columnId: ticket.columnId,
title: ticket.title,
description: ticket.description,
order: ticket.order,
priority: ticket.priority,
tags: parsedTags,
subtasks: parsedSubtasks,
dueDate: ticket.dueDate,
isBlocked: ticket.isBlocked,
blockedReason: ticket.blockedReason,
assignee: ticket.assignee,
movedAt: ticket.movedAt.toISOString(),
createdAt: ticket.createdAt.toISOString(),
updatedAt: ticket.updatedAt.toISOString(),
},
activity: activityPayload,
};
}
/**
* Delete a ticket.
*/
static async deleteTicket(ticketId, userName) {
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: { column: true },
});
if (!ticket)
throw new Error('Ticket not found');
const boardId = ticket.column.boardId;
let activityPayload;
try {
const act = await prisma.activityLog.create({
data: {
boardId,
ticketId: ticket.id,
ticketTitle: ticket.title,
action: 'DELETED',
fromColumnTitle: ticket.column.title,
userName: userName || 'User',
details: `Tugas "${ticket.title}" dihapus dari kolom "${ticket.column.title}"`,
},
});
activityPayload = {
id: act.id,
boardId: act.boardId,
ticketId: act.ticketId,
ticketTitle: act.ticketTitle,
action: act.action,
fromColumnTitle: act.fromColumnTitle,
toColumnTitle: act.toColumnTitle,
userName: act.userName,
details: act.details,
createdAt: act.createdAt.toISOString(),
};
}
catch (err) {
console.error('Failed to log delete activity:', err);
}
await prisma.ticket.delete({
where: { id: ticketId },
});
await prisma.board.update({
where: { id: boardId },
data: { updatedAt: new Date() },
});
return { boardId, activity: activityPayload };
}
/**
* Move ticket: changes column and/or reorders within a column.
* Updates orders in batch transaction, resets movedAt timestamp, and records movement log in DB.
*/
static async moveTicket(ticketId, targetColumnId, newOrder, ticketIdsInTargetColumn, movedBy) {
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: { column: true },
});
if (!ticket)
throw new Error('Ticket not found');
const sourceBoardId = ticket.column.boardId;
const isChangingColumn = ticket.columnId !== targetColumnId;
let targetColumn = ticket.column;
if (isChangingColumn) {
const foundTarget = await prisma.column.findUnique({
where: { id: targetColumnId },
});
if (foundTarget)
targetColumn = foundTarget;
}
let createdActivity = null;
await prisma.$transaction(async (tx) => {
// 1. Move target ticket to new column and update movedAt timestamp if changing column
await tx.ticket.update({
where: { id: ticketId },
data: {
columnId: targetColumnId,
order: newOrder,
movedAt: isChangingColumn ? new Date() : ticket.movedAt,
},
});
// 2. Re-assign sequential orders to all tickets in target column
if (ticketIdsInTargetColumn && ticketIdsInTargetColumn.length > 0) {
for (let i = 0; i < ticketIdsInTargetColumn.length; i++) {
const id = ticketIdsInTargetColumn[i];
await tx.ticket.update({
where: { id },
data: {
columnId: targetColumnId,
order: i,
},
});
}
}
// 3. Save Movement Log to DB if column changed
if (isChangingColumn) {
const act = await tx.activityLog.create({
data: {
boardId: sourceBoardId,
ticketId: ticket.id,
ticketTitle: ticket.title,
action: 'MOVED',
fromColumnTitle: ticket.column.title,
toColumnTitle: targetColumn.title,
userName: movedBy || 'User',
details: `Dipindahkan dari "${ticket.column.title}" ke "${targetColumn.title}"`,
},
});
createdActivity = {
id: act.id,
boardId: act.boardId,
ticketId: act.ticketId,
ticketTitle: act.ticketTitle,
action: act.action,
fromColumnTitle: act.fromColumnTitle,
toColumnTitle: act.toColumnTitle,
userName: act.userName,
details: act.details,
createdAt: act.createdAt.toISOString(),
};
}
// Touch board
await tx.board.update({
where: { id: sourceBoardId },
data: { updatedAt: new Date() },
});
});
return createdActivity;
}
/**
* Get recent activity logs for a board.
*/
static async getBoardActivityLogs(boardId, limit = 50) {
const logs = await prisma.activityLog.findMany({
where: { boardId },
orderBy: { createdAt: 'desc' },
take: Math.min(100, Math.max(1, limit)),
});
return logs.map((log) => ({
id: log.id,
boardId: log.boardId,
ticketId: log.ticketId,
ticketTitle: log.ticketTitle,
action: log.action,
fromColumnTitle: log.fromColumnTitle,
toColumnTitle: log.toColumnTitle,
userName: log.userName,
details: log.details,
createdAt: log.createdAt.toISOString(),
}));
}
/**
* Get activity logs for a specific ticket.
*/
static async getTicketActivityLogs(ticketId, limit = 30) {
const logs = await prisma.activityLog.findMany({
where: { ticketId },
orderBy: { createdAt: 'desc' },
take: Math.min(50, Math.max(1, limit)),
});
return logs.map((log) => ({
id: log.id,
boardId: log.boardId,
ticketId: log.ticketId,
ticketTitle: log.ticketTitle,
action: log.action,
fromColumnTitle: log.fromColumnTitle,
toColumnTitle: log.toColumnTitle,
userName: log.userName,
details: log.details,
createdAt: log.createdAt.toISOString(),
}));
}
/**
* Reorder columns in a board.
*/
static async reorderColumns(boardId, columnIdsInOrder) {
await prisma.$transaction(async (tx) => {
for (let i = 0; i < columnIdsInOrder.length; i++) {
const id = columnIdsInOrder[i];
await tx.column.update({
where: { id },
data: { order: i },
});
}
await tx.board.update({
where: { id: boardId },
data: { updatedAt: new Date() },
});
});
}
/**
* Check for stale tickets in active columns and auto-move them to Backlog if enabled.
*/
static async processStaleTickets(boardId) {
const board = await prisma.board.findUnique({
where: { id: boardId },
include: {
columns: {
orderBy: { order: 'asc' },
include: { tickets: true },
},
},
});
if (!board || board.columns.length < 2)
return { movedTicketIds: [] };
const backlogColumn = board.columns[0];
const movedTicketIds = [];
const now = Date.now();
for (let i = 1; i < board.columns.length; i++) {
const col = board.columns[i];
if (col.autoStaleHours && col.autoStaleHours > 0) {
const thresholdMs = col.autoStaleHours * 60 * 60 * 1000;
for (const ticket of col.tickets) {
const ticketMovedAt = new Date(ticket.movedAt || ticket.createdAt).getTime();
const ageMs = now - ticketMovedAt;
if (ageMs >= thresholdMs && board.autoMoveStale) {
// Auto move to backlog
await this.moveTicket(ticket.id, backlogColumn.id, 0, [ticket.id, ...backlogColumn.tickets.map((t) => t.id)], 'Sistem (Auto-Stale)');
movedTicketIds.push(ticket.id);
}
}
}
}
return { movedTicketIds };
}
}
+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;
}
}
+1
View File
@@ -0,0 +1 @@
export {};
+50
View File
@@ -0,0 +1,50 @@
import assert from 'assert';
import { generateSecureKey, hashKey, verifyKeyHash } from '../utils/crypto.js';
import { LockManager } from '../socket/lockManager.js';
async function runTests() {
console.log('🧪 Starting Logic & Security Verification Tests...\n');
// Test 1: Key Generation & SHA-256 Hashing
console.log('Test 1: Key generation and SHA-256 hashing...');
const key1 = generateSecureKey(32);
const key2 = generateSecureKey(32);
assert.notStrictEqual(key1, key2, 'Keys must be uniquely generated');
assert(key1.length > 20, 'Key length should be substantial');
const hash1 = hashKey(key1);
const hash2 = hashKey(key2);
assert.notStrictEqual(hash1, hash2, 'Hashes must differ');
assert.strictEqual(hash1, hashKey(key1), 'Hash function must be deterministic');
assert.strictEqual(verifyKeyHash(key1, hash1), true, 'Key verification must succeed for valid key');
assert.strictEqual(verifyKeyHash('wrong-key', hash1), false, 'Key verification must fail for invalid key');
console.log('✅ Test 1 Passed: Crypto and Hashing work properly!\n');
// Test 2: In-Memory Field-Level Lock Manager
console.log('Test 2: Field-Level Lock Manager & Concurrency...');
const ticketId = 'ticket-uuid-123';
const userA = { socketId: 'sock-A', userId: 'user-A', userName: 'Player One' };
const userB = { socketId: 'sock-B', userId: 'user-B', userName: 'Player Two' };
// User A acquires lock
const resA = LockManager.acquireLock(ticketId, 'description', userA.socketId, userA.userId, userA.userName);
assert.strictEqual(resA.success, true, 'User A should acquire free lock');
assert.strictEqual(resA.lock?.userId, 'user-A');
// User B tries to acquire same lock
const resB = LockManager.acquireLock(ticketId, 'description', userB.socketId, userB.userId, userB.userName);
assert.strictEqual(resB.success, false, 'User B must be rejected while User A holds lock');
assert.strictEqual(resB.currentHolder?.userId, 'user-A');
// User A refreshes lock
const resARefresh = LockManager.acquireLock(ticketId, 'description', userA.socketId, userA.userId, userA.userName);
assert.strictEqual(resARefresh.success, true, 'User A should be able to refresh own lock');
// User A releases lock
const released = LockManager.releaseLock(ticketId, 'description', userA.socketId);
assert.notStrictEqual(released, null, 'Lock should be released');
// Now User B can acquire lock
const resB2 = LockManager.acquireLock(ticketId, 'description', userB.socketId, userB.userId, userB.userName);
assert.strictEqual(resB2.success, true, 'User B should acquire lock after release');
// Socket disconnect cleanup
const cleaned = LockManager.releaseSocketLocks(userB.socketId);
assert.strictEqual(cleaned.length, 1, 'Should clean up 1 lock on disconnect');
console.log('✅ Test 2 Passed: LockManager concurrency & conflict resolution works!\n');
console.log('🎉 All backend logic tests passed successfully!');
}
runTests().catch((err) => {
console.error('❌ Test failed:', err);
process.exit(1);
});
+79
View File
@@ -0,0 +1,79 @@
export type Role = 'admin' | 'member';
export type Priority = 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
export interface Subtask {
id: string;
title: string;
completed: boolean;
}
export interface ActivityLogPayload {
id: string;
boardId: string;
ticketId?: string | null;
ticketTitle: string;
action: 'MOVED' | 'CREATED' | 'BLOCKED' | 'UNBLOCKED' | 'DELETED';
fromColumnTitle?: string | null;
toColumnTitle?: string | null;
userName: string;
details?: string | null;
createdAt: string;
}
export interface BoardCreationResult {
id: string;
title: string;
adminKey: string;
memberKey: string;
adminUrlFragment: string;
memberUrlFragment: string;
createdAt: string;
}
export interface BoardPayload {
id: string;
title: string;
role: Role;
autoMoveStale: boolean;
columns: ColumnPayload[];
createdAt: string;
updatedAt: string;
}
export interface ColumnPayload {
id: string;
boardId: string;
title: string;
order: number;
maxWipLimit: number;
autoStaleHours: number;
tickets: TicketPayload[];
createdAt: string;
updatedAt: string;
}
export interface TicketPayload {
id: string;
columnId: string;
title: string;
description: string;
order: number;
priority: Priority;
tags: string[];
subtasks: Subtask[];
dueDate?: string | null;
isBlocked?: boolean;
blockedReason?: string | null;
assignee?: string | null;
movedAt: string;
createdAt: string;
updatedAt: string;
}
export interface FieldLockInfo {
ticketId: string;
field: 'title' | 'description';
socketId: string;
userId: string;
userName: string;
lockedAt: number;
}
export interface SocketAuthData {
boardId: string;
key: string;
userName?: string;
userId?: string;
}
+1
View File
@@ -0,0 +1 @@
export {};
+12
View File
@@ -0,0 +1,12 @@
/**
* Generate a cryptographically secure random string for authorization keys.
*/
export declare function generateSecureKey(length?: number): string;
/**
* Hash a plain text key with SHA-256 for secure database storage.
*/
export declare function hashKey(key: string): string;
/**
* Constant-time hash verification to prevent timing attacks.
*/
export declare function verifyKeyHash(key: string, storedHash: string): boolean;
+22
View File
@@ -0,0 +1,22 @@
import crypto from 'crypto';
/**
* Generate a cryptographically secure random string for authorization keys.
*/
export function generateSecureKey(length = 32) {
return crypto.randomBytes(length).toString('base64url');
}
/**
* Hash a plain text key with SHA-256 for secure database storage.
*/
export function hashKey(key) {
return crypto.createHash('sha256').update(key.trim()).digest('hex');
}
/**
* Constant-time hash verification to prevent timing attacks.
*/
export function verifyKeyHash(key, storedHash) {
const computedHash = hashKey(key);
if (computedHash.length !== storedHash.length)
return false;
return crypto.timingSafeEqual(Buffer.from(computedHash, 'hex'), Buffer.from(storedHash, 'hex'));
}