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_TIME_SCHEMA = z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}T/)); const QUERY_SCHEMA = z.object({ manage: z.enum(["0", "1", "true", "false"]).optional() }); const WRITE_SCHEMA = z.object({ title: z.string().trim().min(1).max(120), summary: z.string().trim().min(1).max(500), linkUrl: z.string().trim().url().nullable().optional(), startsAt: DATE_TIME_SCHEMA.nullable().optional(), endsAt: DATE_TIME_SCHEMA.nullable().optional(), enabled: z.boolean().optional(), priority: z.coerce.number().int().min(1).max(9999).optional(), category: z.enum(["general", "policy"]).optional(), requiresAcknowledgement: z.boolean().optional(), acknowledgementStatement: z.string().trim().min(1).max(300).nullable().optional(), acknowledgementDueAt: DATE_TIME_SCHEMA.nullable().optional(), }).strict(); const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); const ACKNOWLEDGE_SCHEMA = z.object({ confirmed: z.literal(true) }).strict(); const ACKNOWLEDGEMENT_PROTECTED_FIELDS = new Set(["title", "summary", "category", "requiresAcknowledgement", "acknowledgementStatement"]); function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); } function buildNotFoundError(details = {}) { return new AppError({ code: "ANN_404", message: "公告不存在或不可见", statusCode: 404, details }); } function parseId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) throw buildValidationError({ id: "invalid_uuid" }); return parsed.data; } function parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) throw buildValidationError(parsed.error.flatten()); return parsed.data; } async function ensureFeatureEnabled(fastify) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.announcements, strictDepartment: false }); if (!gate.enabled) { throw buildValidationError({ featureFlag: "announcements_v1_disabled", reason: gate.reason }, 409); } } function normalizeAnnouncementPolicyFields(body, current = null) { let requiresAcknowledgement = current?.requires_acknowledgement ?? false; if (body.category === "policy") { requiresAcknowledgement = true; } else if (body.category === "general") { requiresAcknowledgement = false; } if (body.requiresAcknowledgement !== undefined) { requiresAcknowledgement = body.requiresAcknowledgement; } const acknowledgementStatement = body.acknowledgementStatement !== undefined ? body.acknowledgementStatement?.trim() || null : current?.acknowledgement_statement ?? null; const acknowledgementDueAt = body.acknowledgementDueAt !== undefined ? (body.acknowledgementDueAt ? new Date(body.acknowledgementDueAt) : null) : current?.acknowledgement_due_at ?? null; if (requiresAcknowledgement && !acknowledgementStatement) { throw buildValidationError({ acknowledgementStatement: "required_for_policy_acknowledgement" }); } return { category: requiresAcknowledgement ? "policy" : "general", requires_acknowledgement: requiresAcknowledgement, acknowledgement_statement: requiresAcknowledgement ? acknowledgementStatement : null, acknowledgement_due_at: requiresAcknowledgement ? acknowledgementDueAt : null, }; } function hasAcknowledgementProtectedPatch(body) { return Object.keys(body).some((key) => ACKNOWLEDGEMENT_PROTECTED_FIELDS.has(key)); } function isVisibleAnnouncement(item, now = new Date()) { return Boolean( item?.enabled && (!item.starts_at || item.starts_at <= now) && (!item.ends_at || item.ends_at >= now), ); } function buildUserAcknowledgement(item, now) { if (!item.requires_acknowledgement) { return { status: "not_required", acknowledgedAt: null, overdue: false }; } const acknowledgement = item.acknowledgements?.[0] ?? null; if (acknowledgement) { return { status: "acknowledged", acknowledgedAt: acknowledgement.acknowledged_at?.toISOString?.() ?? null, overdue: false, }; } const overdue = Boolean(item.acknowledgement_due_at && item.acknowledgement_due_at < now); return { status: overdue ? "overdue" : "pending", acknowledgedAt: null, overdue }; } function buildManageAcknowledgement(item, activeEmployees, now) { if (!item.requires_acknowledgement) { return { summary: { required: false, totalActiveEmployees: activeEmployees.length, acknowledgedCount: 0, pendingCount: 0, overdueCount: 0, }, pendingEmployees: [], }; } const acknowledgedEmployeeIds = new Set((item.acknowledgements ?? []).map((acknowledgement) => acknowledgement.employee_id)); const pendingEmployees = activeEmployees .filter((employee) => !acknowledgedEmployeeIds.has(employee.id)) .map((employee) => ({ id: employee.id, employeeNo: employee.employee_no, name: employee.name, status: item.acknowledgement_due_at && item.acknowledgement_due_at < now ? "overdue" : "pending", })); const overdueCount = item.acknowledgement_due_at && item.acknowledgement_due_at < now ? pendingEmployees.length : 0; return { summary: { required: true, totalActiveEmployees: activeEmployees.length, acknowledgedCount: activeEmployees.length - pendingEmployees.length, pendingCount: pendingEmployees.length, overdueCount, }, pendingEmployees, }; } function mapItem(item, { activeEmployees = [], isManage = false, now = new Date() } = {}) { return { id: item.id, title: item.title, summary: item.summary, linkUrl: item.link_url ?? null, startsAt: item.starts_at?.toISOString?.() ?? null, endsAt: item.ends_at?.toISOString?.() ?? null, enabled: item.enabled, priority: item.priority, category: item.category ?? "general", requiresAcknowledgement: Boolean(item.requires_acknowledgement), acknowledgementStatement: item.acknowledgement_statement ?? null, acknowledgementDueAt: item.acknowledgement_due_at?.toISOString?.() ?? null, ...(isManage ? (() => { const result = buildManageAcknowledgement(item, activeEmployees, now); return { acknowledgementSummary: result.summary, pendingEmployees: result.pendingEmployees, }; })() : { acknowledgement: buildUserAcknowledgement(item, now) }), createdAt: item.created_at?.toISOString?.() ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null, }; } async function announcementsRoutes(fastify) { fastify.get("/announcements", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify); const queryParsed = QUERY_SCHEMA.safeParse(request.query ?? {}); if (!queryParsed.success) throw buildValidationError(queryParsed.error.flatten()); const query = queryParsed.data; const isManage = query.manage === "1" || query.manage === "true"; if (isManage) { fastify.assertPermission(request.employee, "system.settings"); } const now = new Date(); const where = isManage ? {} : { enabled: true, OR: [{ starts_at: null }, { starts_at: { lte: now } }], AND: [{ OR: [{ ends_at: null }, { ends_at: { gte: now } }] }], }; const [items, activeEmployees] = await Promise.all([ fastify.prisma.portalAnnouncement.findMany({ where, orderBy: [{ priority: "asc" }, { created_at: "desc" }], include: { acknowledgements: isManage ? { select: { employee_id: true, acknowledged_at: true } } : { where: { employee_id: request.employee.id }, select: { employee_id: true, acknowledged_at: true } }, }, }), isManage ? fastify.prisma.employee.findMany({ where: { status: "active" }, select: { id: true, employee_no: true, name: true }, orderBy: [{ employee_no: "asc" }, { name: "asc" }], }) : Promise.resolve([]), ]); return sendData(reply, request, { items: items.map((item) => mapItem(item, { activeEmployees, isManage, now })) }); }); fastify.post("/announcements", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { await ensureFeatureEnabled(fastify); const body = parseBody(WRITE_SCHEMA, request.body); const policyFields = normalizeAnnouncementPolicyFields(body); const created = await fastify.prisma.portalAnnouncement.create({ data: { title: body.title, summary: body.summary, link_url: body.linkUrl ?? null, starts_at: body.startsAt ? new Date(body.startsAt) : null, ends_at: body.endsAt ? new Date(body.endsAt) : null, enabled: body.enabled ?? true, priority: body.priority ?? 100, ...policyFields, created_by: request.employee.id, updated_by: request.employee.id, }, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "PORTAL_ANNOUNCEMENT_CREATED", targetType: "portal_announcement", targetId: created.id, extra: { requiresAcknowledgement: created.requires_acknowledgement }, }), }); return reply.code(201).send({ requestId: request.id, data: { item: mapItem(created, { isManage: true }) } }); }); fastify.patch("/announcements/:id", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { await ensureFeatureEnabled(fastify); const announcementId = parseId(request.params?.id); const body = parseBody(PATCH_SCHEMA, request.body); const current = await fastify.prisma.portalAnnouncement.findUnique({ where: { id: announcementId } }); if (!current) throw buildNotFoundError({ id: "not_found" }); if (hasAcknowledgementProtectedPatch(body)) { const acknowledgementCount = await fastify.prisma.portalAnnouncementAcknowledgement.count({ where: { announcement_id: announcementId }, }); if (acknowledgementCount > 0) { throw buildValidationError({ acknowledgement: "create_new_version_after_acknowledgement" }, 409); } } const policyFields = normalizeAnnouncementPolicyFields(body, current); const updated = await fastify.prisma.portalAnnouncement.update({ where: { id: announcementId }, data: { ...(body.title !== undefined ? { title: body.title } : {}), ...(body.summary !== undefined ? { summary: body.summary } : {}), ...(body.linkUrl !== undefined ? { link_url: body.linkUrl ?? null } : {}), ...(body.startsAt !== undefined ? { starts_at: body.startsAt ? new Date(body.startsAt) : null } : {}), ...(body.endsAt !== undefined ? { ends_at: body.endsAt ? new Date(body.endsAt) : null } : {}), ...(body.enabled !== undefined ? { enabled: body.enabled } : {}), ...(body.priority !== undefined ? { priority: body.priority } : {}), ...policyFields, updated_by: request.employee.id, }, }); return sendData(reply, request, { item: mapItem(updated, { isManage: true }) }); }); fastify.post("/announcements/:id/archive", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { await ensureFeatureEnabled(fastify); const announcementId = parseId(request.params?.id); const updated = await fastify.prisma.portalAnnouncement.update({ where: { id: announcementId }, data: { enabled: false, ends_at: new Date(), updated_by: request.employee.id }, }); return sendData(reply, request, { item: mapItem(updated, { isManage: true }) }); }); fastify.post("/announcements/:id/acknowledge", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify); const announcementId = parseId(request.params?.id); parseBody(ACKNOWLEDGE_SCHEMA, request.body); const now = new Date(); const announcement = await fastify.prisma.portalAnnouncement.findUnique({ where: { id: announcementId }, include: { acknowledgements: { where: { employee_id: request.employee.id }, select: { id: true, acknowledged_at: true }, }, }, }); if (!announcement || !isVisibleAnnouncement(announcement, now)) { throw buildNotFoundError({ id: "not_found_or_not_visible" }); } if (!announcement.requires_acknowledgement) { throw buildValidationError({ acknowledgement: "not_required" }, 409); } if (!announcement.acknowledgement_statement) { throw buildValidationError({ acknowledgementStatement: "missing_for_policy_acknowledgement" }, 409); } const existing = announcement.acknowledgements?.[0] ?? null; if (existing) { return sendData(reply, request, { item: { id: existing.id, announcementId: announcement.id, status: "acknowledged", acknowledgedAt: existing.acknowledged_at?.toISOString?.() ?? null, }, }); } try { const acknowledgement = await fastify.prisma.$transaction(async (tx) => { const created = await tx.portalAnnouncementAcknowledgement.create({ data: { announcement_id: announcement.id, employee_id: request.employee.id, statement_snapshot: announcement.acknowledgement_statement, title_snapshot: announcement.title, summary_snapshot: announcement.summary, actor_id: request.employee.id, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "PORTAL_ANNOUNCEMENT_ACKNOWLEDGED", targetType: "portal_announcement_acknowledgement", targetId: created.id, extra: { announcementId: announcement.id }, }), }); return created; }); return reply.code(201).send({ requestId: request.id, data: { item: { id: acknowledgement.id, announcementId: announcement.id, status: "acknowledged", acknowledgedAt: acknowledgement.acknowledged_at?.toISOString?.() ?? null, }, }, }); } catch (error) { if (error?.code !== "P2002") throw error; const acknowledgement = await fastify.prisma.portalAnnouncementAcknowledgement.findUnique({ where: { announcement_id_employee_id: { announcement_id: announcement.id, employee_id: request.employee.id } }, select: { id: true, acknowledged_at: true }, }); return sendData(reply, request, { item: { id: acknowledgement?.id ?? null, announcementId: announcement.id, status: "acknowledged", acknowledgedAt: acknowledgement?.acknowledged_at?.toISOString?.() ?? null, }, }); } }); } module.exports = announcementsRoutes;