feat(P25-1/P25-5/P26-1/P26-3/P24-1/P29-4): 落地内部OA够用版后端与试运行能力
关联 Issue: P25-1, P25-2, P25-3, P25-4, P25-5, P26-1, P26-3, P24-1, P24-2, P29-1, P29-2, P29-3, P29-4
这个提交包含在:
@@ -0,0 +1,127 @@
|
||||
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(),
|
||||
}).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 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 mapItem(item) {
|
||||
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,
|
||||
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";
|
||||
const now = new Date();
|
||||
const where = isManage && fastify.hasPermission(request.employee, "system.settings")
|
||||
? {}
|
||||
: {
|
||||
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) });
|
||||
});
|
||||
|
||||
fastify.post("/announcements", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify);
|
||||
const body = parseBody(WRITE_SCHEMA, request.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,
|
||||
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) } });
|
||||
});
|
||||
|
||||
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 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 } : {}),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
return sendData(reply, request, { item: mapItem(updated) });
|
||||
});
|
||||
|
||||
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) });
|
||||
});
|
||||
}
|
||||
module.exports = announcementsRoutes;
|
||||
在新工单中引用
屏蔽一个用户