import { Router } from 'express'; import { authenticate, authorize } from '../../shared/middleware/auth'; import prisma from '../../config/database'; import { ResponseFormatter } from '../../shared/utils/responseFormatter'; import { AuditLogger } from '../../shared/utils/auditLogger'; import { AppError } from '../../shared/middleware/errorHandler'; const router = Router(); router.use(authenticate); // ============================================================ // Helpers // ============================================================ // Convert a "YYYY-MM-DD" or ISO string to a Date at UTC midnight. // Prisma's @db.Date columns require a real DateTime; a bare "YYYY-MM-DD" // in JSON gets parsed to "Invalid data" by the validator. const toDate = (value: unknown): Date | null | undefined => { if (value === null) return null; if (value === undefined || value === '') return undefined; if (value instanceof Date) return value; if (typeof value !== 'string') return undefined; // Already ISO? if (value.includes('T')) { const d = new Date(value); return isNaN(d.getTime()) ? undefined : d; } // Plain "YYYY-MM-DD" const d = new Date(`${value}T00:00:00Z`); return isNaN(d.getTime()) ? undefined : d; }; // Drop empty strings, normalize known date/number fields, and only keep keys // that actually exist on the Prisma Task model. Anything extra (e.g. `tags`) // would otherwise crash Prisma with "Unknown argument". const sanitizeTaskBody = (body: any, opts: { isUpdate?: boolean } = {}) => { const out: any = {}; const setIfPresent = (key: string, value: any) => { if (value === undefined) return; out[key] = value; }; // Strings: collapse '' -> null (update) or skip (create) const strField = (key: string) => { if (!(key in body)) return; const v = body[key]; if (v === '' || v === null) { if (opts.isUpdate) out[key] = null; return; } if (typeof v === 'string') out[key] = v.trim(); }; strField('title'); strField('description'); strField('projectId'); strField('phaseId'); strField('parentId'); strField('assignedToId'); if ('status' in body && body.status) out.status = String(body.status); if ('priority' in body && body.priority) out.priority = String(body.priority); if ('progress' in body) { const n = Number(body.progress); if (!Number.isNaN(n)) out.progress = Math.max(0, Math.min(100, Math.round(n))); } if ('startDate' in body) { const d = toDate(body.startDate); if (d !== undefined) out.startDate = d; } if ('dueDate' in body) { const d = toDate(body.dueDate); if (d !== undefined) out.dueDate = d; } if ('completedDate' in body) { const d = toDate(body.completedDate); if (d !== undefined) out.completedDate = d; } if ('estimatedHours' in body) { const v = body.estimatedHours; if (v === '' || v === null) { if (opts.isUpdate) out.estimatedHours = null; } else { const n = Number(v); if (!Number.isNaN(n) && n >= 0) out.estimatedHours = n; } } if ('actualHours' in body) { const v = body.actualHours; if (v === '' || v === null) { if (opts.isUpdate) out.actualHours = null; } else { const n = Number(v); if (!Number.isNaN(n) && n >= 0) out.actualHours = n; } } // dependencies is Json? in the schema if ('dependencies' in body) out.dependencies = body.dependencies; return out; }; const sanitizeProjectBody = (body: any, opts: { isUpdate?: boolean } = {}) => { const out: any = {}; const strField = (key: string) => { if (!(key in body)) return; const v = body[key]; if (v === '' || v === null) { if (opts.isUpdate) out[key] = null; return; } if (typeof v === 'string') out[key] = v.trim(); }; // Required-ish strings if ('name' in body && body.name) out.name = String(body.name).trim(); if ('type' in body && body.type) out.type = String(body.type); strField('description'); strField('dealId'); strField('clientId'); if ('status' in body && body.status) out.status = String(body.status); if ('priority' in body && body.priority) out.priority = String(body.priority); if ('progress' in body) { const n = Number(body.progress); if (!Number.isNaN(n)) out.progress = Math.max(0, Math.min(100, Math.round(n))); } if ('startDate' in body) { const d = toDate(body.startDate); if (d !== undefined) out.startDate = d; } if ('endDate' in body) { const d = toDate(body.endDate); if (d !== undefined) out.endDate = d; } if ('actualEndDate' in body) { const d = toDate(body.actualEndDate); if (d !== undefined) out.actualEndDate = d; } const numField = (key: string) => { if (!(key in body)) return; const v = body[key]; if (v === '' || v === null) { if (opts.isUpdate) out[key] = null; return; } const n = Number(v); if (!Number.isNaN(n)) out[key] = n; }; numField('estimatedCost'); numField('actualCost'); return out; }; // ============================================================ // Projects // ============================================================ router.get('/projects', authorize('projects', 'projects', 'read'), async (req, res, next) => { try { const projects = await prisma.project.findMany({ include: { phases: true, tasks: { take: 10 }, members: { include: { user: true } }, }, orderBy: { createdAt: 'desc' }, }); res.json(ResponseFormatter.success(projects)); } catch (error) { next(error); } }); router.get('/projects/:id', authorize('projects', 'projects', 'read'), async (req, res, next) => { try { const project = await prisma.project.findUnique({ where: { id: req.params.id }, include: { phases: { include: { tasks: true } }, tasks: true, members: { include: { user: { include: { employee: true } } } }, expenses: true, attachments: true, notes: true, }, }); if (!project) { throw new AppError(404, 'المشروع غير موجود - Project not found'); } res.json(ResponseFormatter.success(project)); } catch (error) { next(error); } }); router.post('/projects', authorize('projects', 'projects', 'create'), async (req, res, next) => { try { const data = sanitizeProjectBody(req.body, { isUpdate: false }); if (!data.name) { throw new AppError(400, 'اسم المشروع مطلوب - Project name is required'); } if (!data.type) { throw new AppError(400, 'نوع المشروع مطلوب - Project type is required'); } if (!data.startDate) { throw new AppError(400, 'تاريخ البدء مطلوب - Start date is required'); } const projectNumber = `PRJ-${new Date().getFullYear()}-${Date.now().toString().slice(-6)}`; const project = await prisma.project.create({ data: { ...data, projectNumber }, }); await AuditLogger.log({ entityType: 'PROJECT', entityId: project.id, action: 'CREATE', userId: (req as any).user.id, }); res.status(201).json(ResponseFormatter.success(project)); } catch (error) { next(error); } }); router.put('/projects/:id', authorize('projects', 'projects', 'update'), async (req, res, next) => { try { const data = sanitizeProjectBody(req.body, { isUpdate: true }); const project = await prisma.project.update({ where: { id: req.params.id }, data, }); await AuditLogger.log({ entityType: 'PROJECT', entityId: project.id, action: 'UPDATE', userId: (req as any).user.id, }); res.json(ResponseFormatter.success(project)); } catch (error) { next(error); } }); router.delete( '/projects/:id', authorize('projects', 'projects', 'delete'), async (req, res, next) => { try { // Block delete when the project still has tasks / phases / etc. const counts = await prisma.project.findUnique({ where: { id: req.params.id }, select: { _count: { select: { tasks: true, phases: true, members: true, expenses: true, }, }, }, }); if (!counts) { throw new AppError(404, 'المشروع غير موجود - Project not found'); } const c = counts._count; if (c.tasks > 0 || c.phases > 0 || c.expenses > 0) { throw new AppError( 409, `لا يمكن حذف المشروع - يحتوي على ${c.tasks} مهمة، ${c.phases} مرحلة، ${c.expenses} مصروف. احذفها أولاً.`, ); } // Detach members (safe - they're just join rows) then delete if (c.members > 0) { await prisma.projectMember.deleteMany({ where: { projectId: req.params.id } }); } await prisma.project.delete({ where: { id: req.params.id } }); await AuditLogger.log({ entityType: 'PROJECT', entityId: req.params.id, action: 'DELETE', userId: (req as any).user.id, }); res.json(ResponseFormatter.success({ id: req.params.id }, 'تم حذف المشروع - Project deleted')); } catch (error) { next(error); } }, ); // ============================================================ // Tasks // ============================================================ router.get('/tasks', authorize('projects', 'tasks', 'read'), async (req, res, next) => { try { const page = Math.max(1, parseInt(String(req.query.page || '1'), 10) || 1); const pageSize = Math.min( 100, Math.max(1, parseInt(String(req.query.pageSize || '20'), 10) || 20), ); const where: any = {}; if (req.query.projectId) where.projectId = req.query.projectId; if (req.query.assignedToId) where.assignedToId = req.query.assignedToId; if (req.query.status) where.status = req.query.status; if (req.query.priority) where.priority = req.query.priority; if (req.query.search) { const q = String(req.query.search); where.OR = [ { title: { contains: q, mode: 'insensitive' } }, { description: { contains: q, mode: 'insensitive' } }, { taskNumber: { contains: q, mode: 'insensitive' } }, ]; } const [tasks, total] = await Promise.all([ prisma.task.findMany({ where, include: { project: true, assignedTo: { select: { id: true, email: true, username: true } }, }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), prisma.task.count({ where }), ]); res.json(ResponseFormatter.paginated(tasks, total, page, pageSize)); } catch (error) { next(error); } }); router.get('/tasks/:id', authorize('projects', 'tasks', 'read'), async (req, res, next) => { try { const task = await prisma.task.findUnique({ where: { id: req.params.id }, include: { project: true, assignedTo: { select: { id: true, email: true, username: true } }, phase: true, parent: true, children: true, }, }); if (!task) { throw new AppError(404, 'المهمة غير موجودة - Task not found'); } res.json(ResponseFormatter.success(task)); } catch (error) { next(error); } }); router.post('/tasks', authorize('projects', 'tasks', 'create'), async (req, res, next) => { try { const data = sanitizeTaskBody(req.body, { isUpdate: false }); if (!data.title) { throw new AppError(400, 'عنوان المهمة مطلوب - Task title is required'); } const taskNumber = `TSK-${new Date().getFullYear()}-${Date.now().toString().slice(-6)}`; const task = await prisma.task.create({ data: { ...data, taskNumber }, include: { project: true, assignedTo: true }, }); // Create notification for assigned user if (task.assignedToId) { await prisma.notification.create({ data: { userId: task.assignedToId, type: 'TASK_ASSIGNED', title: 'مهمة جديدة - New Task Assigned', message: `تم تعيينك لمهمة: ${task.title}`, entityType: 'TASK', entityId: task.id, }, }); } await AuditLogger.log({ entityType: 'TASK', entityId: task.id, action: 'CREATE', userId: (req as any).user.id, }); res.status(201).json(ResponseFormatter.success(task)); } catch (error) { next(error); } }); router.put('/tasks/:id', authorize('projects', 'tasks', 'update'), async (req, res, next) => { try { const data = sanitizeTaskBody(req.body, { isUpdate: true }); const task = await prisma.task.update({ where: { id: req.params.id }, data, include: { project: true, assignedTo: true }, }); await AuditLogger.log({ entityType: 'TASK', entityId: task.id, action: 'UPDATE', userId: (req as any).user.id, }); res.json(ResponseFormatter.success(task)); } catch (error) { next(error); } }); router.delete('/tasks/:id', authorize('projects', 'tasks', 'delete'), async (req, res, next) => { try { await prisma.task.delete({ where: { id: req.params.id } }); await AuditLogger.log({ entityType: 'TASK', entityId: req.params.id, action: 'DELETE', userId: (req as any).user.id, }); res.json(ResponseFormatter.success({ id: req.params.id }, 'تم حذف المهمة - Task deleted')); } catch (error) { next(error); } }); export default router;