edits for suppliers & projects
This commit is contained in:
@@ -3,11 +3,169 @@ 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({
|
||||
@@ -37,6 +195,9 @@ router.get('/projects/:id', authorize('projects', 'projects', 'read'), async (re
|
||||
notes: true,
|
||||
},
|
||||
});
|
||||
if (!project) {
|
||||
throw new AppError(404, 'المشروع غير موجود - Project not found');
|
||||
}
|
||||
res.json(ResponseFormatter.success(project));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -45,18 +206,30 @@ router.get('/projects/:id', authorize('projects', 'projects', 'read'), async (re
|
||||
|
||||
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: { ...req.body, projectNumber },
|
||||
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);
|
||||
@@ -65,33 +238,141 @@ router.post('/projects', authorize('projects', 'projects', 'create'), async (req
|
||||
|
||||
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: req.body,
|
||||
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;
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where,
|
||||
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: { email: true, username: true } },
|
||||
assignedTo: { select: { id: true, email: true, username: true } },
|
||||
phase: true,
|
||||
parent: true,
|
||||
children: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(ResponseFormatter.success(tasks));
|
||||
if (!task) {
|
||||
throw new AppError(404, 'المهمة غير موجودة - Task not found');
|
||||
}
|
||||
res.json(ResponseFormatter.success(task));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -99,12 +380,18 @@ router.get('/tasks', authorize('projects', 'tasks', 'read'), async (req, res, ne
|
||||
|
||||
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: { ...req.body, taskNumber },
|
||||
data: { ...data, taskNumber },
|
||||
include: { project: true, assignedTo: true },
|
||||
});
|
||||
|
||||
|
||||
// Create notification for assigned user
|
||||
if (task.assignedToId) {
|
||||
await prisma.notification.create({
|
||||
@@ -118,7 +405,14 @@ router.post('/tasks', authorize('projects', 'tasks', 'create'), async (req, res,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
@@ -127,15 +421,41 @@ router.post('/tasks', authorize('projects', 'tasks', 'create'), async (req, res,
|
||||
|
||||
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: req.body,
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user