const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { currentDateString, parseDateOnlyString } = require("../utils/date"); const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const UUID_SCHEMA = z.string().uuid(); const QUERY_SCHEMA = z.object({ weekStart: DATE_SCHEMA.optional() }); const WRITE_SCHEMA = z .object({ weekStart: DATE_SCHEMA.optional(), highlightsText: z.string().max(5000).optional(), blockersText: z.string().max(5000).optional(), nextWeekText: z.string().max(5000).optional(), needsHelp: z.boolean().optional(), projectLinks: z.array(z.object({ projectId: UUID_SCHEMA, progressText: z.string().max(4000).optional(), riskText: z.string().max(4000).optional(), nextStepText: z.string().max(4000).optional() }).strict()).max(20).optional(), }) .strict() .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; } function normalizeDateString(value) { return new Date(`${value}T00:00:00.000Z`).toISOString().slice(0, 10); } function resolveWeekStart(rawValue, timeZone) { const base = rawValue ? normalizeDateString(rawValue) : currentDateString(timeZone ?? "Asia/Shanghai"); const date = parseDateOnlyString(base); const day = date.getUTCDay() || 7; date.setUTCDate(date.getUTCDate() - (day - 1)); return date.toISOString().slice(0, 10); } async function ensureFeatureEnabled(fastify, request) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.weeklyReport, departmentId: request.employee.department_id ?? null, strictDepartment: false, }); if (!gate.enabled) { throw buildValidationError({ featureFlag: "weekly_report_v1_disabled", reason: gate.reason }, 409); } } function mapReport(row) { return { id: row.id, employeeId: row.employee_id, weekStart: row.week_start?.toISOString?.()?.slice(0, 10) ?? null, status: row.status, highlightsText: row.highlights_text, blockersText: row.blockers_text, nextWeekText: row.next_week_text, needsHelp: row.needs_help, submittedAt: row.submitted_at?.toISOString?.() ?? null, createdAt: row.created_at?.toISOString?.() ?? null, updatedAt: row.updated_at?.toISOString?.() ?? null, projectLinks: Array.isArray(row.project_links) ? row.project_links.map((item) => ({ id: item.id, projectId: item.project_id, progressText: item.progress_text, riskText: item.risk_text, nextStepText: item.next_step_text, project: item.project ? { id: item.project.id, projectNo: item.project.project_no, name: item.project.name } : null })) : [], }; } async function loadReportWithLinks(prisma, reportId) { return prisma.weeklyReport.findUnique({ where: { id: reportId }, include: { project_links: { include: { project: { select: { id: true, project_no: true, name: true } } }, orderBy: { updated_at: "desc" } } } }); } async function ensureReportDraft(prisma, employeeId, weekStart) { const existing = await prisma.weeklyReport.findUnique({ where: { employee_id_week_start: { employee_id: employeeId, week_start: parseDateOnlyString(weekStart), }, }, }); if (existing) { return existing; } return prisma.weeklyReport.create({ data: { employee_id: employeeId, week_start: parseDateOnlyString(weekStart), status: "draft", highlights_text: "", blockers_text: "", next_week_text: "", needs_help: false, }, }); } async function weeklyReportsRoutes(fastify) { fastify.get("/weekly-reports/me", { 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 weekStart = resolveWeekStart(parsed.data.weekStart, fastify.config.appTimezone); const report = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart); return sendData(reply, request, { item: mapReport(await loadReportWithLinks(fastify.prisma, report.id)) }); }); fastify.put("/weekly-reports/me", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const body = parseBody(WRITE_SCHEMA, request.body); const weekStart = resolveWeekStart(body.weekStart, fastify.config.appTimezone); const current = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart); if (current.status === "submitted") { throw buildValidationError({ status: "submitted_readonly" }, 409); } const updated = await fastify.prisma.$transaction(async (tx) => { const saved = await tx.weeklyReport.update({ where: { id: current.id }, data: { ...(body.highlightsText !== undefined ? { highlights_text: body.highlightsText } : {}), ...(body.blockersText !== undefined ? { blockers_text: body.blockersText } : {}), ...(body.nextWeekText !== undefined ? { next_week_text: body.nextWeekText } : {}), ...(body.needsHelp !== undefined ? { needs_help: body.needsHelp } : {}), }, }); if (body.projectLinks !== undefined) { const linkGate = await evaluateFeatureFlag({ prisma: tx, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.projectLinking, departmentId: request.employee.department_id ?? null, strictDepartment: false }); if (!linkGate.enabled) { throw buildValidationError({ featureFlag: "project_linking_v1_disabled", reason: linkGate.reason }, 409); } await tx.projectLinkOnWeeklyReport.deleteMany({ where: { weekly_report_id: current.id } }); if (body.projectLinks.length) { await tx.projectLinkOnWeeklyReport.createMany({ data: body.projectLinks.map((item) => ({ weekly_report_id: current.id, project_id: item.projectId, progress_text: item.progressText ?? "", risk_text: item.riskText ?? "", next_step_text: item.nextStepText ?? "", })), }); } } return saved; }); return sendData(reply, request, { item: mapReport(await loadReportWithLinks(fastify.prisma, updated.id)) }); }); fastify.post("/weekly-reports/me/submit", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const body = parseBody(QUERY_SCHEMA, request.body); const weekStart = resolveWeekStart(body.weekStart, fastify.config.appTimezone); const current = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart); const updated = await fastify.prisma.weeklyReport.update({ where: { id: current.id }, data: { status: "submitted", submitted_at: new Date(), }, }); return sendData(reply, request, { item: mapReport(await loadReportWithLinks(fastify.prisma, updated.id)) }); }); fastify.get("/weekly-reports/team", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!(fastify.hasPermission(request.employee, "report.dashboard.view") || fastify.hasPermission(request.employee, "system.users"))) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "report.dashboard.view" } }); } const parsed = QUERY_SCHEMA.safeParse(request.query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } const weekStart = resolveWeekStart(parsed.data.weekStart, fastify.config.appTimezone); const [employees, reports] = await Promise.all([ fastify.prisma.employee.findMany({ where: { status: "active" }, orderBy: { employee_no: "asc" }, select: { id: true, employee_no: true, name: true, title: true, department: { select: { id: true, name: true } }, }, }), fastify.prisma.weeklyReport.findMany({ where: { week_start: parseDateOnlyString(weekStart) }, select: { id: true, employee_id: true, week_start: true, status: true, highlights_text: true, blockers_text: true, next_week_text: true, needs_help: true, submitted_at: true, created_at: true, updated_at: true, }, }), ]); const reportMap = new Map(reports.map((item) => [item.employee_id, item])); const items = employees.map((employee) => { const report = reportMap.get(employee.id) ?? null; return { employee: { id: employee.id, employeeNo: employee.employee_no, name: employee.name, title: employee.title, department: employee.department ? { id: employee.department.id, name: employee.department.name } : null, }, report: report ? mapReport(report) : null, }; }); const summary = { totalEmployees: employees.length, submittedCount: items.filter((item) => item.report?.status === "submitted").length, draftCount: items.filter((item) => item.report?.status === "draft").length, missingCount: items.filter((item) => !item.report).length, needsHelpCount: items.filter((item) => item.report?.needsHelp).length, }; return sendData(reply, request, { weekStart, summary, items }); }); } module.exports = weeklyReportsRoutes;