const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { sendInstantMessageByChannel } = require("../services/notifications/channels"); const LIST_QUERY_SCHEMA = z.object({ page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), unreadOnly: z.enum(["0", "1", "true", "false"]).optional(), }); function parseListQuery(query) { const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } const page = parsed.data.page ?? 1; const pageSize = parsed.data.pageSize ?? 20; const unreadOnlyRaw = parsed.data.unreadOnly ?? "0"; const unreadOnly = unreadOnlyRaw === "1" || unreadOnlyRaw === "true"; return { page, pageSize, unreadOnly, }; } function parseNotificationId(value) { const parsed = z.string().uuid().safeParse(value); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { id: "invalid_uuid" }, }); } return parsed.data; } function mapNotificationItem(record) { return { id: record.id, type: record.type, title: record.title, body: record.body ?? null, targetType: record.target_type ?? null, targetId: record.target_id ?? null, readAt: record.read_at ? record.read_at.toISOString() : null, createdAt: record.created_at.toISOString(), }; } function mapNotificationChannel(record) { return { id: record.id, channelType: record.channel_type, name: record.name, enabled: record.enabled, configJson: record.config_json, templateJson: record.template_json, createdAt: record.created_at?.toISOString?.() ?? null, updatedAt: record.updated_at?.toISOString?.() ?? null, }; } async function notificationsRoutes(fastify) { fastify.get( "/notifications", { preHandler: fastify.authenticate, }, async (request, reply) => { const query = parseListQuery(request.query); const skip = (query.page - 1) * query.pageSize; const where = { recipient_employee_id: request.employee.id, ...(query.unreadOnly ? { read_at: null } : {}), }; const [items, total, unreadCount] = await Promise.all([ fastify.prisma.notification.findMany({ where, orderBy: { created_at: "desc" }, skip, take: query.pageSize, select: { id: true, type: true, title: true, body: true, target_type: true, target_id: true, read_at: true, created_at: true, }, }), fastify.prisma.notification.count({ where }), fastify.prisma.notification.count({ where: { recipient_employee_id: request.employee.id, read_at: null, }, }), ]); return sendData(reply, request, { page: query.page, pageSize: query.pageSize, total, unreadOnly: query.unreadOnly, unreadCount, items: items.map(mapNotificationItem), }); }, ); fastify.post( "/notifications/:id/mark-read", { preHandler: fastify.authenticate, }, async (request, reply) => { const notificationId = parseNotificationId(request.params?.id); const notification = await fastify.prisma.$transaction(async (tx) => { const locked = await tx.$queryRaw` SELECT id FROM "notifications" WHERE id = ${notificationId}::uuid FOR UPDATE `; if (!Array.isArray(locked) || locked.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const current = await tx.notification.findUnique({ where: { id: notificationId }, select: { id: true, recipient_employee_id: true, read_at: true, type: true, title: true, body: true, target_type: true, target_id: true, created_at: true, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } if (current.recipient_employee_id !== request.employee.id) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } if (current.read_at) { return current; } return tx.notification.update({ where: { id: notificationId }, data: { read_at: new Date(), }, select: { id: true, type: true, title: true, body: true, target_type: true, target_id: true, read_at: true, created_at: true, }, }); }); return sendData(reply, request, mapNotificationItem(notification)); }, ); fastify.get( "/notifications/channels", { preHandler: fastify.requirePermission("notification.channel.manage"), }, async (request, reply) => { const items = await fastify.prisma.notificationChannel.findMany({ orderBy: [{ channel_type: "asc" }, { name: "asc" }], }); return sendData(reply, request, { items: items.map(mapNotificationChannel), }); }, ); fastify.post( "/notifications/channels/test", { preHandler: fastify.requirePermission("notification.channel.manage"), }, async (request, reply) => { const schema = z .object({ channelType: z.string().trim().min(1).max(32), name: z.string().trim().min(1).max(100), }) .strict(); const parsed = schema.safeParse(request.body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } const input = parsed.data; const channel = await fastify.prisma.notificationChannel.upsert({ where: { channel_type_name: { channel_type: input.channelType, name: input.name, }, }, update: { enabled: true, config_json: { mode: "mock", }, updated_by: request.employee.id, }, create: { channel_type: input.channelType, name: input.name, enabled: true, config_json: { mode: "mock", }, created_by: request.employee.id, updated_by: request.employee.id, }, }); const result = await sendInstantMessageByChannel({ config: fastify.config, channel, payload: { title: "通知渠道测试", body: `测试人:${request.employee.id}`, targetType: "notification_channel", targetId: channel.id, }, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "NOTIFICATION_CHANNEL_TESTED", targetType: "notification_channel", targetId: channel.id, extra: { channelType: channel.channel_type, name: channel.name, resultCode: result.code, }, }), }); return sendData(reply, request, { channel: mapNotificationChannel(channel), testResult: result, }); }, ); } module.exports = notificationsRoutes;