370 行
15 KiB
JavaScript
370 行
15 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_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;
|