309 行
7.9 KiB
JavaScript
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;
|