实现制度签收轻量回执(05-28-oa-policy-acknowledgement-v1)
这个提交包含在:
@@ -0,0 +1,43 @@
|
||||
ALTER TABLE "portal_announcements"
|
||||
ADD COLUMN "category" TEXT NOT NULL DEFAULT 'general',
|
||||
ADD COLUMN "requires_acknowledgement" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "acknowledgement_statement" TEXT,
|
||||
ADD COLUMN "acknowledgement_due_at" TIMESTAMP(3);
|
||||
|
||||
CREATE TABLE "portal_announcement_acknowledgements" (
|
||||
"id" UUID NOT NULL,
|
||||
"announcement_id" UUID NOT NULL,
|
||||
"employee_id" UUID NOT NULL,
|
||||
"acknowledged_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"statement_snapshot" TEXT NOT NULL,
|
||||
"title_snapshot" TEXT NOT NULL,
|
||||
"summary_snapshot" TEXT NOT NULL,
|
||||
"actor_id" UUID NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "portal_announcement_acknowledgements_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "portal_announcement_acknowledgements_announcement_id_employee_id_key"
|
||||
ON "portal_announcement_acknowledgements"("announcement_id", "employee_id");
|
||||
|
||||
CREATE INDEX "portal_announcement_acknowledgements_employee_id_acknowledged_at_idx"
|
||||
ON "portal_announcement_acknowledgements"("employee_id", "acknowledged_at");
|
||||
|
||||
CREATE INDEX "portal_announcement_acknowledgements_announcement_id_acknowledged_at_idx"
|
||||
ON "portal_announcement_acknowledgements"("announcement_id", "acknowledged_at");
|
||||
|
||||
ALTER TABLE "portal_announcement_acknowledgements"
|
||||
ADD CONSTRAINT "portal_announcement_acknowledgements_announcement_id_fkey"
|
||||
FOREIGN KEY ("announcement_id") REFERENCES "portal_announcements"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "portal_announcement_acknowledgements"
|
||||
ADD CONSTRAINT "portal_announcement_acknowledgements_employee_id_fkey"
|
||||
FOREIGN KEY ("employee_id") REFERENCES "employees"("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "portal_announcement_acknowledgements"
|
||||
ADD CONSTRAINT "portal_announcement_acknowledgements_actor_id_fkey"
|
||||
FOREIGN KEY ("actor_id") REFERENCES "employees"("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
+37
-8
@@ -683,6 +683,8 @@ model Employee {
|
||||
email_login_codes EmailLoginCode[]
|
||||
lifecycle_events EmployeeLifecycleEvent[]
|
||||
checklist_tasks EmployeeChecklistTask[]
|
||||
portal_announcement_acknowledgements PortalAnnouncementAcknowledgement[] @relation("PortalAnnouncementAcknowledgementEmployee")
|
||||
portal_announcement_acknowledgement_actions PortalAnnouncementAcknowledgement[] @relation("PortalAnnouncementAcknowledgementActor")
|
||||
presence_records EmployeePresence[]
|
||||
attendance_records AttendanceRecord[]
|
||||
weekly_reports WeeklyReport[]
|
||||
@@ -1876,24 +1878,51 @@ model PaymentBatchReceiptAttachment {
|
||||
}
|
||||
|
||||
model PortalAnnouncement {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
title String
|
||||
summary String
|
||||
link_url String?
|
||||
starts_at DateTime?
|
||||
ends_at DateTime?
|
||||
enabled Boolean @default(true)
|
||||
priority Int @default(100)
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
title String
|
||||
summary String
|
||||
link_url String?
|
||||
starts_at DateTime?
|
||||
ends_at DateTime?
|
||||
enabled Boolean @default(true)
|
||||
priority Int @default(100)
|
||||
category String @default("general")
|
||||
requires_acknowledgement Boolean @default(false)
|
||||
acknowledgement_statement String?
|
||||
acknowledgement_due_at DateTime?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
acknowledgements PortalAnnouncementAcknowledgement[]
|
||||
|
||||
@@index([enabled, priority, starts_at, ends_at], map: "idx_portal_announcements_enabled_priority")
|
||||
@@map("portal_announcements")
|
||||
}
|
||||
|
||||
model PortalAnnouncementAcknowledgement {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
announcement_id String @db.Uuid
|
||||
employee_id String @db.Uuid
|
||||
acknowledged_at DateTime @default(now())
|
||||
statement_snapshot String
|
||||
title_snapshot String
|
||||
summary_snapshot String
|
||||
actor_id String @db.Uuid
|
||||
created_at DateTime @default(now())
|
||||
|
||||
announcement PortalAnnouncement @relation(fields: [announcement_id], references: [id], onDelete: Cascade)
|
||||
employee Employee @relation("PortalAnnouncementAcknowledgementEmployee", fields: [employee_id], references: [id], onDelete: Restrict)
|
||||
actor Employee @relation("PortalAnnouncementAcknowledgementActor", fields: [actor_id], references: [id], onDelete: Restrict)
|
||||
|
||||
@@unique([announcement_id, employee_id])
|
||||
@@index([employee_id, acknowledged_at])
|
||||
@@index([announcement_id, acknowledged_at])
|
||||
@@map("portal_announcement_acknowledgements")
|
||||
}
|
||||
|
||||
model WorkspaceSetting {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
profile WorkspaceProfile @default(small_team_internal)
|
||||
|
||||
+253
-11
@@ -16,12 +16,21 @@ const WRITE_SCHEMA = z.object({
|
||||
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" });
|
||||
@@ -38,7 +47,97 @@ async function ensureFeatureEnabled(fastify) {
|
||||
throw buildValidationError({ featureFlag: "announcements_v1_disabled", reason: gate.reason }, 409);
|
||||
}
|
||||
}
|
||||
function mapItem(item) {
|
||||
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,
|
||||
@@ -48,6 +147,19 @@ function mapItem(item) {
|
||||
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,
|
||||
};
|
||||
@@ -59,24 +171,42 @@ async function announcementsRoutes(fastify) {
|
||||
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 && fastify.hasPermission(request.employee, "system.settings")
|
||||
const where = isManage
|
||||
? {}
|
||||
: {
|
||||
enabled: true,
|
||||
OR: [{ starts_at: null }, { starts_at: { lte: now } }],
|
||||
AND: [{ OR: [{ ends_at: null }, { ends_at: { gte: now } }] }],
|
||||
};
|
||||
const items = await fastify.prisma.portalAnnouncement.findMany({
|
||||
where,
|
||||
orderBy: [{ priority: "asc" }, { created_at: "desc" }],
|
||||
});
|
||||
return sendData(reply, request, { items: items.map(mapItem) });
|
||||
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,
|
||||
@@ -86,18 +216,40 @@ async function announcementsRoutes(fastify) {
|
||||
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 }) });
|
||||
return reply.code(201).send({ requestId: request.id, data: { item: mapItem(created) } });
|
||||
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: {
|
||||
@@ -108,10 +260,11 @@ async function announcementsRoutes(fastify) {
|
||||
...(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) });
|
||||
return sendData(reply, request, { item: mapItem(updated, { isManage: true }) });
|
||||
});
|
||||
|
||||
fastify.post("/announcements/:id/archive", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
|
||||
@@ -121,7 +274,96 @@ async function announcementsRoutes(fastify) {
|
||||
where: { id: announcementId },
|
||||
data: { enabled: false, ends_at: new Date(), updated_by: request.employee.id },
|
||||
});
|
||||
return sendData(reply, request, { item: mapItem(updated) });
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const featureFlagState = vi.hoisted(() => ({ enabled: true }));
|
||||
|
||||
vi.mock("../../../src/services/featureFlags", () => ({
|
||||
FEATURE_FLAG_KEYS: { announcements: "announcements_v1" },
|
||||
evaluateFeatureFlag: vi.fn(async () => ({
|
||||
enabled: featureFlagState.enabled,
|
||||
reason: featureFlagState.enabled ? "mock_on" : "mock_off",
|
||||
})),
|
||||
}));
|
||||
|
||||
const announcementsRoutes = require("../../../src/routes/announcements");
|
||||
|
||||
const ADMIN_ID = "00000000-0000-4000-8000-000000000001";
|
||||
const EMPLOYEE_ID = "00000000-0000-4000-8000-000000000002";
|
||||
const EMPLOYEE_2_ID = "00000000-0000-4000-8000-000000000003";
|
||||
const INACTIVE_EMPLOYEE_ID = "00000000-0000-4000-8000-000000000004";
|
||||
const POLICY_ID = "10000000-0000-4000-8000-000000000001";
|
||||
const GENERAL_ID = "10000000-0000-4000-8000-000000000002";
|
||||
const HIDDEN_ID = "10000000-0000-4000-8000-000000000003";
|
||||
|
||||
function buildEmployee(overrides = {}) {
|
||||
return {
|
||||
id: EMPLOYEE_ID,
|
||||
employee_no: "E001",
|
||||
name: "员工一",
|
||||
status: "active",
|
||||
permissionCodes: ["expense.view_own"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAnnouncement(overrides = {}) {
|
||||
const now = new Date("2026-05-28T02:00:00.000Z");
|
||||
return {
|
||||
id: POLICY_ID,
|
||||
title: "费用制度签收",
|
||||
summary: "请确认费用制度 V1。",
|
||||
link_url: null,
|
||||
starts_at: null,
|
||||
ends_at: null,
|
||||
enabled: true,
|
||||
priority: 10,
|
||||
category: "policy",
|
||||
requires_acknowledgement: true,
|
||||
acknowledgement_statement: "我已阅读并理解该制度。",
|
||||
acknowledgement_due_at: new Date("2026-06-01T15:59:59.000Z"),
|
||||
created_at: now,
|
||||
created_by: ADMIN_ID,
|
||||
updated_at: now,
|
||||
updated_by: ADMIN_ID,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneWithInclude(item, include, acknowledgements) {
|
||||
const row = { ...item };
|
||||
if (include?.acknowledgements) {
|
||||
let rows = acknowledgements.filter((acknowledgement) => acknowledgement.announcement_id === item.id);
|
||||
if (include.acknowledgements.where?.employee_id) {
|
||||
rows = rows.filter((acknowledgement) => acknowledgement.employee_id === include.acknowledgements.where.employee_id);
|
||||
}
|
||||
row.acknowledgements = rows.map((acknowledgement) => ({
|
||||
id: acknowledgement.id,
|
||||
employee_id: acknowledgement.employee_id,
|
||||
acknowledged_at: acknowledgement.acknowledged_at,
|
||||
}));
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function matchAnnouncementWhere(item, where = {}) {
|
||||
if (where.enabled !== undefined && item.enabled !== where.enabled) {
|
||||
return false;
|
||||
}
|
||||
const now = new Date("2026-05-28T02:00:00.000Z");
|
||||
if (where.OR && item.starts_at && item.starts_at > now) {
|
||||
return false;
|
||||
}
|
||||
if (where.AND && item.ends_at && item.ends_at < now) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function createPrismaState(options = {}) {
|
||||
const employees = options.employees ?? [
|
||||
buildEmployee({ id: EMPLOYEE_ID, employee_no: "E001", name: "员工一" }),
|
||||
buildEmployee({ id: EMPLOYEE_2_ID, employee_no: "E002", name: "员工二" }),
|
||||
buildEmployee({ id: INACTIVE_EMPLOYEE_ID, employee_no: "E003", name: "离职员工", status: "archived" }),
|
||||
];
|
||||
const announcements = options.announcements ?? [
|
||||
buildAnnouncement(),
|
||||
buildAnnouncement({
|
||||
id: GENERAL_ID,
|
||||
title: "普通公告",
|
||||
summary: "无需签收。",
|
||||
category: "general",
|
||||
requires_acknowledgement: false,
|
||||
acknowledgement_statement: null,
|
||||
acknowledgement_due_at: null,
|
||||
priority: 20,
|
||||
}),
|
||||
buildAnnouncement({
|
||||
id: HIDDEN_ID,
|
||||
title: "未来公告",
|
||||
starts_at: new Date("2026-06-01T00:00:00.000Z"),
|
||||
}),
|
||||
];
|
||||
const acknowledgements = options.acknowledgements ?? [];
|
||||
const auditLogs = [];
|
||||
|
||||
const portalAnnouncement = {
|
||||
findMany: vi.fn(async ({ where, include }) =>
|
||||
announcements
|
||||
.filter((item) => matchAnnouncementWhere(item, where))
|
||||
.sort((left, right) => left.priority - right.priority || right.created_at.getTime() - left.created_at.getTime())
|
||||
.map((item) => cloneWithInclude(item, include, acknowledgements)),
|
||||
),
|
||||
findUnique: vi.fn(async ({ where, include }) => {
|
||||
const item = announcements.find((announcement) => announcement.id === where.id);
|
||||
return item ? cloneWithInclude(item, include, acknowledgements) : null;
|
||||
}),
|
||||
create: vi.fn(async ({ data }) => {
|
||||
const created = buildAnnouncement({
|
||||
...data,
|
||||
id: `10000000-0000-4000-8000-${String(announcements.length + 10).padStart(12, "0")}`,
|
||||
link_url: data.link_url ?? null,
|
||||
starts_at: data.starts_at ?? null,
|
||||
ends_at: data.ends_at ?? null,
|
||||
created_at: new Date("2026-05-28T02:00:00.000Z"),
|
||||
updated_at: new Date("2026-05-28T02:00:00.000Z"),
|
||||
});
|
||||
announcements.push(created);
|
||||
return created;
|
||||
}),
|
||||
update: vi.fn(async ({ where, data }) => {
|
||||
const index = announcements.findIndex((announcement) => announcement.id === where.id);
|
||||
if (index < 0) {
|
||||
throw new Error("not_found");
|
||||
}
|
||||
announcements[index] = { ...announcements[index], ...data, updated_at: new Date("2026-05-28T03:00:00.000Z") };
|
||||
return announcements[index];
|
||||
}),
|
||||
};
|
||||
|
||||
const portalAnnouncementAcknowledgement = {
|
||||
count: vi.fn(async ({ where }) =>
|
||||
acknowledgements.filter((acknowledgement) => acknowledgement.announcement_id === where.announcement_id).length,
|
||||
),
|
||||
create: vi.fn(async ({ data }) => {
|
||||
const duplicated = acknowledgements.find(
|
||||
(acknowledgement) =>
|
||||
acknowledgement.announcement_id === data.announcement_id &&
|
||||
acknowledgement.employee_id === data.employee_id,
|
||||
);
|
||||
if (duplicated) {
|
||||
const error = new Error("duplicate_acknowledgement");
|
||||
error.code = "P2002";
|
||||
throw error;
|
||||
}
|
||||
const created = {
|
||||
id: `20000000-0000-4000-8000-${String(acknowledgements.length + 1).padStart(12, "0")}`,
|
||||
acknowledged_at: new Date("2026-05-28T02:30:00.000Z"),
|
||||
created_at: new Date("2026-05-28T02:30:00.000Z"),
|
||||
...data,
|
||||
};
|
||||
acknowledgements.push(created);
|
||||
return created;
|
||||
}),
|
||||
findUnique: vi.fn(async ({ where }) => {
|
||||
const compound = where.announcement_id_employee_id;
|
||||
if (!compound) return null;
|
||||
return acknowledgements.find(
|
||||
(acknowledgement) =>
|
||||
acknowledgement.announcement_id === compound.announcement_id &&
|
||||
acknowledgement.employee_id === compound.employee_id,
|
||||
) ?? null;
|
||||
}),
|
||||
};
|
||||
|
||||
const auditLog = {
|
||||
create: vi.fn(async ({ data }) => {
|
||||
auditLogs.push(data);
|
||||
return { id: `audit-${auditLogs.length}`, ...data };
|
||||
}),
|
||||
};
|
||||
|
||||
const prisma = {
|
||||
portalAnnouncement,
|
||||
portalAnnouncementAcknowledgement,
|
||||
employee: {
|
||||
findMany: vi.fn(async ({ where }) => employees.filter((employee) => employee.status === where.status)),
|
||||
},
|
||||
auditLog,
|
||||
featureFlag: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: "flag-announcements",
|
||||
flag_key: "announcements_v1",
|
||||
override_mode: "force_on",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "",
|
||||
version: 1,
|
||||
created_at: new Date("2026-05-28T00:00:00.000Z"),
|
||||
created_by: null,
|
||||
updated_at: new Date("2026-05-28T00:00:00.000Z"),
|
||||
updated_by: null,
|
||||
})),
|
||||
},
|
||||
$transaction: vi.fn(async (callback) => callback({ portalAnnouncementAcknowledgement, auditLog })),
|
||||
};
|
||||
|
||||
return { prisma, employees, announcements, acknowledgements, auditLogs };
|
||||
}
|
||||
|
||||
async function buildApp(options = {}) {
|
||||
const state = createPrismaState(options);
|
||||
const actor = options.actor ?? buildEmployee({ id: EMPLOYEE_ID, permissionCodes: ["expense.view_own"] });
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
app.decorate("config", { announcementsEnabled: true });
|
||||
app.decorate("prisma", state.prisma);
|
||||
app.decorate("hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes(permissionCode));
|
||||
app.decorate("assertPermission", (employee, permissionCode) => {
|
||||
if (!app.hasPermission(employee, permissionCode)) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
details: { permission: permissionCode },
|
||||
});
|
||||
}
|
||||
});
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
await app.authenticate(request);
|
||||
app.assertPermission(request.employee, permissionCode);
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
if (error instanceof AppError) {
|
||||
return reply.status(error.statusCode).send({
|
||||
requestId: request.id,
|
||||
error: { code: error.code, message: error.message, details: error.details },
|
||||
});
|
||||
}
|
||||
return reply.status(500).send({ requestId: request.id, error: { code: "SYS_001", message: error.message, details: {} } });
|
||||
});
|
||||
await app.register(announcementsRoutes, { prefix: "/api" });
|
||||
return { app, state };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
featureFlagState.enabled = true;
|
||||
});
|
||||
|
||||
describe("announcements policy acknowledgement routes", () => {
|
||||
it("rejects policy acknowledgement announcement without statement", async () => {
|
||||
const { app } = await buildApp({
|
||||
actor: buildEmployee({ id: ADMIN_ID, permissionCodes: ["system.settings"] }),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/announcements",
|
||||
payload: {
|
||||
title: "制度公告",
|
||||
summary: "缺少签收声明。",
|
||||
category: "policy",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details.acknowledgementStatement).toBe("required_for_policy_acknowledgement");
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns only current employee acknowledgement status for normal list", async () => {
|
||||
const { app } = await buildApp();
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/announcements" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const item = response.json().data.items.find((announcement) => announcement.id === POLICY_ID);
|
||||
expect(item.acknowledgement).toMatchObject({ status: "pending", acknowledgedAt: null, overdue: false });
|
||||
expect(item.acknowledgementSummary).toBeUndefined();
|
||||
expect(item.pendingEmployees).toBeUndefined();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("creates acknowledgement once and writes audit log idempotently", async () => {
|
||||
const { app, state } = await buildApp();
|
||||
|
||||
const first = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/announcements/${POLICY_ID}/acknowledge`,
|
||||
payload: { confirmed: true },
|
||||
});
|
||||
const second = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/announcements/${POLICY_ID}/acknowledge`,
|
||||
payload: { confirmed: true },
|
||||
});
|
||||
|
||||
expect(first.statusCode).toBe(201);
|
||||
expect(second.statusCode).toBe(200);
|
||||
expect(state.acknowledgements).toHaveLength(1);
|
||||
expect(state.acknowledgements[0]).toMatchObject({
|
||||
announcement_id: POLICY_ID,
|
||||
employee_id: EMPLOYEE_ID,
|
||||
statement_snapshot: "我已阅读并理解该制度。",
|
||||
title_snapshot: "费用制度签收",
|
||||
});
|
||||
expect(state.auditLogs).toHaveLength(1);
|
||||
expect(state.auditLogs[0]).toMatchObject({
|
||||
action: "PORTAL_ANNOUNCEMENT_ACKNOWLEDGED",
|
||||
target_type: "portal_announcement_acknowledgement",
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects acknowledgement for general or invisible announcements", async () => {
|
||||
const { app } = await buildApp();
|
||||
|
||||
const general = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/announcements/${GENERAL_ID}/acknowledge`,
|
||||
payload: { confirmed: true },
|
||||
});
|
||||
const hidden = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/announcements/${HIDDEN_ID}/acknowledge`,
|
||||
payload: { confirmed: true },
|
||||
});
|
||||
|
||||
expect(general.statusCode).toBe(409);
|
||||
expect(general.json().error.details.acknowledgement).toBe("not_required");
|
||||
expect(hidden.statusCode).toBe(404);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns admin acknowledgement summary and minimal pending employees only", async () => {
|
||||
const { app } = await buildApp({
|
||||
actor: buildEmployee({ id: ADMIN_ID, permissionCodes: ["system.settings"] }),
|
||||
acknowledgements: [
|
||||
{
|
||||
id: "20000000-0000-4000-8000-000000000099",
|
||||
announcement_id: POLICY_ID,
|
||||
employee_id: EMPLOYEE_ID,
|
||||
actor_id: EMPLOYEE_ID,
|
||||
acknowledged_at: new Date("2026-05-28T01:00:00.000Z"),
|
||||
statement_snapshot: "我已阅读并理解该制度。",
|
||||
title_snapshot: "费用制度签收",
|
||||
summary_snapshot: "请确认费用制度 V1。",
|
||||
created_at: new Date("2026-05-28T01:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/announcements?manage=1" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const item = response.json().data.items.find((announcement) => announcement.id === POLICY_ID);
|
||||
expect(item.acknowledgementSummary).toMatchObject({
|
||||
required: true,
|
||||
totalActiveEmployees: 2,
|
||||
acknowledgedCount: 1,
|
||||
pendingCount: 1,
|
||||
overdueCount: 0,
|
||||
});
|
||||
expect(item.pendingEmployees).toEqual([
|
||||
{ id: EMPLOYEE_2_ID, employeeNo: "E002", name: "员工二", status: "pending" },
|
||||
]);
|
||||
expect(JSON.stringify(item.pendingEmployees)).not.toMatch(/phone|email|wecom/i);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("denies manage query for employee without system settings permission", async () => {
|
||||
const { app } = await buildApp();
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/announcements?manage=1" });
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects protected policy content edits after acknowledgement exists", async () => {
|
||||
const { app } = await buildApp({
|
||||
actor: buildEmployee({ id: ADMIN_ID, permissionCodes: ["system.settings"] }),
|
||||
acknowledgements: [
|
||||
{
|
||||
id: "20000000-0000-4000-8000-000000000099",
|
||||
announcement_id: POLICY_ID,
|
||||
employee_id: EMPLOYEE_ID,
|
||||
actor_id: EMPLOYEE_ID,
|
||||
acknowledged_at: new Date("2026-05-28T01:00:00.000Z"),
|
||||
statement_snapshot: "我已阅读并理解该制度。",
|
||||
title_snapshot: "费用制度签收",
|
||||
summary_snapshot: "请确认费用制度 V1。",
|
||||
created_at: new Date("2026-05-28T01:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/announcements/${POLICY_ID}`,
|
||||
payload: { title: "费用制度签收 V2" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.acknowledgement).toBe("create_new_version_after_acknowledgement");
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户