feat(server): 站内通知基础设施与关键事件触发(F14,F30)
- 新增 notifications 表与 API(list/mark-read) - submit/approve/reject/return/transfer/pay/forgot-password 写入站内通知 - 通知失败降级,不阻塞主流程
这个提交包含在:
@@ -0,0 +1,195 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
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));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = notificationsRoutes;
|
||||
|
||||
在新工单中引用
屏蔽一个用户