const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const UUID_SCHEMA = z.string().uuid(); const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const PROJECT_STATUS_SCHEMA = z.enum(["draft", "active", "paused", "completed", "archived"]); const PROJECT_ROLE_SCHEMA = z.enum(["owner", "manager", "member", "observer"]); const MILESTONE_STATUS_SCHEMA = z.enum(["pending", "in_progress", "done", "delayed", "cancelled"]); const TASK_STATUS_SCHEMA = z.enum(["todo", "doing", "blocked", "done", "cancelled"]); const TASK_PRIORITY_SCHEMA = z.enum(["low", "normal", "high", "urgent"]); const RISK_LEVEL_SCHEMA = z.enum(["low", "medium", "high"]); const DELIVERY_RECORD_TYPE_SCHEMA = z.enum(["kickoff", "onsite", "stage_acceptance", "final_acceptance", "handover"]); const LIST_QUERY_SCHEMA = z.object({ status: PROJECT_STATUS_SCHEMA.optional(), mine: z.union([z.literal("1"), z.literal("0")]).optional(), q: z.string().trim().max(100).optional(), }); const PROJECT_CREATE_SCHEMA = z.object({ projectNo: z.string().trim().max(40).optional(), name: z.string().trim().min(1).max(200), clientName: z.string().trim().min(1).max(200), clientAlias: z.string().trim().max(200).nullable().optional(), ownerEmployeeId: UUID_SCHEMA.optional(), managerEmployeeId: UUID_SCHEMA.nullable().optional(), status: PROJECT_STATUS_SCHEMA.optional(), startsAt: DATE_SCHEMA.nullable().optional(), targetEndAt: DATE_SCHEMA.nullable().optional(), actualEndAt: DATE_SCHEMA.nullable().optional(), summary: z.string().trim().max(4000).nullable().optional(), isConfidential: z.boolean().optional(), }).strict(); const PROJECT_PATCH_SCHEMA = PROJECT_CREATE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); const MEMBER_CREATE_SCHEMA = z.object({ employeeId: UUID_SCHEMA, role: PROJECT_ROLE_SCHEMA.optional(), }).strict(); const MEMBER_PATCH_SCHEMA = z.object({ role: PROJECT_ROLE_SCHEMA.optional(), leftAt: DATE_SCHEMA.nullable().optional(), }).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); const MILESTONE_CREATE_SCHEMA = z.object({ title: z.string().trim().min(1).max(200), description: z.string().trim().max(4000).nullable().optional(), status: MILESTONE_STATUS_SCHEMA.optional(), dueDate: DATE_SCHEMA.nullable().optional(), completedAt: z.string().datetime().nullable().optional(), ownerEmployeeId: UUID_SCHEMA.nullable().optional(), sortOrder: z.number().int().min(0).max(9999).optional(), }).strict(); const MILESTONE_PATCH_SCHEMA = MILESTONE_CREATE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); const TASK_CREATE_SCHEMA = z.object({ title: z.string().trim().min(1).max(200), description: z.string().trim().max(4000).nullable().optional(), status: TASK_STATUS_SCHEMA.optional(), priority: TASK_PRIORITY_SCHEMA.optional(), assigneeEmployeeId: UUID_SCHEMA.nullable().optional(), dueDate: DATE_SCHEMA.nullable().optional(), blockedReason: z.string().trim().max(1000).nullable().optional(), }).strict(); const TASK_PATCH_SCHEMA = TASK_CREATE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); const RISK_CREATE_SCHEMA = z.object({ title: z.string().trim().min(1).max(200), detail: z.string().trim().min(1).max(4000), level: RISK_LEVEL_SCHEMA.optional(), status: z.string().trim().min(1).max(32).optional(), ownerEmployeeId: UUID_SCHEMA.nullable().optional(), }).strict(); const RISK_PATCH_SCHEMA = RISK_CREATE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); const DELIVERY_CREATE_SCHEMA = z.object({ title: z.string().trim().min(1).max(200), recordType: DELIVERY_RECORD_TYPE_SCHEMA, summary: z.string().trim().min(1).max(4000), occurredAt: z.string().datetime(), ownerEmployeeId: UUID_SCHEMA.optional(), }).strict(); const COMMUNICATION_CHANNEL_SCHEMA = z.enum(["wechat", "phone", "meeting", "wecom", "email", "onsite", "other"]); const CLOSURE_CHECKLIST_STATUS_SCHEMA = z.enum(["pending", "done", "waived"]); const COMMUNICATION_LOG_CREATE_SCHEMA = z.object({ happenedAt: z.string().datetime(), channel: COMMUNICATION_CHANNEL_SCHEMA, externalContactName: z.string().trim().max(120).nullable().optional(), externalOrgName: z.string().trim().max(200).nullable().optional(), summary: z.string().trim().min(1).max(1000), nextAction: z.string().trim().max(300).nullable().optional(), nextFollowUpDate: DATE_SCHEMA.nullable().optional(), }).strict(); const COMMUNICATION_LOG_PATCH_SCHEMA = COMMUNICATION_LOG_CREATE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); const CLOSURE_CHECKLIST_PATCH_SCHEMA = z.object({ status: CLOSURE_CHECKLIST_STATUS_SCHEMA.optional(), note: z.string().trim().max(500).nullable().optional(), }).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); const PROJECT_CLOSURE_TEMPLATE = [ { itemKey: "delivery_record_checked", title: "确认交付/验收记录齐全", sortOrder: 10 }, { itemKey: "risk_review_done", title: "确认项目风险已收口或转监控", sortOrder: 20 }, { itemKey: "reimbursement_cleared", title: "确认关联报销已处理", sortOrder: 30 }, { itemKey: "travel_cleared", title: "确认关联差旅已处理", sortOrder: 40 }, { itemKey: "contract_archive_confirmed", title: "确认合同台账与归档位置已核对", sortOrder: 50 }, { itemKey: "handover_note_done", title: "确认项目交接说明已补齐", sortOrder: 60 }, ]; const PROJECT_COMMUNICATION_EDIT_WINDOW_MS = 24 * 60 * 60 * 1000; function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); } function parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return parsed.data; } function parseId(value, key = "id") { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { throw buildValidationError({ [key]: "invalid_uuid" }); } return parsed.data; } function parseDateOnly(value) { return value ? new Date(`${value}T00:00:00.000Z`) : null; } async function ensureFeatureEnabled(fastify, request, flagKey) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey, departmentId: request.employee.department_id ?? null, strictDepartment: false, }); if (!gate.enabled) { throw buildValidationError({ featureFlag: `${flagKey}_disabled`, reason: gate.reason }, 409); } } function hasGlobalProjectRead(fastify, employee) { return fastify.hasPermission(employee, "project.view_all") || fastify.hasPermission(employee, "project.manage") || fastify.hasPermission(employee, "system.settings"); } function hasGlobalProjectManage(fastify, employee) { return fastify.hasPermission(employee, "project.manage") || fastify.hasPermission(employee, "system.settings"); } function buildProjectDetailInclude() { return { owner: { select: { id: true, employee_no: true, name: true } }, manager: { select: { id: true, employee_no: true, name: true } }, members: { where: { left_at: null }, include: { employee: { select: { id: true, employee_no: true, name: true } } }, orderBy: [{ role: "asc" }, { joined_at: "asc" }], }, milestones: { orderBy: [{ sort_order: "asc" }, { due_date: "asc" }] }, tasks: { include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } }, }, orderBy: [{ priority: "desc" }, { due_date: "asc" }, { created_at: "desc" }], }, risks: { include: { owner: { select: { id: true, employee_no: true, name: true } } }, orderBy: [{ level: "desc" }, { created_at: "desc" }], }, _count: { select: { members: true, milestones: true, tasks: true, risks: true, reimbursements: true, purchase_requests: true, travel_requests: true, todo_items: true, weekly_reports: true, communication_logs: true, closure_checklist_items: true, contracts: true, deliveries: true, document_refs: true, }, }, }; } async function loadProjectContext(prisma, projectId) { return prisma.project.findUnique({ where: { id: projectId }, include: buildProjectDetailInclude(), }); } function canManageProjectRecord(fastify, requestEmployeeId, project) { if (!project) return false; if (project.owner_employee_id === requestEmployeeId) return true; if (project.manager_employee_id === requestEmployeeId) return true; return (project.members ?? []).some((item) => item.employee_id === requestEmployeeId && item.left_at == null && ["owner", "manager"].includes(item.role)); } async function ensureReadableProject(fastify, request, projectId) { const project = await loadProjectContext(fastify.prisma, projectId, request.employee.id); if (!project) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (hasGlobalProjectRead(fastify, request.employee)) { return project; } const isMember = project.owner_employee_id === request.employee.id || project.manager_employee_id === request.employee.id || project.members.some((item) => item.employee_id === request.employee.id && item.left_at == null); if (!isMember) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.view_own" } }); } return project; } async function ensureManageableProject(fastify, request, projectId) { const project = await ensureReadableProject(fastify, request, projectId); if (hasGlobalProjectManage(fastify, request.employee) || canManageProjectRecord(fastify, request.employee.id, project)) { return project; } throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } }); } async function writeAudit(fastify, request, action, targetId, extra = {}) { try { await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action, targetType: "project", targetId, extra, }), }); } catch (error) { request.log.error({ err: error, action, targetId }, "project audit failed"); } } async function buildProjectNo(prisma) { const now = new Date(); const day = now.toISOString().slice(0, 10).replace(/-/g, ""); const start = new Date(`${now.toISOString().slice(0, 10)}T00:00:00.000Z`); const end = new Date(`${now.toISOString().slice(0, 10)}T23:59:59.999Z`); const count = await prisma.project.count({ where: { created_at: { gte: start, lte: end } } }); return `PRJ-${day}-${String(count + 1).padStart(3, "0")}`; } function mapEmployeeBasic(employee) { if (!employee) return null; return { id: employee.id, employeeNo: employee.employee_no, name: employee.name }; } function startOfUtcDay(value = new Date()) { return new Date(`${value.toISOString().slice(0, 10)}T00:00:00.000Z`); } function endOfUtcDay(value = new Date()) { return new Date(`${value.toISOString().slice(0, 10)}T23:59:59.999Z`); } function mapProjectCommunicationLog(item, canEdit) { return { id: item.id, happenedAt: item.happened_at?.toISOString?.() ?? null, channel: item.channel, externalContactName: item.external_contact_name ?? null, externalOrgName: item.external_org_name ?? null, summary: item.summary, nextAction: item.next_action ?? null, nextFollowUpDate: item.next_follow_up_date?.toISOString?.().slice(0, 10) ?? null, createdBy: item.created_by, createdAt: item.created_at?.toISOString?.() ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null, canEdit, creator: mapEmployeeBasic(item.creator), }; } function mapProjectClosureChecklistItem(item, canEdit) { return { id: item.id, itemKey: item.item_key, title: item.title, status: item.status, note: item.note ?? null, sortOrder: item.sort_order, completedAt: item.completed_at?.toISOString?.() ?? null, completedBy: item.completed_by ?? null, canEdit, completer: mapEmployeeBasic(item.completer), }; } function canEditCommunicationLog(fastify, employee, project, item) { if (hasGlobalProjectManage(fastify, employee) || canManageProjectRecord(fastify, employee.id, project)) { return true; } if (item.created_by !== employee.id) { return false; } return Date.now() - new Date(item.created_at).getTime() <= PROJECT_COMMUNICATION_EDIT_WINDOW_MS; } async function ensureClosureChecklistBootstrapped(prisma, projectId) { const count = await prisma.projectClosureChecklistItem.count({ where: { project_id: projectId } }); if (count > 0) { return false; } await prisma.projectClosureChecklistItem.createMany({ data: PROJECT_CLOSURE_TEMPLATE.map((item) => ({ project_id: projectId, item_key: item.itemKey, title: item.title, sort_order: item.sortOrder, status: "pending", })), skipDuplicates: true, }); return true; } async function buildProjectCommunicationSummary(prisma, projectId) { const todayStart = startOfUtcDay(); const todayEnd = endOfUtcDay(); const [totalCount, latest, followUpDueTodayCount, overdueFollowUpCount] = await Promise.all([ prisma.projectCommunicationLog.count({ where: { project_id: projectId } }), prisma.projectCommunicationLog.findFirst({ where: { project_id: projectId }, orderBy: [{ happened_at: "desc" }, { updated_at: "desc" }] }), prisma.projectCommunicationLog.count({ where: { project_id: projectId, next_follow_up_date: { gte: todayStart, lte: todayEnd } } }), prisma.projectCommunicationLog.count({ where: { project_id: projectId, next_follow_up_date: { lt: todayStart } } }), ]); return { totalCount, latestHappenedAt: latest?.happened_at?.toISOString?.() ?? null, followUpDueTodayCount, overdueFollowUpCount, }; } async function buildProjectClosureSummary(prisma, projectId) { const items = await prisma.projectClosureChecklistItem.findMany({ where: { project_id: projectId }, select: { status: true } }); const pendingCount = items.filter((item) => item.status === "pending").length; const waivedCount = items.filter((item) => item.status === "waived").length; const [openRiskCount, openReimbursementCount, openTravelCount, deliveryRecordCount, contractCount, confidentialDocRefCount] = await Promise.all([ prisma.projectRisk.count({ where: { project_id: projectId, status: { not: "closed" } } }), prisma.reimbursementForm.count({ where: { project_id: projectId, status: { notIn: ["paid", "cancelled", "rejected"] } } }), prisma.travelRequest.count({ where: { project_id: projectId, status: { notIn: ["rejected"] } } }), prisma.projectDeliveryRecord.count({ where: { project_id: projectId } }), prisma.contractRegistry.count({ where: { project_id: projectId } }), prisma.confidentialDocumentRef.count({ where: { project_id: projectId } }), ]); return { totalCount: items.length, pendingCount, waivedCount, warned: pendingCount > 0, facts: { openRiskCount, openReimbursementCount, openTravelCount, deliveryRecordCount, contractCount, confidentialDocRefCount, }, }; } async function buildProjectDetailPayload(fastify, request, project) { const [communicationSummary, closureSummary, communicationGate, closureGate] = await Promise.all([ buildProjectCommunicationSummary(fastify.prisma, project.id), buildProjectClosureSummary(fastify.prisma, project.id), evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.projectCommunicationLog, departmentId: request.employee.department_id ?? null, strictDepartment: false }), evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.projectClosureChecklist, departmentId: request.employee.department_id ?? null, strictDepartment: false }), ]); return { ...mapProjectDetail(project), communicationSummary, closureSummary, capabilities: { communicationLogEnabled: communicationGate.enabled, closureChecklistEnabled: closureGate.enabled, }, }; } function mapProjectDetail(project) { return { id: project.id, projectNo: project.project_no, name: project.name, clientName: project.client_name, clientAlias: project.client_alias, ownerEmployeeId: project.owner_employee_id, managerEmployeeId: project.manager_employee_id, status: project.status, startsAt: project.starts_at?.toISOString?.().slice(0, 10) ?? null, targetEndAt: project.target_end_at?.toISOString?.().slice(0, 10) ?? null, actualEndAt: project.actual_end_at?.toISOString?.().slice(0, 10) ?? null, summary: project.summary ?? null, isConfidential: Boolean(project.is_confidential), createdAt: project.created_at?.toISOString?.() ?? null, updatedAt: project.updated_at?.toISOString?.() ?? null, owner: mapEmployeeBasic(project.owner), manager: mapEmployeeBasic(project.manager), counts: { members: project._count?.members ?? 0, milestones: project._count?.milestones ?? 0, tasks: project._count?.tasks ?? 0, risks: project._count?.risks ?? 0, reimbursements: project._count?.reimbursements ?? 0, purchaseRequests: project._count?.purchase_requests ?? 0, travelRequests: project._count?.travel_requests ?? 0, todoItems: project._count?.todo_items ?? 0, weeklyReports: project._count?.weekly_reports ?? 0, communicationLogs: project._count?.communication_logs ?? 0, closureChecklistItems: project._count?.closure_checklist_items ?? 0, contracts: project._count?.contracts ?? 0, deliveries: project._count?.deliveries ?? 0, documentRefs: project._count?.document_refs ?? 0, }, members: (project.members ?? []).map((item) => ({ id: item.id, employeeId: item.employee_id, role: item.role, joinedAt: item.joined_at?.toISOString?.() ?? null, leftAt: item.left_at?.toISOString?.() ?? null, employee: mapEmployeeBasic(item.employee), })), milestones: (project.milestones ?? []).map((item) => ({ id: item.id, title: item.title, description: item.description ?? null, status: item.status, dueDate: item.due_date?.toISOString?.().slice(0, 10) ?? null, completedAt: item.completed_at?.toISOString?.() ?? null, ownerEmployeeId: item.owner_employee_id ?? null, sortOrder: item.sort_order, })), tasks: (project.tasks ?? []).map((item) => ({ id: item.id, title: item.title, description: item.description ?? null, status: item.status, priority: item.priority, assigneeEmployeeId: item.assignee_employee_id ?? null, dueDate: item.due_date?.toISOString?.().slice(0, 10) ?? null, blockedReason: item.blocked_reason ?? null, createdBy: item.created_by, assignee: mapEmployeeBasic(item.assignee), creator: mapEmployeeBasic(item.creator), createdAt: item.created_at?.toISOString?.() ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null, })), risks: (project.risks ?? []).map((item) => ({ id: item.id, title: item.title, detail: item.detail, level: item.level, status: item.status, ownerEmployeeId: item.owner_employee_id ?? null, owner: mapEmployeeBasic(item.owner), createdAt: item.created_at?.toISOString?.() ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null, })), }; } async function projectsRoutes(fastify) { fastify.get("/projects", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); const parsed = LIST_QUERY_SCHEMA.safeParse(request.query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } const mine = parsed.data.mine === "1"; const where = { AND: [ parsed.data.status ? { status: parsed.data.status } : {}, parsed.data.q ? { OR: [ { project_no: { contains: parsed.data.q, mode: "insensitive" } }, { name: { contains: parsed.data.q, mode: "insensitive" } }, { client_name: { contains: parsed.data.q, mode: "insensitive" } }, { client_alias: { contains: parsed.data.q, mode: "insensitive" } }, ], } : {}, hasGlobalProjectRead(fastify, request.employee) && !mine ? {} : { OR: [ { owner_employee_id: request.employee.id }, { manager_employee_id: request.employee.id }, { members: { some: { employee_id: request.employee.id, left_at: null } } }, ], }, ], }; const rows = await fastify.prisma.project.findMany({ where, orderBy: [{ target_end_at: "asc" }, { updated_at: "desc" }], include: { owner: { select: { id: true, employee_no: true, name: true } }, manager: { select: { id: true, employee_no: true, name: true } }, _count: { select: { members: true, milestones: true, tasks: true, risks: true } }, }, }); const today = new Date(); const items = await Promise.all(rows.map(async (item) => { const overdueMilestones = await fastify.prisma.projectMilestone.count({ where: { project_id: item.id, status: { in: ["pending", "in_progress", "delayed"] }, due_date: { lt: today } } }); const highPriorityTasks = await fastify.prisma.projectTask.count({ where: { project_id: item.id, status: { in: ["todo", "doing", "blocked"] }, priority: { in: ["high", "urgent"] } } }); const openRisks = await fastify.prisma.projectRisk.count({ where: { project_id: item.id, status: { not: "closed" } } }); return { id: item.id, projectNo: item.project_no, name: item.name, clientName: item.client_name, clientAlias: item.client_alias, status: item.status, startsAt: item.starts_at?.toISOString?.().slice(0, 10) ?? null, targetEndAt: item.target_end_at?.toISOString?.().slice(0, 10) ?? null, actualEndAt: item.actual_end_at?.toISOString?.().slice(0, 10) ?? null, summary: item.summary ?? null, isConfidential: Boolean(item.is_confidential), owner: mapEmployeeBasic(item.owner), manager: mapEmployeeBasic(item.manager), counts: { members: item._count.members, milestones: item._count.milestones, tasks: item._count.tasks, risks: item._count.risks, overdueMilestones, highPriorityTasks, openRisks, }, updatedAt: item.updated_at?.toISOString?.() ?? null, }; })); return sendData(reply, request, { items }); }); fastify.post("/projects", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); if (!fastify.hasPermission(request.employee, "project.manage") && !fastify.hasPermission(request.employee, "system.settings") && !fastify.hasPermission(request.employee, "report.dashboard.view")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } }); } const body = parseBody(PROJECT_CREATE_SCHEMA, request.body); const created = await fastify.prisma.project.create({ data: { project_no: body.projectNo || await buildProjectNo(fastify.prisma), name: body.name, client_name: body.clientName, client_alias: body.clientAlias ?? null, owner_employee_id: body.ownerEmployeeId ?? request.employee.id, manager_employee_id: body.managerEmployeeId ?? null, status: body.status ?? "draft", starts_at: parseDateOnly(body.startsAt), target_end_at: parseDateOnly(body.targetEndAt), actual_end_at: parseDateOnly(body.actualEndAt), summary: body.summary ?? null, is_confidential: body.isConfidential ?? false, members: { create: { employee_id: body.ownerEmployeeId ?? request.employee.id, role: "owner", }, }, }, include: buildProjectDetailInclude(), }); await writeAudit(fastify, request, "PROJECT_CREATED", created.id, { projectNo: created.project_no }); const payload = await buildProjectDetailPayload(fastify, request, created); return reply.code(201).send({ requestId: request.id, data: { item: payload } }); }); fastify.get("/projects/:id", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); const projectId = parseId(request.params?.id); const project = await ensureReadableProject(fastify, request, projectId); return sendData(reply, request, { item: await buildProjectDetailPayload(fastify, request, project) }); }); fastify.patch("/projects/:id", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); const body = parseBody(PROJECT_PATCH_SCHEMA, request.body); const wantsClosureWarning = ["completed", "archived"].includes(body.status ?? ""); if (wantsClosureWarning) { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectClosureChecklist); await ensureClosureChecklistBootstrapped(fastify.prisma, projectId); } const updated = await fastify.prisma.project.update({ where: { id: projectId }, data: { ...(body.projectNo !== undefined ? { project_no: body.projectNo } : {}), ...(body.name !== undefined ? { name: body.name } : {}), ...(body.clientName !== undefined ? { client_name: body.clientName } : {}), ...(body.clientAlias !== undefined ? { client_alias: body.clientAlias ?? null } : {}), ...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId } : {}), ...(body.managerEmployeeId !== undefined ? { manager_employee_id: body.managerEmployeeId ?? null } : {}), ...(body.status !== undefined ? { status: body.status } : {}), ...(body.startsAt !== undefined ? { starts_at: parseDateOnly(body.startsAt) } : {}), ...(body.targetEndAt !== undefined ? { target_end_at: parseDateOnly(body.targetEndAt) } : {}), ...(body.actualEndAt !== undefined ? { actual_end_at: parseDateOnly(body.actualEndAt) } : {}), ...(body.summary !== undefined ? { summary: body.summary ?? null } : {}), ...(body.isConfidential !== undefined ? { is_confidential: body.isConfidential } : {}), }, include: buildProjectDetailInclude(), }); await writeAudit(fastify, request, "PROJECT_UPDATED", projectId, { fields: Object.keys(body) }); const item = await buildProjectDetailPayload(fastify, request, updated); const closureWarning = wantsClosureWarning && item.closureSummary.pendingCount > 0 ? { pendingCount: item.closureSummary.pendingCount, message: `项目仍有 ${item.closureSummary.pendingCount} 项待收口,请尽快补齐。` } : null; return sendData(reply, request, { item, closureWarning }); }); fastify.post("/projects/:id/archive", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); let closureWarning = null; const closureGate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.projectClosureChecklist, departmentId: request.employee.department_id ?? null, strictDepartment: false }); if (closureGate.enabled) { await ensureClosureChecklistBootstrapped(fastify.prisma, projectId); } const updated = await fastify.prisma.project.update({ where: { id: projectId }, data: { status: "archived", actual_end_at: new Date() }, include: buildProjectDetailInclude() }); const item = await buildProjectDetailPayload(fastify, request, updated); if (closureGate.enabled && item.closureSummary.pendingCount > 0) { closureWarning = { pendingCount: item.closureSummary.pendingCount, message: `项目仍有 ${item.closureSummary.pendingCount} 项待收口,请尽快补齐。` }; } await writeAudit(fastify, request, "PROJECT_ARCHIVED", projectId, { warned: Boolean(closureWarning) }); return sendData(reply, request, { item, closureWarning }); }); fastify.get("/projects/:id/members", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); const projectId = parseId(request.params?.id); const project = await ensureReadableProject(fastify, request, projectId); return sendData(reply, request, { items: mapProjectDetail(project).members }); }); fastify.post("/projects/:id/members", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); const body = parseBody(MEMBER_CREATE_SCHEMA, request.body); const item = await fastify.prisma.projectMember.upsert({ where: { project_id_employee_id: { project_id: projectId, employee_id: body.employeeId } }, update: { role: body.role ?? "member", left_at: null }, create: { project_id: projectId, employee_id: body.employeeId, role: body.role ?? "member" }, include: { employee: { select: { id: true, employee_no: true, name: true } } }, }); await writeAudit(fastify, request, "PROJECT_MEMBER_UPSERTED", projectId, { memberId: item.id, employeeId: item.employee_id }); return reply.code(201).send({ requestId: request.id, data: { item: { id: item.id, employeeId: item.employee_id, role: item.role, joinedAt: item.joined_at?.toISOString?.() ?? null, leftAt: item.left_at?.toISOString?.() ?? null, employee: mapEmployeeBasic(item.employee) } } }); }); fastify.patch("/projects/:id/members/:memberId", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); const memberId = parseId(request.params?.memberId, "memberId"); const body = parseBody(MEMBER_PATCH_SCHEMA, request.body); const updated = await fastify.prisma.projectMember.update({ where: { id: memberId }, data: { ...(body.role !== undefined ? { role: body.role } : {}), ...(body.leftAt !== undefined ? { left_at: parseDateOnly(body.leftAt) } : {}), }, include: { employee: { select: { id: true, employee_no: true, name: true } } }, }); return sendData(reply, request, { item: { id: updated.id, employeeId: updated.employee_id, role: updated.role, joinedAt: updated.joined_at?.toISOString?.() ?? null, leftAt: updated.left_at?.toISOString?.() ?? null, employee: mapEmployeeBasic(updated.employee) } }); }); fastify.get("/projects/:id/milestones", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); const projectId = parseId(request.params?.id); const project = await ensureReadableProject(fastify, request, projectId); return sendData(reply, request, { items: mapProjectDetail(project).milestones }); }); fastify.post("/projects/:id/milestones", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); const body = parseBody(MILESTONE_CREATE_SCHEMA, request.body); const created = await fastify.prisma.projectMilestone.create({ data: { project_id: projectId, title: body.title, description: body.description ?? null, status: body.status ?? "pending", due_date: parseDateOnly(body.dueDate), completed_at: body.completedAt ? new Date(body.completedAt) : null, owner_employee_id: body.ownerEmployeeId ?? null, sort_order: body.sortOrder ?? 100, }, }); return reply.code(201).send({ requestId: request.id, data: { item: { id: created.id, title: created.title, description: created.description ?? null, status: created.status, dueDate: created.due_date?.toISOString?.().slice(0, 10) ?? null, completedAt: created.completed_at?.toISOString?.() ?? null, ownerEmployeeId: created.owner_employee_id ?? null, sortOrder: created.sort_order } } }); }); fastify.patch("/projects/:id/milestones/:milestoneId", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); const milestoneId = parseId(request.params?.milestoneId, "milestoneId"); const body = parseBody(MILESTONE_PATCH_SCHEMA, request.body); const updated = await fastify.prisma.projectMilestone.update({ where: { id: milestoneId }, data: { ...(body.title !== undefined ? { title: body.title } : {}), ...(body.description !== undefined ? { description: body.description ?? null } : {}), ...(body.status !== undefined ? { status: body.status } : {}), ...(body.dueDate !== undefined ? { due_date: parseDateOnly(body.dueDate) } : {}), ...(body.completedAt !== undefined ? { completed_at: body.completedAt ? new Date(body.completedAt) : null } : {}), ...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId ?? null } : {}), ...(body.sortOrder !== undefined ? { sort_order: body.sortOrder } : {}), }, }); return sendData(reply, request, { item: { id: updated.id, title: updated.title, description: updated.description ?? null, status: updated.status, dueDate: updated.due_date?.toISOString?.().slice(0, 10) ?? null, completedAt: updated.completed_at?.toISOString?.() ?? null, ownerEmployeeId: updated.owner_employee_id ?? null, sortOrder: updated.sort_order } }); }); fastify.get("/projects/:id/tasks", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); const projectId = parseId(request.params?.id); const project = await ensureReadableProject(fastify, request, projectId); return sendData(reply, request, { items: mapProjectDetail(project).tasks }); }); fastify.post("/projects/:id/tasks", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); const body = parseBody(TASK_CREATE_SCHEMA, request.body); const created = await fastify.prisma.projectTask.create({ data: { project_id: projectId, title: body.title, description: body.description ?? null, status: body.status ?? "todo", priority: body.priority ?? "normal", assignee_employee_id: body.assigneeEmployeeId ?? null, due_date: parseDateOnly(body.dueDate), blocked_reason: body.blockedReason ?? null, created_by: request.employee.id, }, include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } }, }); return reply.code(201).send({ requestId: request.id, data: { item: { id: created.id, title: created.title, description: created.description ?? null, status: created.status, priority: created.priority, assigneeEmployeeId: created.assignee_employee_id ?? null, dueDate: created.due_date?.toISOString?.().slice(0, 10) ?? null, blockedReason: created.blocked_reason ?? null, createdBy: created.created_by, assignee: mapEmployeeBasic(created.assignee), creator: mapEmployeeBasic(created.creator), createdAt: created.created_at?.toISOString?.() ?? null, updatedAt: created.updated_at?.toISOString?.() ?? null } } }); }); fastify.patch("/projects/:id/tasks/:taskId", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); const taskId = parseId(request.params?.taskId, "taskId"); const body = parseBody(TASK_PATCH_SCHEMA, request.body); const updated = await fastify.prisma.projectTask.update({ where: { id: taskId }, data: { ...(body.title !== undefined ? { title: body.title } : {}), ...(body.description !== undefined ? { description: body.description ?? null } : {}), ...(body.status !== undefined ? { status: body.status } : {}), ...(body.priority !== undefined ? { priority: body.priority } : {}), ...(body.assigneeEmployeeId !== undefined ? { assignee_employee_id: body.assigneeEmployeeId ?? null } : {}), ...(body.dueDate !== undefined ? { due_date: parseDateOnly(body.dueDate) } : {}), ...(body.blockedReason !== undefined ? { blocked_reason: body.blockedReason ?? null } : {}), }, include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } }, }); return sendData(reply, request, { item: { id: updated.id, title: updated.title, description: updated.description ?? null, status: updated.status, priority: updated.priority, assigneeEmployeeId: updated.assignee_employee_id ?? null, dueDate: updated.due_date?.toISOString?.().slice(0, 10) ?? null, blockedReason: updated.blocked_reason ?? null, createdBy: updated.created_by, assignee: mapEmployeeBasic(updated.assignee), creator: mapEmployeeBasic(updated.creator), createdAt: updated.created_at?.toISOString?.() ?? null, updatedAt: updated.updated_at?.toISOString?.() ?? null } }); }); fastify.get("/projects/:id/risks", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); const projectId = parseId(request.params?.id); const project = await ensureReadableProject(fastify, request, projectId); return sendData(reply, request, { items: mapProjectDetail(project).risks }); }); fastify.post("/projects/:id/risks", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); const body = parseBody(RISK_CREATE_SCHEMA, request.body); const created = await fastify.prisma.projectRisk.create({ data: { project_id: projectId, title: body.title, detail: body.detail, level: body.level ?? "medium", status: body.status ?? "open", owner_employee_id: body.ownerEmployeeId ?? null, }, include: { owner: { select: { id: true, employee_no: true, name: true } } }, }); return reply.code(201).send({ requestId: request.id, data: { item: { id: created.id, title: created.title, detail: created.detail, level: created.level, status: created.status, ownerEmployeeId: created.owner_employee_id ?? null, owner: mapEmployeeBasic(created.owner), createdAt: created.created_at?.toISOString?.() ?? null, updatedAt: created.updated_at?.toISOString?.() ?? null } } }); }); fastify.patch("/projects/:id/risks/:riskId", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); const riskId = parseId(request.params?.riskId, "riskId"); const body = parseBody(RISK_PATCH_SCHEMA, request.body); const updated = await fastify.prisma.projectRisk.update({ where: { id: riskId }, data: { ...(body.title !== undefined ? { title: body.title } : {}), ...(body.detail !== undefined ? { detail: body.detail } : {}), ...(body.level !== undefined ? { level: body.level } : {}), ...(body.status !== undefined ? { status: body.status } : {}), ...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId ?? null } : {}), }, include: { owner: { select: { id: true, employee_no: true, name: true } } }, }); return sendData(reply, request, { item: { id: updated.id, title: updated.title, detail: updated.detail, level: updated.level, status: updated.status, ownerEmployeeId: updated.owner_employee_id ?? null, owner: mapEmployeeBasic(updated.owner), createdAt: updated.created_at?.toISOString?.() ?? null, updatedAt: updated.updated_at?.toISOString?.() ?? null } }); }); fastify.get("/projects/:id/expenses/summary", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking); const projectId = parseId(request.params?.id); await ensureReadableProject(fastify, request, projectId); const items = await fastify.prisma.reimbursementForm.findMany({ where: { project_id: projectId }, orderBy: { updated_at: "desc" }, select: { id: true, form_number: true, status: true, total_amount: true, reimbursement_date: true, updated_at: true }, }); const totalAmount = items.reduce((sum, item) => sum + Number(item.total_amount ?? 0), 0); return sendData(reply, request, { totalAmount: totalAmount.toFixed(2), items: items.map((item) => ({ id: item.id, formNumber: item.form_number ?? item.id, status: item.status, totalAmount: String(item.total_amount ?? 0), reimbursementDate: item.reimbursement_date?.toISOString?.().slice(0, 10) ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null })) }); }); fastify.get("/projects/:id/travel/summary", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking); const projectId = parseId(request.params?.id); await ensureReadableProject(fastify, request, projectId); const items = await fastify.prisma.travelRequest.findMany({ where: { project_id: projectId }, orderBy: { updated_at: "desc" }, select: { id: true, request_no: true, status: true, estimated_amount: true, start_date: true, end_date: true, updated_at: true }, }); const totalAmount = items.reduce((sum, item) => sum + Number(item.estimated_amount ?? 0), 0); return sendData(reply, request, { totalAmount: totalAmount.toFixed(2), items: items.map((item) => ({ id: item.id, requestNo: item.request_no ?? item.id, status: item.status, estimatedAmount: String(item.estimated_amount ?? 0), startDate: item.start_date?.toISOString?.().slice(0, 10) ?? null, endDate: item.end_date?.toISOString?.().slice(0, 10) ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null })) }); }); fastify.get("/projects/:id/purchase-requests/summary", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking); const projectId = parseId(request.params?.id); await ensureReadableProject(fastify, request, projectId); const items = await fastify.prisma.purchaseRequest.findMany({ where: { project_id: projectId }, orderBy: { updated_at: "desc" }, select: { id: true, request_no: true, title: true, status: true, total_amount: true, updated_at: true }, }); const totalAmount = items.reduce((sum, item) => sum + Number(item.total_amount ?? 0), 0); return sendData(reply, request, { totalAmount: totalAmount.toFixed(2), items: items.map((item) => ({ id: item.id, requestNo: item.request_no ?? item.id, title: item.title, status: item.status, totalAmount: String(item.total_amount ?? 0), updatedAt: item.updated_at?.toISOString?.() ?? null, })), }); }); fastify.get("/projects/:id/weekly-reports", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking); const projectId = parseId(request.params?.id); await ensureReadableProject(fastify, request, projectId); const items = await fastify.prisma.projectLinkOnWeeklyReport.findMany({ where: { project_id: projectId }, orderBy: { updated_at: "desc" }, include: { weekly_report: { include: { employee: { select: { id: true, employee_no: true, name: true } } } } }, }); return sendData(reply, request, { items: items.map((item) => ({ id: item.id, weeklyReportId: item.weekly_report_id, weekStart: item.weekly_report.week_start?.toISOString?.().slice(0, 10) ?? null, status: item.weekly_report.status, employee: mapEmployeeBasic(item.weekly_report.employee), progressText: item.progress_text, riskText: item.risk_text, nextStepText: item.next_step_text, updatedAt: item.updated_at?.toISOString?.() ?? null })) }); }); fastify.get("/projects/:id/communication-logs", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectCommunicationLog); const projectId = parseId(request.params?.id); const project = await ensureReadableProject(fastify, request, projectId); const items = await fastify.prisma.projectCommunicationLog.findMany({ where: { project_id: projectId }, orderBy: [{ happened_at: "desc" }, { updated_at: "desc" }], take: 50, include: { creator: { select: { id: true, employee_no: true, name: true } } }, }); return sendData(reply, request, { items: items.map((item) => mapProjectCommunicationLog(item, canEditCommunicationLog(fastify, request.employee, project, item))) }); }); fastify.post("/projects/:id/communication-logs", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectCommunicationLog); const projectId = parseId(request.params?.id); await ensureReadableProject(fastify, request, projectId); const body = parseBody(COMMUNICATION_LOG_CREATE_SCHEMA, request.body); const created = await fastify.prisma.projectCommunicationLog.create({ data: { project_id: projectId, happened_at: new Date(body.happenedAt), channel: body.channel, external_contact_name: body.externalContactName ?? null, external_org_name: body.externalOrgName ?? null, summary: body.summary, next_action: body.nextAction ?? null, next_follow_up_date: parseDateOnly(body.nextFollowUpDate), created_by: request.employee.id, }, include: { creator: { select: { id: true, employee_no: true, name: true } } }, }); await writeAudit(fastify, request, "PROJECT_COMMUNICATION_LOG_CREATED", projectId, { logId: created.id, channel: created.channel }); return reply.code(201).send({ requestId: request.id, data: { item: mapProjectCommunicationLog(created, true) } }); }); fastify.patch("/projects/:id/communication-logs/:logId", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectCommunicationLog); const projectId = parseId(request.params?.id); const logId = parseId(request.params?.logId, "logId"); const project = await ensureReadableProject(fastify, request, projectId); const current = await fastify.prisma.projectCommunicationLog.findUnique({ where: { id: logId }, include: { creator: { select: { id: true, employee_no: true, name: true } } }, }); if (!current || current.project_id !== projectId) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (!canEditCommunicationLog(fastify, request.employee, project, current)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } }); } const body = parseBody(COMMUNICATION_LOG_PATCH_SCHEMA, request.body); const updated = await fastify.prisma.projectCommunicationLog.update({ where: { id: logId }, data: { ...(body.happenedAt !== undefined ? { happened_at: new Date(body.happenedAt) } : {}), ...(body.channel !== undefined ? { channel: body.channel } : {}), ...(body.externalContactName !== undefined ? { external_contact_name: body.externalContactName ?? null } : {}), ...(body.externalOrgName !== undefined ? { external_org_name: body.externalOrgName ?? null } : {}), ...(body.summary !== undefined ? { summary: body.summary } : {}), ...(body.nextAction !== undefined ? { next_action: body.nextAction ?? null } : {}), ...(body.nextFollowUpDate !== undefined ? { next_follow_up_date: parseDateOnly(body.nextFollowUpDate) } : {}), }, include: { creator: { select: { id: true, employee_no: true, name: true } } }, }); await writeAudit(fastify, request, "PROJECT_COMMUNICATION_LOG_UPDATED", projectId, { logId }); return sendData(reply, request, { item: mapProjectCommunicationLog(updated, canEditCommunicationLog(fastify, request.employee, project, updated)) }); }); fastify.get("/projects/:id/closure-checklist", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectClosureChecklist); const projectId = parseId(request.params?.id); const project = await ensureReadableProject(fastify, request, projectId); await ensureClosureChecklistBootstrapped(fastify.prisma, projectId); const canEdit = hasGlobalProjectManage(fastify, request.employee) || canManageProjectRecord(fastify, request.employee.id, project); const items = await fastify.prisma.projectClosureChecklistItem.findMany({ where: { project_id: projectId }, orderBy: [{ sort_order: "asc" }, { created_at: "asc" }], include: { completer: { select: { id: true, employee_no: true, name: true } } }, }); return sendData(reply, request, { items: items.map((item) => mapProjectClosureChecklistItem(item, canEdit)) }); }); fastify.patch("/projects/:id/closure-checklist/:itemId", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectClosureChecklist); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); await ensureClosureChecklistBootstrapped(fastify.prisma, projectId); const itemId = parseId(request.params?.itemId, "itemId"); const current = await fastify.prisma.projectClosureChecklistItem.findUnique({ where: { id: itemId }, include: { completer: { select: { id: true, employee_no: true, name: true } } }, }); if (!current || current.project_id !== projectId) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } const body = parseBody(CLOSURE_CHECKLIST_PATCH_SCHEMA, request.body); const nextStatus = body.status ?? current.status; const nextNote = body.note !== undefined ? (body.note ?? null) : current.note; if (nextStatus === "waived" && !String(nextNote ?? "").trim()) { throw buildValidationError({ note: "waived_requires_note" }); } const completedMeta = nextStatus === "pending" ? { completed_at: null, completed_by: null } : { completed_at: new Date(), completed_by: request.employee.id }; const updated = await fastify.prisma.projectClosureChecklistItem.update({ where: { id: itemId }, data: { ...(body.status !== undefined ? { status: body.status } : {}), ...(body.note !== undefined ? { note: body.note ?? null } : {}), ...(body.status !== undefined ? completedMeta : {}), }, include: { completer: { select: { id: true, employee_no: true, name: true } } }, }); await writeAudit(fastify, request, "PROJECT_CLOSURE_CHECKLIST_UPDATED", projectId, { itemId, status: updated.status }); return sendData(reply, request, { item: mapProjectClosureChecklistItem(updated, true) }); }); fastify.get("/projects/:id/deliveries", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking); const projectId = parseId(request.params?.id); await ensureReadableProject(fastify, request, projectId); const items = await fastify.prisma.projectDeliveryRecord.findMany({ where: { project_id: projectId }, orderBy: { occurred_at: "desc" }, include: { owner: { select: { id: true, employee_no: true, name: true } } }, }); return sendData(reply, request, { items: items.map((item) => ({ id: item.id, title: item.title, recordType: item.record_type, summary: item.summary, occurredAt: item.occurred_at?.toISOString?.() ?? null, owner: mapEmployeeBasic(item.owner), createdAt: item.created_at?.toISOString?.() ?? null })) }); }); fastify.post("/projects/:id/deliveries", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); const body = parseBody(DELIVERY_CREATE_SCHEMA, request.body); const created = await fastify.prisma.projectDeliveryRecord.create({ data: { project_id: projectId, title: body.title, record_type: body.recordType, summary: body.summary, occurred_at: new Date(body.occurredAt), owner_employee_id: body.ownerEmployeeId ?? request.employee.id, }, include: { owner: { select: { id: true, employee_no: true, name: true } } }, }); return reply.code(201).send({ requestId: request.id, data: { item: { id: created.id, title: created.title, recordType: created.record_type, summary: created.summary, occurredAt: created.occurred_at?.toISOString?.() ?? null, owner: mapEmployeeBasic(created.owner), createdAt: created.created_at?.toISOString?.() ?? null } } }); }); fastify.get("/projects/:id/document-refs", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.confidentialDocRefs); const projectId = parseId(request.params?.id); await ensureReadableProject(fastify, request, projectId); const items = await fastify.prisma.confidentialDocumentRef.findMany({ where: { project_id: projectId }, orderBy: { updated_at: "desc" } }); return sendData(reply, request, { items: items.map((item) => ({ id: item.id, title: item.title, documentNo: item.document_no ?? null, confidentialityLevel: item.confidentiality_level, storageType: item.storage_type, storagePath: item.storage_path, offlineArchiveNo: item.offline_archive_no ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null })) }); }); } module.exports = projectsRoutes;