edits for suppliers & projects

This commit is contained in:
Aya
2026-05-20 11:41:38 +03:00
parent 12c4ca8334
commit 61ca570e7a
10 changed files with 1733 additions and 70 deletions

View File

@@ -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;

View File

@@ -194,6 +194,19 @@ export class TendersController {
}
}
async getAssignableEmployees(
_req: AuthRequest,
res: Response,
next: NextFunction
) {
try {
const employees = await tendersService.getAssignableEmployees();
res.json(ResponseFormatter.success(employees));
} catch (error) {
next(error);
}
}
async uploadTenderAttachment(req: AuthRequest, res: Response, next: NextFunction) {
try {
if (!req.file) {

View File

@@ -56,6 +56,16 @@ router.get(
tendersController.getDirectiveTypeValues
);
// Minimal employee list for directive assignee dropdown.
// Guarded by directive-create permission so users who can issue directives
// can populate the dropdown WITHOUT being granted hr:employees:read
// (which would expose salaries, national IDs, and other sensitive HR data).
router.get(
'/assignable-employees',
authorize('tenders', 'directives', 'create'),
tendersController.getAssignableEmployees
);
router.post(
'/check-duplicates',
authorize('tenders', 'tenders', 'create'),

View File

@@ -670,6 +670,27 @@ private getEffectiveTenderStatus(tender: {
return [...DIRECTIVE_TYPE_VALUES];
}
/**
* Returns a minimal employee list (id + names only) for filling the
* directive assignee dropdown. Intentionally does NOT include salary,
* national ID, passport, email, phone, or any other sensitive HR fields
* so this endpoint can be exposed to anyone with directive-create
* permission without leaking HR data.
*/
async getAssignableEmployees() {
return prisma.employee.findMany({
where: { status: 'ACTIVE' },
select: {
id: true,
firstName: true,
lastName: true,
firstNameAr: true,
lastNameAr: true,
},
orderBy: [{ firstName: 'asc' }, { lastName: 'asc' }],
});
}
async convertToDeal(
tenderId: string,
data: { contactId: string; pipelineId: string; ownerId?: string },