文件
hyb-oa/server/src/routes/projects.js
T

1060 行
55 KiB
JavaScript

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;