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 CONFIDENTIALITY_SCHEMA = z.enum(["public", "internal", "confidential", "restricted"]); const STORAGE_TYPE_SCHEMA = z.enum(["oa_link", "nas", "internal_share", "offline_archive"]); const QUERY_SCHEMA = z.object({ projectId: UUID_SCHEMA.optional(), contractRegistryId: UUID_SCHEMA.optional() }); const WRITE_SCHEMA = z.object({ projectId: UUID_SCHEMA.nullable().optional(), contractRegistryId: UUID_SCHEMA.nullable().optional(), title: z.string().trim().min(1).max(200), documentNo: z.string().trim().max(80).nullable().optional(), confidentialityLevel: CONFIDENTIALITY_SCHEMA.optional(), storageType: STORAGE_TYPE_SCHEMA, storagePath: z.string().trim().min(1).max(500), offlineArchiveNo: z.string().trim().max(120).nullable().optional(), ownerEmployeeId: UUID_SCHEMA.optional(), accessRuleJson: z.record(z.string(), z.unknown()).nullable().optional(), }).strict(); const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); 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; } async function ensureFeatureEnabled(fastify, request) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.confidentialDocRefs, departmentId: request.employee.department_id ?? null, strictDepartment: false }); if (!gate.enabled) throw buildValidationError({ featureFlag: "confidential_doc_refs_v1_disabled", reason: gate.reason }, 409); } function canManage(fastify, employee) { return fastify.hasPermission(employee, "project.manage") || fastify.hasPermission(employee, "project.view_all") || fastify.hasPermission(employee, "system.settings"); } async function writeAudit(fastify, request, action, targetId, extra = {}) { try { await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action, targetType: "confidential_document_ref", targetId, extra }) }); } catch (error) { request.log.error({ err: error, action, targetId }, "confidential doc ref audit failed"); } } function mapRow(item) { return { id: item.id, projectId: item.project_id ?? null, contractRegistryId: item.contract_registry_id ?? null, 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, ownerEmployeeId: item.owner_employee_id, accessRuleJson: item.access_rule_json ?? null, createdAt: item.created_at?.toISOString?.() ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null, owner: item.owner ? { id: item.owner.id, employeeNo: item.owner.employee_no, name: item.owner.name } : null, project: item.project ? { id: item.project.id, projectNo: item.project.project_no, name: item.project.name } : null, contractRegistry: item.contract_registry ? { id: item.contract_registry.id, contractNo: item.contract_registry.contract_no } : null, }; } async function confidentialDocRefsRoutes(fastify) { fastify.get("/confidential-doc-refs", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const parsed = QUERY_SCHEMA.safeParse(request.query ?? {}); if (!parsed.success) throw buildValidationError(parsed.error.flatten()); const rows = await fastify.prisma.confidentialDocumentRef.findMany({ where: { AND: [ parsed.data.projectId ? { project_id: parsed.data.projectId } : {}, parsed.data.contractRegistryId ? { contract_registry_id: parsed.data.contractRegistryId } : {}, canManage(fastify, request.employee) ? {} : { OR: [ { owner_employee_id: request.employee.id }, { project: { owner_employee_id: request.employee.id } }, { project: { manager_employee_id: request.employee.id } }, { project: { members: { some: { employee_id: request.employee.id, left_at: null } } } }, { contract_registry: { owner_employee_id: request.employee.id } }, ], }, ], }, orderBy: { updated_at: "desc" }, include: { owner: { select: { id: true, employee_no: true, name: true } }, project: { select: { id: true, project_no: true, name: true } }, contract_registry: { select: { id: true, contract_no: true } }, }, }); if (rows.some((item) => ["confidential", "restricted"].includes(item.confidentiality_level))) { await writeAudit(fastify, request, "CONFIDENTIAL_DOC_REF_VIEW", rows[0]?.id ?? request.employee.id, { count: rows.length, query: parsed.data }); } return sendData(reply, request, { items: rows.map(mapRow) }); }); fastify.post("/confidential-doc-refs", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!canManage(fastify, request.employee)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } }); } const body = parseBody(WRITE_SCHEMA, request.body); const created = await fastify.prisma.confidentialDocumentRef.create({ data: { project_id: body.projectId ?? null, contract_registry_id: body.contractRegistryId ?? null, title: body.title, document_no: body.documentNo ?? null, confidentiality_level: body.confidentialityLevel ?? "internal", storage_type: body.storageType, storage_path: body.storagePath, offline_archive_no: body.offlineArchiveNo ?? null, owner_employee_id: body.ownerEmployeeId ?? request.employee.id, access_rule_json: body.accessRuleJson ?? null, }, include: { owner: { select: { id: true, employee_no: true, name: true } }, project: { select: { id: true, project_no: true, name: true } }, contract_registry: { select: { id: true, contract_no: true } }, }, }); await writeAudit(fastify, request, "CONFIDENTIAL_DOC_REF_CREATED", created.id, { confidentialityLevel: created.confidentiality_level }); return reply.code(201).send({ requestId: request.id, data: { item: mapRow(created) } }); }); fastify.patch("/confidential-doc-refs/:id", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!canManage(fastify, request.employee)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } }); } const refId = UUID_SCHEMA.parse(request.params?.id); const body = parseBody(PATCH_SCHEMA, request.body); const updated = await fastify.prisma.confidentialDocumentRef.update({ where: { id: refId }, data: { ...(body.projectId !== undefined ? { project_id: body.projectId ?? null } : {}), ...(body.contractRegistryId !== undefined ? { contract_registry_id: body.contractRegistryId ?? null } : {}), ...(body.title !== undefined ? { title: body.title } : {}), ...(body.documentNo !== undefined ? { document_no: body.documentNo ?? null } : {}), ...(body.confidentialityLevel !== undefined ? { confidentiality_level: body.confidentialityLevel } : {}), ...(body.storageType !== undefined ? { storage_type: body.storageType } : {}), ...(body.storagePath !== undefined ? { storage_path: body.storagePath } : {}), ...(body.offlineArchiveNo !== undefined ? { offline_archive_no: body.offlineArchiveNo ?? null } : {}), ...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId } : {}), ...(body.accessRuleJson !== undefined ? { access_rule_json: body.accessRuleJson ?? null } : {}), }, include: { owner: { select: { id: true, employee_no: true, name: true } }, project: { select: { id: true, project_no: true, name: true } }, contract_registry: { select: { id: true, contract_no: true } }, }, }); await writeAudit(fastify, request, "CONFIDENTIAL_DOC_REF_UPDATED", updated.id, { fields: Object.keys(body) }); return sendData(reply, request, { item: mapRow(updated) }); }); } module.exports = confidentialDocRefsRoutes;