文件
hyb-oa/server/src/routes/notifications.js
T

309 行
7.9 KiB
JavaScript

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;