first init
This commit is contained in:
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
import { Express } from 'express';
|
||||
export declare function createApp(): Express;
|
||||
Vendored
+58
@@ -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;
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
export declare const config: {
|
||||
port: number;
|
||||
nodeEnv: string;
|
||||
clientOrigin: string;
|
||||
databaseUrl: string;
|
||||
rateLimitMaxBoardsPerHour: number;
|
||||
};
|
||||
Vendored
+9
@@ -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
@@ -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
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -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;
|
||||
Vendored
+51
@@ -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);
|
||||
});
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export {};
|
||||
Vendored
+22
@@ -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}`);
|
||||
});
|
||||
Vendored
+15
@@ -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>;
|
||||
Vendored
+55
@@ -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
@@ -0,0 +1 @@
|
||||
export declare const createBoardLimiter: import("express-rate-limit").RateLimitRequestHandler;
|
||||
Vendored
+12
@@ -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.`,
|
||||
},
|
||||
});
|
||||
Vendored
+2
@@ -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>;
|
||||
Vendored
+8
@@ -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;
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
declare const router: import("express-serve-static-core").Router;
|
||||
export default router;
|
||||
Vendored
+20
@@ -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
@@ -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>;
|
||||
}
|
||||
Vendored
+169
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export {};
|
||||
Vendored
+50
@@ -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);
|
||||
});
|
||||
Vendored
+79
@@ -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;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export {};
|
||||
Vendored
+12
@@ -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;
|
||||
Vendored
+22
@@ -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'));
|
||||
}
|
||||
Reference in New Issue
Block a user