feat(server): 站内通知基础设施与关键事件触发(F14,F30)
- 新增 notifications 表与 API(list/mark-read) - submit/approve/reject/return/transfer/pay/forgot-password 写入站内通知 - 通知失败降级,不阻塞主流程
这个提交包含在:
@@ -0,0 +1,23 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "notifications" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"recipient_employee_id" UUID NOT NULL,
|
||||||
|
"type" VARCHAR(64) NOT NULL,
|
||||||
|
"title" VARCHAR(200) NOT NULL,
|
||||||
|
"body" TEXT,
|
||||||
|
"target_type" VARCHAR(64),
|
||||||
|
"target_id" UUID,
|
||||||
|
"read_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "notifications_recipient_employee_id_created_at_idx" ON "notifications"("recipient_employee_id", "created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "idx_notifications_unread_query" ON "notifications"("recipient_employee_id", "read_at", "created_at");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_recipient_employee_id_fkey" FOREIGN KEY ("recipient_employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -172,6 +172,7 @@ model Employee {
|
|||||||
|
|
||||||
auth_sessions AuthSession[]
|
auth_sessions AuthSession[]
|
||||||
audit_logs AuditLog[] @relation("AuditActor")
|
audit_logs AuditLog[] @relation("AuditActor")
|
||||||
|
notifications Notification[]
|
||||||
|
|
||||||
submitted_reimbursements ReimbursementForm[] @relation("ReimbursementSubmitter")
|
submitted_reimbursements ReimbursementForm[] @relation("ReimbursementSubmitter")
|
||||||
owned_reimbursements ReimbursementForm[] @relation("ReimbursementOwner")
|
owned_reimbursements ReimbursementForm[] @relation("ReimbursementOwner")
|
||||||
@@ -286,6 +287,24 @@ model AuditLog {
|
|||||||
@@map("audit_logs")
|
@@map("audit_logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Notification {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
recipient_employee_id String @db.Uuid
|
||||||
|
type String @db.VarChar(64)
|
||||||
|
title String @db.VarChar(200)
|
||||||
|
body String?
|
||||||
|
target_type String? @db.VarChar(64)
|
||||||
|
target_id String? @db.Uuid
|
||||||
|
read_at DateTime?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
|
||||||
|
recipient Employee @relation(fields: [recipient_employee_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([recipient_employee_id, created_at])
|
||||||
|
@@index([recipient_employee_id, read_at, created_at], map: "idx_notifications_unread_query")
|
||||||
|
@@map("notifications")
|
||||||
|
}
|
||||||
|
|
||||||
model ExpenseType {
|
model ExpenseType {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
code String @unique
|
code String @unique
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const departmentsRoutes = require("./routes/departments");
|
|||||||
const employeesRoutes = require("./routes/employees");
|
const employeesRoutes = require("./routes/employees");
|
||||||
const expenseTypesRoutes = require("./routes/expenseTypes");
|
const expenseTypesRoutes = require("./routes/expenseTypes");
|
||||||
const configReadRoutes = require("./routes/configRead");
|
const configReadRoutes = require("./routes/configRead");
|
||||||
|
const notificationsRoutes = require("./routes/notifications");
|
||||||
const { startDelegationRevokeJob } = require("./jobs/delegationRevokeJob");
|
const { startDelegationRevokeJob } = require("./jobs/delegationRevokeJob");
|
||||||
const { startOcrJob } = require("./jobs/ocrJob");
|
const { startOcrJob } = require("./jobs/ocrJob");
|
||||||
const { startInvoiceArchiveJob } = require("./jobs/invoiceArchiveJob");
|
const { startInvoiceArchiveJob } = require("./jobs/invoiceArchiveJob");
|
||||||
@@ -200,6 +201,7 @@ async function buildApp() {
|
|||||||
await app.register(departmentsRoutes, { prefix: "/api" });
|
await app.register(departmentsRoutes, { prefix: "/api" });
|
||||||
await app.register(expenseTypesRoutes, { prefix: "/api" });
|
await app.register(expenseTypesRoutes, { prefix: "/api" });
|
||||||
await app.register(configReadRoutes, { prefix: "/api" });
|
await app.register(configReadRoutes, { prefix: "/api" });
|
||||||
|
await app.register(notificationsRoutes, { prefix: "/api" });
|
||||||
|
|
||||||
startDelegationRevokeJob(app);
|
startDelegationRevokeJob(app);
|
||||||
startOcrJob(app);
|
startOcrJob(app);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const { buildAuditLogData } = require("../utils/audit");
|
|||||||
const { hashPassword, randomRefreshToken, sha256Hex, verifyPassword } = require("../utils/crypto");
|
const { hashPassword, randomRefreshToken, sha256Hex, verifyPassword } = require("../utils/crypto");
|
||||||
const { validatePassword } = require("../utils/passwordPolicy");
|
const { validatePassword } = require("../utils/passwordPolicy");
|
||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require("../utils/reply");
|
||||||
|
const { createNotifications } = require("../services/notifications");
|
||||||
|
|
||||||
function getHeaderString(request, name) {
|
function getHeaderString(request, name) {
|
||||||
const value = request.headers[name];
|
const value = request.headers[name];
|
||||||
@@ -661,6 +662,22 @@ async function authRoutes(fastify) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createNotifications(fastify.prisma, {
|
||||||
|
recipientEmployeeIds: [employee.id],
|
||||||
|
type: "auth.password_reset_requested",
|
||||||
|
title: "【安全】收到密码重置请求",
|
||||||
|
body: "如非本人操作,请尽快联系管理员核查账号安全。",
|
||||||
|
targetType: "employee",
|
||||||
|
targetId: employee.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(
|
||||||
|
{ err: error, employeeId: employee.id, requestId: request.id },
|
||||||
|
"Failed to create password reset requested notifications",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (fastify.config.devShowPasswordResetCode) {
|
if (fastify.config.devShowPasswordResetCode) {
|
||||||
fastify.log.info(
|
fastify.log.info(
|
||||||
{ employeeId: employee.id, email, code, requestId: request.id },
|
{ employeeId: employee.id, email, code, requestId: request.id },
|
||||||
@@ -763,6 +780,22 @@ async function authRoutes(fastify) {
|
|||||||
extra: {},
|
extra: {},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createNotifications(tx, {
|
||||||
|
recipientEmployeeIds: [employee.id],
|
||||||
|
type: "auth.password_reset_succeeded",
|
||||||
|
title: "【安全】密码已重置",
|
||||||
|
body: "你的账号密码已重置成功。如非本人操作,请立即联系管理员。",
|
||||||
|
targetType: "employee",
|
||||||
|
targetId: employee.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(
|
||||||
|
{ err: error, employeeId: employee.id, requestId: request.id },
|
||||||
|
"Failed to create password reset succeeded notifications",
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config));
|
reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -4,6 +4,10 @@ const { sendData } = require("../utils/reply");
|
|||||||
const { buildAuditLogData } = require("../utils/audit");
|
const { buildAuditLogData } = require("../utils/audit");
|
||||||
const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date");
|
const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||||
const { findActiveDelegationForDelegate } = require("../utils/delegation");
|
const { findActiveDelegationForDelegate } = require("../utils/delegation");
|
||||||
|
const {
|
||||||
|
createNotifications,
|
||||||
|
resolveRecipientsForAssignee,
|
||||||
|
} = require("../services/notifications");
|
||||||
const {
|
const {
|
||||||
buildWorkflowStepsFromApprovalRule,
|
buildWorkflowStepsFromApprovalRule,
|
||||||
resolveActionableStep,
|
resolveActionableStep,
|
||||||
@@ -1347,6 +1351,27 @@ async function reimbursementRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recipients = await resolveRecipientsForAssignee(tx, {
|
||||||
|
assigneeId: workflowTask.assignee_id,
|
||||||
|
assigneeRole: workflowTask.assignee_role,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createNotifications(tx, {
|
||||||
|
recipientEmployeeIds: recipients,
|
||||||
|
type: "workflow.task_assigned",
|
||||||
|
title: `【待办】报销单 ${formNumber} 待审批`,
|
||||||
|
body: `报销单 ${formNumber} 已提交,请及时处理。`,
|
||||||
|
targetType: "workflow_task",
|
||||||
|
targetId: workflowTask.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(
|
||||||
|
{ err: error, reimbursementId, taskId: workflowTask.id },
|
||||||
|
"Failed to create task assigned notifications",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (reimbursement.submitter_id !== reimbursement.owner_id) {
|
if (reimbursement.submitter_id !== reimbursement.owner_id) {
|
||||||
await tx.auditLog.create({
|
await tx.auditLog.create({
|
||||||
data: buildAuditLogData(request, {
|
data: buildAuditLogData(request, {
|
||||||
@@ -1524,7 +1549,8 @@ async function reimbursementRoutes(fastify) {
|
|||||||
|
|
||||||
await fastify.prisma.$transaction(async (tx) => {
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
const reimbursementRows = await tx.$queryRaw`
|
const reimbursementRows = await tx.$queryRaw`
|
||||||
SELECT id, status, payment_voucher_no, payment_date, payment_channel, paid_at, paid_by
|
SELECT id, status, form_number, owner_id, submitter_id,
|
||||||
|
payment_voucher_no, payment_date, payment_channel, paid_at, paid_by
|
||||||
FROM "reimbursement_forms"
|
FROM "reimbursement_forms"
|
||||||
WHERE id = ${reimbursementId}::uuid
|
WHERE id = ${reimbursementId}::uuid
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
@@ -1596,6 +1622,25 @@ async function reimbursementRoutes(fastify) {
|
|||||||
updated_by: request.employee.id,
|
updated_by: request.employee.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recipients = Array.from(
|
||||||
|
new Set([reimbursement.owner_id, reimbursement.submitter_id].filter(Boolean)),
|
||||||
|
);
|
||||||
|
await createNotifications(tx, {
|
||||||
|
recipientEmployeeIds: recipients,
|
||||||
|
type: "reimbursement.paid",
|
||||||
|
title: `【已付款】报销单 ${reimbursement.form_number ?? reimbursementId}`,
|
||||||
|
body: `报销单 ${reimbursement.form_number ?? reimbursementId} 已完成付款。`,
|
||||||
|
targetType: "reimbursement_form",
|
||||||
|
targetId: reimbursementId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(
|
||||||
|
{ err: error, reimbursementId },
|
||||||
|
"Failed to create reimbursement paid notifications",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ const { AppError } = require("../utils/AppError");
|
|||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require("../utils/reply");
|
||||||
const { resolvePermissionsForRoles, hasPermission, ROLE_PERMISSIONS } = require("../constants/permissions");
|
const { resolvePermissionsForRoles, hasPermission, ROLE_PERMISSIONS } = require("../constants/permissions");
|
||||||
const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow");
|
const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow");
|
||||||
|
const {
|
||||||
|
createNotifications,
|
||||||
|
resolveRecipientsForAssignee,
|
||||||
|
} = require("../services/notifications");
|
||||||
const {
|
const {
|
||||||
findActiveDelegationForDelegate,
|
findActiveDelegationForDelegate,
|
||||||
listActiveDelegationPrincipals,
|
listActiveDelegationPrincipals,
|
||||||
@@ -312,6 +316,18 @@ async function loadWorkflowInstanceForTask(tx, instanceId) {
|
|||||||
return workflowInstance;
|
return workflowInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadReimbursementNotifyContext(tx, reimbursementId) {
|
||||||
|
return tx.reimbursementForm.findUnique({
|
||||||
|
where: { id: reimbursementId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
form_number: true,
|
||||||
|
owner_id: true,
|
||||||
|
submitter_id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function createSkipHistoryRecords(tx, workflowInstanceId, actorId, ownerId, skippedSteps) {
|
async function createSkipHistoryRecords(tx, workflowInstanceId, actorId, ownerId, skippedSteps) {
|
||||||
for (const skipped of skippedSteps) {
|
for (const skipped of skippedSteps) {
|
||||||
await tx.workflowHistory.create({
|
await tx.workflowHistory.create({
|
||||||
@@ -794,6 +810,31 @@ async function workflowRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reimbursement = await loadReimbursementNotifyContext(
|
||||||
|
tx,
|
||||||
|
workflowInstance.entity_id,
|
||||||
|
);
|
||||||
|
const recipients = await resolveRecipientsForAssignee(tx, {
|
||||||
|
assigneeId: actionable.assigneeId,
|
||||||
|
assigneeRole: actionable.assigneeRole,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createNotifications(tx, {
|
||||||
|
recipientEmployeeIds: recipients,
|
||||||
|
type: "workflow.task_assigned",
|
||||||
|
title: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`,
|
||||||
|
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`,
|
||||||
|
targetType: "workflow_task",
|
||||||
|
targetId: nextTask.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(
|
||||||
|
{ err: error, taskId: nextTask.id },
|
||||||
|
"Failed to create task assigned notifications",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
taskId,
|
taskId,
|
||||||
outcome: "approved",
|
outcome: "approved",
|
||||||
@@ -838,6 +879,27 @@ async function workflowRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
|
||||||
|
const recipients = Array.from(
|
||||||
|
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await createNotifications(tx, {
|
||||||
|
recipientEmployeeIds: recipients,
|
||||||
|
type: "reimbursement.approved",
|
||||||
|
title: `【已批准】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
||||||
|
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已审批通过,等待财务付款。`,
|
||||||
|
targetType: "reimbursement_form",
|
||||||
|
targetId: workflowInstance.entity_id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(
|
||||||
|
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
|
||||||
|
"Failed to create reimbursement approved notifications",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
taskId,
|
taskId,
|
||||||
outcome: "approved",
|
outcome: "approved",
|
||||||
@@ -916,6 +978,27 @@ async function workflowRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
|
||||||
|
const recipients = Array.from(
|
||||||
|
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await createNotifications(tx, {
|
||||||
|
recipientEmployeeIds: recipients,
|
||||||
|
type: "reimbursement.rejected",
|
||||||
|
title: `【已驳回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
||||||
|
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已被驳回:${comment}`,
|
||||||
|
targetType: "reimbursement_form",
|
||||||
|
targetId: workflowInstance.entity_id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(
|
||||||
|
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
|
||||||
|
"Failed to create reimbursement rejected notifications",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
taskId,
|
taskId,
|
||||||
outcome: "rejected",
|
outcome: "rejected",
|
||||||
@@ -993,6 +1076,27 @@ async function workflowRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
|
||||||
|
const recipients = Array.from(
|
||||||
|
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await createNotifications(tx, {
|
||||||
|
recipientEmployeeIds: recipients,
|
||||||
|
type: "reimbursement.returned",
|
||||||
|
title: `【已退回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
||||||
|
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已退回:${comment}`,
|
||||||
|
targetType: "reimbursement_form",
|
||||||
|
targetId: workflowInstance.entity_id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(
|
||||||
|
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
|
||||||
|
"Failed to create reimbursement returned notifications",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
taskId,
|
taskId,
|
||||||
outcome: "returned",
|
outcome: "returned",
|
||||||
@@ -1075,6 +1179,23 @@ async function workflowRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
|
||||||
|
await createNotifications(tx, {
|
||||||
|
recipientEmployeeIds: [toApproverId],
|
||||||
|
type: "workflow.task_assigned",
|
||||||
|
title: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 转审待处理`,
|
||||||
|
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已转审给你,请及时处理。`,
|
||||||
|
targetType: "workflow_task",
|
||||||
|
targetId: taskId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(
|
||||||
|
{ err: error, taskId, toApproverId },
|
||||||
|
"Failed to create task transferred notifications",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
taskId,
|
taskId,
|
||||||
outcome: "transferred",
|
outcome: "transferred",
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
function uniq(values) {
|
||||||
|
return Array.from(new Set((values ?? []).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRecipientsForAssignee(tx, params) {
|
||||||
|
if (params.assigneeId) {
|
||||||
|
return [params.assigneeId];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.assigneeRole) {
|
||||||
|
const rows = await tx.employeeRole.findMany({
|
||||||
|
where: {
|
||||||
|
role_code: params.assigneeRole,
|
||||||
|
employee: {
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
employee_id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.map((row) => row.employee_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNotifications(tx, input) {
|
||||||
|
const recipientIds = uniq(input.recipientEmployeeIds);
|
||||||
|
if (recipientIds.length === 0) {
|
||||||
|
return { count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = String(input.title ?? "").slice(0, 200);
|
||||||
|
const body = input.body != null ? String(input.body).slice(0, 5000) : null;
|
||||||
|
const type = String(input.type ?? "").slice(0, 64);
|
||||||
|
const targetType = input.targetType != null ? String(input.targetType).slice(0, 64) : null;
|
||||||
|
|
||||||
|
return tx.notification.createMany({
|
||||||
|
data: recipientIds.map((recipientId) => ({
|
||||||
|
recipient_employee_id: recipientId,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
target_type: targetType,
|
||||||
|
target_id: input.targetId ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createNotifications,
|
||||||
|
resolveRecipientsForAssignee,
|
||||||
|
};
|
||||||
|
|
||||||
在新工单中引用
屏蔽一个用户