feat(server): 站内通知基础设施与关键事件触发(F14,F30)

- 新增 notifications 表与 API(list/mark-read)
- submit/approve/reject/return/transfer/pay/forgot-password 写入站内通知
- 通知失败降级,不阻塞主流程
这个提交包含在:
2026-02-09 17:45:17 +08:00
父节点 8bd068f2a3
当前提交 5faf843cee
共修改 8 个文件,包含 495 行新增和 1 行删除
+121
查看文件
@@ -3,6 +3,10 @@ const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { resolvePermissionsForRoles, hasPermission, ROLE_PERMISSIONS } = require("../constants/permissions");
const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow");
const {
createNotifications,
resolveRecipientsForAssignee,
} = require("../services/notifications");
const {
findActiveDelegationForDelegate,
listActiveDelegationPrincipals,
@@ -312,6 +316,18 @@ async function loadWorkflowInstanceForTask(tx, instanceId) {
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) {
for (const skipped of skippedSteps) {
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 = {
taskId,
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 = {
taskId,
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 = {
taskId,
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 = {
taskId,
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 = {
taskId,
outcome: "transferred",