From 9939657790a65ff48a97c38e30bff7082f03b998 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 22 Mar 2026 11:31:08 +0800 Subject: [PATCH] =?UTF-8?q?R23-1/R23-2/R23-4=EF=BC=9A=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=B2=9F=E9=80=9A=E5=9B=9E=E5=A1=AB=E4=B8=8E?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=E6=B2=BB=E7=90=86=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/trialrun/.env.example | 2 + .../migration.sql | 68 +++ server/prisma/schema.prisma | 75 ++- server/src/config.js | 2 + server/src/routes/portal.js | 81 +-- server/src/routes/projects.js | 401 +++++++++++++-- server/src/routes/search.js | 76 ++- server/src/scripts/seed-config.js | 14 + server/src/scripts/seed-trialrun.js | 5 + server/src/services/featureFlags/index.js | 10 + .../src/services/notifications/imEnqueue.js | 3 +- server/tests/unit/routes/projects.test.js | 483 ++++++++++++++++++ server/tests/unit/routes/search.test.js | 153 ++++++ server/tests/unit/services/imEnqueue.test.js | 55 ++ 14 files changed, 1311 insertions(+), 117 deletions(-) create mode 100644 server/prisma/migrations/20260322093000_r23_trial_hardening/migration.sql create mode 100644 server/tests/unit/routes/projects.test.js create mode 100644 server/tests/unit/routes/search.test.js diff --git a/deploy/trialrun/.env.example b/deploy/trialrun/.env.example index a3255e1b..ed824391 100644 --- a/deploy/trialrun/.env.example +++ b/deploy/trialrun/.env.example @@ -121,6 +121,8 @@ EMPLOYEE_LIFECYCLE_ENABLED="true" TODO_BOARD_ENABLED="true" PROJECT_DOMAIN_ENABLED="true" PROJECT_LINKING_ENABLED="true" +PROJECT_COMMUNICATION_LOG_ENABLED="true" +PROJECT_CLOSURE_CHECKLIST_ENABLED="true" CONFIDENTIAL_DOC_REFS_ENABLED="true" PUBLIC_SECURITY_HARDENING_ENABLED="true" PAYMENT_BATCH_ENABLED="false" diff --git a/server/prisma/migrations/20260322093000_r23_trial_hardening/migration.sql b/server/prisma/migrations/20260322093000_r23_trial_hardening/migration.sql new file mode 100644 index 00000000..ec788655 --- /dev/null +++ b/server/prisma/migrations/20260322093000_r23_trial_hardening/migration.sql @@ -0,0 +1,68 @@ +-- CreateEnum +CREATE TYPE "ProjectCommunicationChannel" AS ENUM ('wechat', 'phone', 'meeting', 'wecom', 'email', 'onsite', 'other'); + +-- CreateEnum +CREATE TYPE "ProjectClosureChecklistStatus" AS ENUM ('pending', 'done', 'waived'); + +-- CreateTable +CREATE TABLE "project_communication_logs" ( + "id" UUID NOT NULL, + "project_id" UUID NOT NULL, + "happened_at" TIMESTAMP(3) NOT NULL, + "channel" "ProjectCommunicationChannel" NOT NULL, + "external_contact_name" TEXT, + "external_org_name" TEXT, + "summary" TEXT NOT NULL, + "next_action" TEXT, + "next_follow_up_date" DATE, + "created_by" UUID NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "project_communication_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "project_closure_checklist_items" ( + "id" UUID NOT NULL, + "project_id" UUID NOT NULL, + "item_key" VARCHAR(64) NOT NULL, + "title" TEXT NOT NULL, + "status" "ProjectClosureChecklistStatus" NOT NULL DEFAULT 'pending', + "note" TEXT, + "sort_order" INTEGER NOT NULL DEFAULT 100, + "completed_at" TIMESTAMP(3), + "completed_by" UUID, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "project_closure_checklist_items_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "idx_project_communication_logs_project_happened" ON "project_communication_logs"("project_id", "happened_at"); + +-- CreateIndex +CREATE INDEX "idx_project_communication_logs_followup" ON "project_communication_logs"("project_id", "next_follow_up_date"); + +-- CreateIndex +CREATE INDEX "idx_project_communication_logs_creator_created" ON "project_communication_logs"("created_by", "created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "project_closure_checklists_project_key_ux" ON "project_closure_checklist_items"("project_id", "item_key"); + +-- CreateIndex +CREATE INDEX "idx_project_closure_checklists_project_status" ON "project_closure_checklist_items"("project_id", "status", "sort_order"); + +-- CreateIndex +CREATE INDEX "idx_project_closure_checklists_completed" ON "project_closure_checklist_items"("completed_by", "completed_at"); + +-- AddForeignKey +ALTER TABLE "project_communication_logs" ADD CONSTRAINT "project_communication_logs_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project_communication_logs" ADD CONSTRAINT "project_communication_logs_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project_closure_checklist_items" ADD CONSTRAINT "project_closure_checklist_items_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project_closure_checklist_items" ADD CONSTRAINT "project_closure_checklist_items_completed_by_fkey" FOREIGN KEY ("completed_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 3433b2d3..78b29d9a 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -417,6 +417,22 @@ enum ProjectDeliveryRecordType { handover } +enum ProjectCommunicationChannel { + wechat + phone + meeting + wecom + email + onsite + other +} + +enum ProjectClosureChecklistStatus { + pending + done + waived +} + enum ConfidentialityLevel { public internal @@ -564,6 +580,8 @@ model Employee { created_project_tasks ProjectTask[] @relation("ProjectTaskCreator") owned_project_risks ProjectRisk[] @relation("ProjectRiskOwner") owned_project_delivery_records ProjectDeliveryRecord[] @relation("ProjectDeliveryRecordOwner") + created_project_communication_logs ProjectCommunicationLog[] @relation("ProjectCommunicationLogCreator") + completed_project_closure_checklists ProjectClosureChecklistItem[] @relation("ProjectClosureChecklistItemCompleter") owned_contract_registries ContractRegistry[] @relation("ContractRegistryOwner") owned_confidential_document_refs ConfidentialDocumentRef[] @relation("ConfidentialDocumentRefOwner") @@ -1767,10 +1785,12 @@ model Project { reimbursements ReimbursementForm[] travel_requests TravelRequest[] todo_items TodoItem[] - weekly_reports ProjectLinkOnWeeklyReport[] - contracts ContractRegistry[] - deliveries ProjectDeliveryRecord[] - document_refs ConfidentialDocumentRef[] + weekly_reports ProjectLinkOnWeeklyReport[] + communication_logs ProjectCommunicationLog[] + closure_checklist_items ProjectClosureChecklistItem[] + contracts ContractRegistry[] + deliveries ProjectDeliveryRecord[] + document_refs ConfidentialDocumentRef[] @@index([owner_employee_id, status], map: "idx_projects_owner_status") @@index([manager_employee_id, status], map: "idx_projects_manager_status") @@ -1880,6 +1900,53 @@ model ProjectLinkOnWeeklyReport { @@map("weekly_report_project_links") } +model ProjectCommunicationLog { + id String @id @default(uuid()) @db.Uuid + project_id String @db.Uuid + happened_at DateTime + channel ProjectCommunicationChannel + external_contact_name String? + external_org_name String? + summary String + next_action String? + next_follow_up_date DateTime? @db.Date + created_by String @db.Uuid + + created_at DateTime @default(now()) + updated_at DateTime @default(now()) @updatedAt + + project Project @relation(fields: [project_id], references: [id], onDelete: Cascade) + creator Employee @relation("ProjectCommunicationLogCreator", fields: [created_by], references: [id], onDelete: Restrict) + + @@index([project_id, happened_at], map: "idx_project_communication_logs_project_happened") + @@index([project_id, next_follow_up_date], map: "idx_project_communication_logs_followup") + @@index([created_by, created_at], map: "idx_project_communication_logs_creator_created") + @@map("project_communication_logs") +} + +model ProjectClosureChecklistItem { + id String @id @default(uuid()) @db.Uuid + project_id String @db.Uuid + item_key String @db.VarChar(64) + title String + status ProjectClosureChecklistStatus @default(pending) + note String? + sort_order Int @default(100) + completed_at DateTime? + completed_by String? @db.Uuid + + created_at DateTime @default(now()) + updated_at DateTime @default(now()) @updatedAt + + project Project @relation(fields: [project_id], references: [id], onDelete: Cascade) + completer Employee? @relation("ProjectClosureChecklistItemCompleter", fields: [completed_by], references: [id], onDelete: SetNull) + + @@unique([project_id, item_key], map: "project_closure_checklists_project_key_ux") + @@index([project_id, status, sort_order], map: "idx_project_closure_checklists_project_status") + @@index([completed_by, completed_at], map: "idx_project_closure_checklists_completed") + @@map("project_closure_checklist_items") +} + model ContractRegistry { id String @id @default(uuid()) @db.Uuid contract_no String @unique diff --git a/server/src/config.js b/server/src/config.js index b4c386f8..f5e45d08 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -126,6 +126,8 @@ function getConfig() { wecomEntryEnabled: process.env.WECOM_ENTRY_ENABLED === "true", projectDomainEnabled: process.env.PROJECT_DOMAIN_ENABLED === "true", projectLinkingEnabled: process.env.PROJECT_LINKING_ENABLED === "true", + projectCommunicationLogEnabled: process.env.PROJECT_COMMUNICATION_LOG_ENABLED === "true", + projectClosureChecklistEnabled: process.env.PROJECT_CLOSURE_CHECKLIST_ENABLED === "true", confidentialDocRefsEnabled: process.env.CONFIDENTIAL_DOC_REFS_ENABLED === "true", publicSecurityHardeningEnabled: process.env.PUBLIC_SECURITY_HARDENING_ENABLED === "true", diff --git a/server/src/routes/portal.js b/server/src/routes/portal.js index 793ac756..d9c6237a 100644 --- a/server/src/routes/portal.js +++ b/server/src/routes/portal.js @@ -44,6 +44,23 @@ async function evaluateOptionalFlag(fastify, request, flagKey) { return gate.enabled; } +function canReadAllProjects(fastify, employee) { + return fastify.hasPermission(employee, "project.view_all") || fastify.hasPermission(employee, "project.manage") || fastify.hasPermission(employee, "*"); +} + +function projectScopeWhere(fastify, employee) { + if (canReadAllProjects(fastify, employee)) { + return undefined; + } + return { + OR: [ + { owner_employee_id: employee.id }, + { manager_employee_id: employee.id }, + { members: { some: { employee_id: employee.id, left_at: null } } }, + ], + }; +} + function resolveWeekStart(rawDateString) { const date = parseDateOnlyString(rawDateString); const day = date.getUTCDay() || 7; @@ -190,6 +207,8 @@ async function portalRoutes(fastify) { todoBoard: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.todoBoard), paymentBatch: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.paymentBatch), projectDomain: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.projectDomain), + projectCommunicationLog: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.projectCommunicationLog), + projectClosureChecklist: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.projectClosureChecklist), }; const [ @@ -210,6 +229,8 @@ async function portalRoutes(fastify) { overdueProjectMilestoneCount, highPriorityProjectTaskCount, openProjectRiskCount, + projectFollowUpDueTodayCount, + closurePendingProjectCount, ] = await Promise.all([ fastify.prisma.workflowTask.count({ where: { status: "pending", OR: assigneeFilters } }), fastify.prisma.reimbursementForm.count({ where: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] } }), @@ -270,15 +291,7 @@ async function portalRoutes(fastify) { : Promise.resolve(0), featureEnabled.projectDomain ? fastify.prisma.project.count({ - where: fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage") || fastify.hasPermission(request.employee, "*") - ? {} - : { - OR: [ - { owner_employee_id: request.employee.id }, - { manager_employee_id: request.employee.id }, - { members: { some: { employee_id: request.employee.id, left_at: null } } }, - ], - }, + where: projectScopeWhere(fastify, request.employee) ?? {}, }) : Promise.resolve(0), featureEnabled.projectDomain @@ -286,15 +299,7 @@ async function portalRoutes(fastify) { where: { status: { in: ["pending", "in_progress", "delayed"] }, due_date: { lt: new Date() }, - project: fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage") || fastify.hasPermission(request.employee, "*") - ? undefined - : { - OR: [ - { owner_employee_id: request.employee.id }, - { manager_employee_id: request.employee.id }, - { members: { some: { employee_id: request.employee.id, left_at: null } } }, - ], - }, + project: projectScopeWhere(fastify, request.employee), }, }) : Promise.resolve(0), @@ -303,15 +308,7 @@ async function portalRoutes(fastify) { where: { status: { in: ["todo", "doing", "blocked"] }, priority: { in: ["high", "urgent"] }, - project: fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage") || fastify.hasPermission(request.employee, "*") - ? undefined - : { - OR: [ - { owner_employee_id: request.employee.id }, - { manager_employee_id: request.employee.id }, - { members: { some: { employee_id: request.employee.id, left_at: null } } }, - ], - }, + project: projectScopeWhere(fastify, request.employee), }, }) : Promise.resolve(0), @@ -319,18 +316,28 @@ async function portalRoutes(fastify) { ? fastify.prisma.projectRisk.count({ where: { status: { not: "closed" }, - project: fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage") || fastify.hasPermission(request.employee, "*") - ? undefined - : { - OR: [ - { owner_employee_id: request.employee.id }, - { manager_employee_id: request.employee.id }, - { members: { some: { employee_id: request.employee.id, left_at: null } } }, - ], - }, + project: projectScopeWhere(fastify, request.employee), }, }) : Promise.resolve(0), + featureEnabled.projectDomain && featureEnabled.projectCommunicationLog + ? fastify.prisma.projectCommunicationLog.count({ + where: { + next_follow_up_date: parseDateOnlyString(today), + project: projectScopeWhere(fastify, request.employee), + }, + }) + : Promise.resolve(0), + featureEnabled.projectDomain && featureEnabled.projectClosureChecklist + ? fastify.prisma.projectClosureChecklistItem.findMany({ + where: { + status: "pending", + project: projectScopeWhere(fastify, request.employee), + }, + distinct: ["project_id"], + select: { project_id: true }, + }).then((items) => items.length) + : Promise.resolve(0), ]); const todayPresence = featureEnabled.presenceBoard @@ -507,6 +514,8 @@ async function portalRoutes(fastify) { overdueMilestonesCount: overdueProjectMilestoneCount, highPriorityTaskCount: highPriorityProjectTaskCount, openRiskCount: openProjectRiskCount, + followUpDueTodayCount: projectFollowUpDueTodayCount, + closurePendingProjectCount, href: "/projects", } : null, diff --git a/server/src/routes/projects.js b/server/src/routes/projects.js index 1aea592b..817e1e9e 100644 --- a/server/src/routes/projects.js +++ b/server/src/routes/projects.js @@ -86,6 +86,34 @@ const DELIVERY_CREATE_SCHEMA = z.object({ ownerEmployeeId: UUID_SCHEMA.optional(), }).strict(); +const COMMUNICATION_CHANNEL_SCHEMA = z.enum(["wechat", "phone", "meeting", "wecom", "email", "onsite", "other"]); +const CLOSURE_CHECKLIST_STATUS_SCHEMA = z.enum(["pending", "done", "waived"]); +const COMMUNICATION_LOG_CREATE_SCHEMA = z.object({ + happenedAt: z.string().datetime(), + channel: COMMUNICATION_CHANNEL_SCHEMA, + externalContactName: z.string().trim().max(120).nullable().optional(), + externalOrgName: z.string().trim().max(200).nullable().optional(), + summary: z.string().trim().min(1).max(1000), + nextAction: z.string().trim().max(300).nullable().optional(), + nextFollowUpDate: DATE_SCHEMA.nullable().optional(), +}).strict(); +const COMMUNICATION_LOG_PATCH_SCHEMA = COMMUNICATION_LOG_CREATE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); +const CLOSURE_CHECKLIST_PATCH_SCHEMA = z.object({ + status: CLOSURE_CHECKLIST_STATUS_SCHEMA.optional(), + note: z.string().trim().max(500).nullable().optional(), +}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); + +const PROJECT_CLOSURE_TEMPLATE = [ + { itemKey: "delivery_record_checked", title: "确认交付/验收记录齐全", sortOrder: 10 }, + { itemKey: "risk_review_done", title: "确认项目风险已收口或转监控", sortOrder: 20 }, + { itemKey: "reimbursement_cleared", title: "确认关联报销已处理", sortOrder: 30 }, + { itemKey: "travel_cleared", title: "确认关联差旅已处理", sortOrder: 40 }, + { itemKey: "contract_archive_confirmed", title: "确认合同台账与归档位置已核对", sortOrder: 50 }, + { itemKey: "handover_note_done", title: "确认项目交接说明已补齐", sortOrder: 60 }, +]; + +const PROJECT_COMMUNICATION_EDIT_WINDOW_MS = 24 * 60 * 60 * 1000; + function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); } @@ -131,36 +159,51 @@ function hasGlobalProjectManage(fastify, employee) { return fastify.hasPermission(employee, "project.manage") || fastify.hasPermission(employee, "system.settings"); } -async function loadProjectContext(prisma, projectId, employeeId) { - return prisma.project.findUnique({ - where: { id: projectId }, - include: { - owner: { select: { id: true, employee_no: true, name: true } }, - manager: { select: { id: true, employee_no: true, name: true } }, - members: { - where: { left_at: null }, - include: { employee: { select: { id: true, employee_no: true, name: true } } }, - orderBy: [{ role: "asc" }, { joined_at: "asc" }], +function buildProjectDetailInclude() { + return { + owner: { select: { id: true, employee_no: true, name: true } }, + manager: { select: { id: true, employee_no: true, name: true } }, + members: { + where: { left_at: null }, + include: { employee: { select: { id: true, employee_no: true, name: true } } }, + orderBy: [{ role: "asc" }, { joined_at: "asc" }], + }, + milestones: { orderBy: [{ sort_order: "asc" }, { due_date: "asc" }] }, + tasks: { + include: { + assignee: { select: { id: true, employee_no: true, name: true } }, + creator: { select: { id: true, employee_no: true, name: true } }, }, - milestones: { orderBy: [{ sort_order: "asc" }, { due_date: "asc" }] }, - tasks: { include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } }, orderBy: [{ priority: "desc" }, { due_date: "asc" }, { created_at: "desc" }] }, - risks: { include: { owner: { select: { id: true, employee_no: true, name: true } } }, orderBy: [{ level: "desc" }, { created_at: "desc" }] }, - _count: { - select: { - members: true, - milestones: true, - tasks: true, - risks: true, - reimbursements: true, - travel_requests: true, - todo_items: true, - weekly_reports: true, - contracts: true, - deliveries: true, - document_refs: true, - }, + orderBy: [{ priority: "desc" }, { due_date: "asc" }, { created_at: "desc" }], + }, + risks: { + include: { owner: { select: { id: true, employee_no: true, name: true } } }, + orderBy: [{ level: "desc" }, { created_at: "desc" }], + }, + _count: { + select: { + members: true, + milestones: true, + tasks: true, + risks: true, + reimbursements: true, + travel_requests: true, + todo_items: true, + weekly_reports: true, + communication_logs: true, + closure_checklist_items: true, + contracts: true, + deliveries: true, + document_refs: true, }, }, + }; +} + +async function loadProjectContext(prisma, projectId) { + return prisma.project.findUnique({ + where: { id: projectId }, + include: buildProjectDetailInclude(), }); } @@ -224,6 +267,139 @@ function mapEmployeeBasic(employee) { return { id: employee.id, employeeNo: employee.employee_no, name: employee.name }; } + +function startOfUtcDay(value = new Date()) { + return new Date(`${value.toISOString().slice(0, 10)}T00:00:00.000Z`); +} + +function endOfUtcDay(value = new Date()) { + return new Date(`${value.toISOString().slice(0, 10)}T23:59:59.999Z`); +} + +function mapProjectCommunicationLog(item, canEdit) { + return { + id: item.id, + happenedAt: item.happened_at?.toISOString?.() ?? null, + channel: item.channel, + externalContactName: item.external_contact_name ?? null, + externalOrgName: item.external_org_name ?? null, + summary: item.summary, + nextAction: item.next_action ?? null, + nextFollowUpDate: item.next_follow_up_date?.toISOString?.().slice(0, 10) ?? null, + createdBy: item.created_by, + createdAt: item.created_at?.toISOString?.() ?? null, + updatedAt: item.updated_at?.toISOString?.() ?? null, + canEdit, + creator: mapEmployeeBasic(item.creator), + }; +} + +function mapProjectClosureChecklistItem(item, canEdit) { + return { + id: item.id, + itemKey: item.item_key, + title: item.title, + status: item.status, + note: item.note ?? null, + sortOrder: item.sort_order, + completedAt: item.completed_at?.toISOString?.() ?? null, + completedBy: item.completed_by ?? null, + canEdit, + completer: mapEmployeeBasic(item.completer), + }; +} + +function canEditCommunicationLog(fastify, employee, project, item) { + if (hasGlobalProjectManage(fastify, employee) || canManageProjectRecord(fastify, employee.id, project)) { + return true; + } + if (item.created_by !== employee.id) { + return false; + } + return Date.now() - new Date(item.created_at).getTime() <= PROJECT_COMMUNICATION_EDIT_WINDOW_MS; +} + +async function ensureClosureChecklistBootstrapped(prisma, projectId) { + const count = await prisma.projectClosureChecklistItem.count({ where: { project_id: projectId } }); + if (count > 0) { + return false; + } + await prisma.projectClosureChecklistItem.createMany({ + data: PROJECT_CLOSURE_TEMPLATE.map((item) => ({ + project_id: projectId, + item_key: item.itemKey, + title: item.title, + sort_order: item.sortOrder, + status: "pending", + })), + skipDuplicates: true, + }); + return true; +} + +async function buildProjectCommunicationSummary(prisma, projectId) { + const todayStart = startOfUtcDay(); + const todayEnd = endOfUtcDay(); + const [totalCount, latest, followUpDueTodayCount, overdueFollowUpCount] = await Promise.all([ + prisma.projectCommunicationLog.count({ where: { project_id: projectId } }), + prisma.projectCommunicationLog.findFirst({ where: { project_id: projectId }, orderBy: [{ happened_at: "desc" }, { updated_at: "desc" }] }), + prisma.projectCommunicationLog.count({ where: { project_id: projectId, next_follow_up_date: { gte: todayStart, lte: todayEnd } } }), + prisma.projectCommunicationLog.count({ where: { project_id: projectId, next_follow_up_date: { lt: todayStart } } }), + ]); + return { + totalCount, + latestHappenedAt: latest?.happened_at?.toISOString?.() ?? null, + followUpDueTodayCount, + overdueFollowUpCount, + }; +} + +async function buildProjectClosureSummary(prisma, projectId) { + const items = await prisma.projectClosureChecklistItem.findMany({ where: { project_id: projectId }, select: { status: true } }); + const pendingCount = items.filter((item) => item.status === "pending").length; + const waivedCount = items.filter((item) => item.status === "waived").length; + const [openRiskCount, openReimbursementCount, openTravelCount, deliveryRecordCount, contractCount, confidentialDocRefCount] = await Promise.all([ + prisma.projectRisk.count({ where: { project_id: projectId, status: { not: "closed" } } }), + prisma.reimbursementForm.count({ where: { project_id: projectId, status: { notIn: ["paid", "cancelled", "rejected"] } } }), + prisma.travelRequest.count({ where: { project_id: projectId, status: { notIn: ["rejected"] } } }), + prisma.projectDeliveryRecord.count({ where: { project_id: projectId } }), + prisma.contractRegistry.count({ where: { project_id: projectId } }), + prisma.confidentialDocumentRef.count({ where: { project_id: projectId } }), + ]); + return { + totalCount: items.length, + pendingCount, + waivedCount, + warned: pendingCount > 0, + facts: { + openRiskCount, + openReimbursementCount, + openTravelCount, + deliveryRecordCount, + contractCount, + confidentialDocRefCount, + }, + }; +} + +async function buildProjectDetailPayload(fastify, request, project) { + const [communicationSummary, closureSummary, communicationGate, closureGate] = await Promise.all([ + buildProjectCommunicationSummary(fastify.prisma, project.id), + buildProjectClosureSummary(fastify.prisma, project.id), + evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.projectCommunicationLog, departmentId: request.employee.department_id ?? null, strictDepartment: false }), + evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.projectClosureChecklist, departmentId: request.employee.department_id ?? null, strictDepartment: false }), + ]); + return { + ...mapProjectDetail(project), + communicationSummary, + closureSummary, + capabilities: { + communicationLogEnabled: communicationGate.enabled, + closureChecklistEnabled: closureGate.enabled, + }, + }; +} + function mapProjectDetail(project) { return { id: project.id, @@ -252,6 +428,8 @@ function mapProjectDetail(project) { travelRequests: project._count?.travel_requests ?? 0, todoItems: project._count?.todo_items ?? 0, weeklyReports: project._count?.weekly_reports ?? 0, + communicationLogs: project._count?.communication_logs ?? 0, + closureChecklistItems: project._count?.closure_checklist_items ?? 0, contracts: project._count?.contracts ?? 0, deliveries: project._count?.deliveries ?? 0, documentRefs: project._count?.document_refs ?? 0, @@ -405,25 +583,18 @@ async function projectsRoutes(fastify) { }, }, }, - include: { - owner: { select: { id: true, employee_no: true, name: true } }, - manager: { select: { id: true, employee_no: true, name: true } }, - members: { where: { left_at: null }, include: { employee: { select: { id: true, employee_no: true, name: true } } } }, - milestones: true, - tasks: { include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } } }, - risks: { include: { owner: { select: { id: true, employee_no: true, name: true } } } }, - _count: { select: { members: true, milestones: true, tasks: true, risks: true, reimbursements: true, travel_requests: true, todo_items: true, weekly_reports: true, contracts: true, deliveries: true, document_refs: true } }, - }, + include: buildProjectDetailInclude(), }); await writeAudit(fastify, request, "PROJECT_CREATED", created.id, { projectNo: created.project_no }); - return reply.code(201).send({ requestId: request.id, data: { item: mapProjectDetail(created) } }); + const payload = await buildProjectDetailPayload(fastify, request, created); + return reply.code(201).send({ requestId: request.id, data: { item: payload } }); }); fastify.get("/projects/:id", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); const projectId = parseId(request.params?.id); const project = await ensureReadableProject(fastify, request, projectId); - return sendData(reply, request, { item: mapProjectDetail(project) }); + return sendData(reply, request, { item: await buildProjectDetailPayload(fastify, request, project) }); }); fastify.patch("/projects/:id", { preHandler: fastify.authenticate }, async (request, reply) => { @@ -431,6 +602,11 @@ async function projectsRoutes(fastify) { const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); const body = parseBody(PROJECT_PATCH_SCHEMA, request.body); + const wantsClosureWarning = ["completed", "archived"].includes(body.status ?? ""); + if (wantsClosureWarning) { + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectClosureChecklist); + await ensureClosureChecklistBootstrapped(fastify.prisma, projectId); + } const updated = await fastify.prisma.project.update({ where: { id: projectId }, data: { @@ -447,27 +623,32 @@ async function projectsRoutes(fastify) { ...(body.summary !== undefined ? { summary: body.summary ?? null } : {}), ...(body.isConfidential !== undefined ? { is_confidential: body.isConfidential } : {}), }, - include: { - owner: { select: { id: true, employee_no: true, name: true } }, - manager: { select: { id: true, employee_no: true, name: true } }, - members: { where: { left_at: null }, include: { employee: { select: { id: true, employee_no: true, name: true } } } }, - milestones: true, - tasks: { include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } } }, - risks: { include: { owner: { select: { id: true, employee_no: true, name: true } } } }, - _count: { select: { members: true, milestones: true, tasks: true, risks: true, reimbursements: true, travel_requests: true, todo_items: true, weekly_reports: true, contracts: true, deliveries: true, document_refs: true } }, - }, + include: buildProjectDetailInclude(), }); await writeAudit(fastify, request, "PROJECT_UPDATED", projectId, { fields: Object.keys(body) }); - return sendData(reply, request, { item: mapProjectDetail(updated) }); + const item = await buildProjectDetailPayload(fastify, request, updated); + const closureWarning = wantsClosureWarning && item.closureSummary.pendingCount > 0 + ? { pendingCount: item.closureSummary.pendingCount, message: `项目仍有 ${item.closureSummary.pendingCount} 项待收口,请尽快补齐。` } + : null; + return sendData(reply, request, { item, closureWarning }); }); fastify.post("/projects/:id/archive", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); - const updated = await fastify.prisma.project.update({ where: { id: projectId }, data: { status: "archived", actual_end_at: new Date() } }); - await writeAudit(fastify, request, "PROJECT_ARCHIVED", projectId, {}); - return sendData(reply, request, { item: { id: updated.id, status: updated.status } }); + let closureWarning = null; + const closureGate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.projectClosureChecklist, departmentId: request.employee.department_id ?? null, strictDepartment: false }); + if (closureGate.enabled) { + await ensureClosureChecklistBootstrapped(fastify.prisma, projectId); + } + const updated = await fastify.prisma.project.update({ where: { id: projectId }, data: { status: "archived", actual_end_at: new Date() }, include: buildProjectDetailInclude() }); + const item = await buildProjectDetailPayload(fastify, request, updated); + if (closureGate.enabled && item.closureSummary.pendingCount > 0) { + closureWarning = { pendingCount: item.closureSummary.pendingCount, message: `项目仍有 ${item.closureSummary.pendingCount} 项待收口,请尽快补齐。` }; + } + await writeAudit(fastify, request, "PROJECT_ARCHIVED", projectId, { warned: Boolean(closureWarning) }); + return sendData(reply, request, { item, closureWarning }); }); fastify.get("/projects/:id/members", { preHandler: fastify.authenticate }, async (request, reply) => { @@ -692,6 +873,124 @@ async function projectsRoutes(fastify) { return sendData(reply, request, { items: items.map((item) => ({ id: item.id, weeklyReportId: item.weekly_report_id, weekStart: item.weekly_report.week_start?.toISOString?.().slice(0, 10) ?? null, status: item.weekly_report.status, employee: mapEmployeeBasic(item.weekly_report.employee), progressText: item.progress_text, riskText: item.risk_text, nextStepText: item.next_step_text, updatedAt: item.updated_at?.toISOString?.() ?? null })) }); }); + fastify.get("/projects/:id/communication-logs", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectCommunicationLog); + const projectId = parseId(request.params?.id); + const project = await ensureReadableProject(fastify, request, projectId); + const items = await fastify.prisma.projectCommunicationLog.findMany({ + where: { project_id: projectId }, + orderBy: [{ happened_at: "desc" }, { updated_at: "desc" }], + take: 50, + include: { creator: { select: { id: true, employee_no: true, name: true } } }, + }); + return sendData(reply, request, { items: items.map((item) => mapProjectCommunicationLog(item, canEditCommunicationLog(fastify, request.employee, project, item))) }); + }); + + fastify.post("/projects/:id/communication-logs", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectCommunicationLog); + const projectId = parseId(request.params?.id); + await ensureReadableProject(fastify, request, projectId); + const body = parseBody(COMMUNICATION_LOG_CREATE_SCHEMA, request.body); + const created = await fastify.prisma.projectCommunicationLog.create({ + data: { + project_id: projectId, + happened_at: new Date(body.happenedAt), + channel: body.channel, + external_contact_name: body.externalContactName ?? null, + external_org_name: body.externalOrgName ?? null, + summary: body.summary, + next_action: body.nextAction ?? null, + next_follow_up_date: parseDateOnly(body.nextFollowUpDate), + created_by: request.employee.id, + }, + include: { creator: { select: { id: true, employee_no: true, name: true } } }, + }); + await writeAudit(fastify, request, "PROJECT_COMMUNICATION_LOG_CREATED", projectId, { logId: created.id, channel: created.channel }); + return reply.code(201).send({ requestId: request.id, data: { item: mapProjectCommunicationLog(created, true) } }); + }); + + fastify.patch("/projects/:id/communication-logs/:logId", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectCommunicationLog); + const projectId = parseId(request.params?.id); + const logId = parseId(request.params?.logId, "logId"); + const project = await ensureReadableProject(fastify, request, projectId); + const current = await fastify.prisma.projectCommunicationLog.findUnique({ + where: { id: logId }, + include: { creator: { select: { id: true, employee_no: true, name: true } } }, + }); + if (!current || current.project_id !== projectId) { + throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + } + if (!canEditCommunicationLog(fastify, request.employee, project, current)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } }); + } + const body = parseBody(COMMUNICATION_LOG_PATCH_SCHEMA, request.body); + const updated = await fastify.prisma.projectCommunicationLog.update({ + where: { id: logId }, + data: { + ...(body.happenedAt !== undefined ? { happened_at: new Date(body.happenedAt) } : {}), + ...(body.channel !== undefined ? { channel: body.channel } : {}), + ...(body.externalContactName !== undefined ? { external_contact_name: body.externalContactName ?? null } : {}), + ...(body.externalOrgName !== undefined ? { external_org_name: body.externalOrgName ?? null } : {}), + ...(body.summary !== undefined ? { summary: body.summary } : {}), + ...(body.nextAction !== undefined ? { next_action: body.nextAction ?? null } : {}), + ...(body.nextFollowUpDate !== undefined ? { next_follow_up_date: parseDateOnly(body.nextFollowUpDate) } : {}), + }, + include: { creator: { select: { id: true, employee_no: true, name: true } } }, + }); + await writeAudit(fastify, request, "PROJECT_COMMUNICATION_LOG_UPDATED", projectId, { logId }); + return sendData(reply, request, { item: mapProjectCommunicationLog(updated, canEditCommunicationLog(fastify, request.employee, project, updated)) }); + }); + + fastify.get("/projects/:id/closure-checklist", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectClosureChecklist); + const projectId = parseId(request.params?.id); + const project = await ensureReadableProject(fastify, request, projectId); + await ensureClosureChecklistBootstrapped(fastify.prisma, projectId); + const canEdit = hasGlobalProjectManage(fastify, request.employee) || canManageProjectRecord(fastify, request.employee.id, project); + const items = await fastify.prisma.projectClosureChecklistItem.findMany({ + where: { project_id: projectId }, + orderBy: [{ sort_order: "asc" }, { created_at: "asc" }], + include: { completer: { select: { id: true, employee_no: true, name: true } } }, + }); + return sendData(reply, request, { items: items.map((item) => mapProjectClosureChecklistItem(item, canEdit)) }); + }); + + fastify.patch("/projects/:id/closure-checklist/:itemId", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectClosureChecklist); + const projectId = parseId(request.params?.id); + await ensureManageableProject(fastify, request, projectId); + await ensureClosureChecklistBootstrapped(fastify.prisma, projectId); + const itemId = parseId(request.params?.itemId, "itemId"); + const current = await fastify.prisma.projectClosureChecklistItem.findUnique({ + where: { id: itemId }, + include: { completer: { select: { id: true, employee_no: true, name: true } } }, + }); + if (!current || current.project_id !== projectId) { + throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + } + const body = parseBody(CLOSURE_CHECKLIST_PATCH_SCHEMA, request.body); + const nextStatus = body.status ?? current.status; + const nextNote = body.note !== undefined ? (body.note ?? null) : current.note; + if (nextStatus === "waived" && !String(nextNote ?? "").trim()) { + throw buildValidationError({ note: "waived_requires_note" }); + } + const completedMeta = nextStatus === "pending" + ? { completed_at: null, completed_by: null } + : { completed_at: new Date(), completed_by: request.employee.id }; + const updated = await fastify.prisma.projectClosureChecklistItem.update({ + where: { id: itemId }, + data: { + ...(body.status !== undefined ? { status: body.status } : {}), + ...(body.note !== undefined ? { note: body.note ?? null } : {}), + ...(body.status !== undefined ? completedMeta : {}), + }, + include: { completer: { select: { id: true, employee_no: true, name: true } } }, + }); + await writeAudit(fastify, request, "PROJECT_CLOSURE_CHECKLIST_UPDATED", projectId, { itemId, status: updated.status }); + return sendData(reply, request, { item: mapProjectClosureChecklistItem(updated, true) }); + }); + fastify.get("/projects/:id/deliveries", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking); const projectId = parseId(request.params?.id); diff --git a/server/src/routes/search.js b/server/src/routes/search.js index 7314b03a..c09cbacf 100644 --- a/server/src/routes/search.js +++ b/server/src/routes/search.js @@ -313,23 +313,38 @@ async function searchRoutes(fastify) { where: { OR: [ { contract_no: { contains: q, mode: "insensitive" } }, - { counterparty_name: { contains: q, mode: "insensitive" } }, - { summary: { contains: q, mode: "insensitive" } }, + { + AND: [ + { confidentiality_level: { in: ["public", "internal"] } }, + { + OR: [ + { counterparty_name: { contains: q, mode: "insensitive" } }, + { summary: { contains: q, mode: "insensitive" } }, + ], + }, + ], + }, + { project: { is: { project_no: { contains: q, mode: "insensitive" } } } }, ], }, take: limit, orderBy: { updated_at: "desc" }, - select: { id: true, contract_no: true, counterparty_name: true, confidentiality_level: true, summary: true, updated_at: true }, - }).then((rows) => rows.map((item) => ({ - entityType: "contract_registry", - entityId: item.id, - title: item.contract_no, - subtitle: ["confidential", "restricted"].includes(item.confidentiality_level) ? "涉密合同台账" : item.counterparty_name, - status: item.confidentiality_level, - href: "/contracts-registry", - matchedBy: matchByValue(q, item.contract_no, item.counterparty_name, item.summary), - updatedAt: item.updated_at?.toISOString?.() ?? null, - }))), + select: { id: true, contract_no: true, counterparty_name: true, confidentiality_level: true, summary: true, updated_at: true, project: { select: { project_no: true } } }, + }).then((rows) => rows.map((item) => { + const isSensitive = ["confidential", "restricted"].includes(item.confidentiality_level); + return { + entityType: "contract_registry", + entityId: item.id, + title: item.contract_no, + subtitle: isSensitive ? "涉密合同台账" : item.counterparty_name, + status: item.confidentiality_level, + href: "/contracts-registry", + matchedBy: isSensitive + ? matchByValue(q, item.contract_no, item.project?.project_no) + : matchByValue(q, item.contract_no, item.counterparty_name, item.summary, item.project?.project_no), + updatedAt: item.updated_at?.toISOString?.() ?? null, + }; + })), ); } @@ -338,24 +353,35 @@ async function searchRoutes(fastify) { fastify.prisma.confidentialDocumentRef.findMany({ where: { OR: [ - { title: { contains: q, mode: "insensitive" } }, { document_no: { contains: q, mode: "insensitive" } }, { offline_archive_no: { contains: q, mode: "insensitive" } }, + { project: { is: { project_no: { contains: q, mode: "insensitive" } } } }, + { + AND: [ + { confidentiality_level: { in: ["public", "internal"] } }, + { title: { contains: q, mode: "insensitive" } }, + ], + }, ], }, take: limit, orderBy: { updated_at: "desc" }, - select: { id: true, title: true, document_no: true, confidentiality_level: true, updated_at: true }, - }).then((rows) => rows.map((item) => ({ - entityType: "confidential_doc_ref", - entityId: item.id, - title: item.document_no || item.title, - subtitle: ["confidential", "restricted"].includes(item.confidentiality_level) ? "涉密资料引用" : item.title, - status: item.confidentiality_level, - href: "/contracts-registry", - matchedBy: matchByValue(q, item.title, item.document_no), - updatedAt: item.updated_at?.toISOString?.() ?? null, - }))), + select: { id: true, title: true, document_no: true, offline_archive_no: true, confidentiality_level: true, updated_at: true, project: { select: { project_no: true } } }, + }).then((rows) => rows.map((item) => { + const isSensitive = ["confidential", "restricted"].includes(item.confidentiality_level); + return { + entityType: "confidential_doc_ref", + entityId: item.id, + title: item.document_no || item.title, + subtitle: isSensitive ? "涉密资料引用" : item.title, + status: item.confidentiality_level, + href: "/contracts-registry", + matchedBy: isSensitive + ? matchByValue(q, item.document_no, item.offline_archive_no, item.project?.project_no) + : matchByValue(q, item.title, item.document_no, item.offline_archive_no, item.project?.project_no), + updatedAt: item.updated_at?.toISOString?.() ?? null, + }; + })), ); } diff --git a/server/src/scripts/seed-config.js b/server/src/scripts/seed-config.js index 4cb99237..0a16e003 100644 --- a/server/src/scripts/seed-config.js +++ b/server/src/scripts/seed-config.js @@ -331,6 +331,20 @@ const DEFAULT_FEATURE_FLAGS = [ department_ids: [], note: "bootstrap default: force_off", }, + { + flag_key: "project_communication_log_v1", + override_mode: "force_off", + rollout_type: "global", + department_ids: [], + note: "bootstrap default: force_off", + }, + { + flag_key: "project_closure_checklist_v1", + override_mode: "force_off", + rollout_type: "global", + department_ids: [], + note: "bootstrap default: force_off", + }, { flag_key: "confidential_doc_refs_v1", override_mode: "force_off", diff --git a/server/src/scripts/seed-trialrun.js b/server/src/scripts/seed-trialrun.js index 42d649ca..7eb517db 100644 --- a/server/src/scripts/seed-trialrun.js +++ b/server/src/scripts/seed-trialrun.js @@ -263,6 +263,11 @@ async function main() { "weekly_report_v1", "employee_lifecycle_v1", "todo_board_v1", + "project_domain_v1", + "project_linking_v1", + "project_communication_log_v1", + "project_closure_checklist_v1", + "confidential_doc_refs_v1", ]) { await ensureFeatureFlag(prisma, { flag_key: flagKey, diff --git a/server/src/services/featureFlags/index.js b/server/src/services/featureFlags/index.js index a965fcb5..681e2e8b 100644 --- a/server/src/services/featureFlags/index.js +++ b/server/src/services/featureFlags/index.js @@ -17,6 +17,8 @@ const FEATURE_FLAG_KEYS = { wecomEntry: "wecom_entry_v1", projectDomain: "project_domain_v1", projectLinking: "project_linking_v1", + projectCommunicationLog: "project_communication_log_v1", + projectClosureChecklist: "project_closure_checklist_v1", confidentialDocRefs: "confidential_doc_refs_v1", publicSecurityHardening: "public_security_hardening_v1", }; @@ -144,6 +146,14 @@ function resolveEnvGate(config, flagKey) { return Boolean(config.projectLinkingEnabled); } + if (flagKey === FEATURE_FLAG_KEYS.projectCommunicationLog) { + return Boolean(config.projectCommunicationLogEnabled); + } + + if (flagKey === FEATURE_FLAG_KEYS.projectClosureChecklist) { + return Boolean(config.projectClosureChecklistEnabled); + } + if (flagKey === FEATURE_FLAG_KEYS.confidentialDocRefs) { return Boolean(config.confidentialDocRefsEnabled); } diff --git a/server/src/services/notifications/imEnqueue.js b/server/src/services/notifications/imEnqueue.js index f9090927..3e033660 100644 --- a/server/src/services/notifications/imEnqueue.js +++ b/server/src/services/notifications/imEnqueue.js @@ -85,8 +85,9 @@ function redactImMessage(config, payload) { } if (effectiveType === 'project') { + const genericTitle = rawTitle.includes('收口') ? '有项目待收口' : '有项目待跟进'; return { - title: rawTitle || '项目待处理事项', + title: genericTitle, body: '', }; } diff --git a/server/tests/unit/routes/projects.test.js b/server/tests/unit/routes/projects.test.js new file mode 100644 index 00000000..87081af9 --- /dev/null +++ b/server/tests/unit/routes/projects.test.js @@ -0,0 +1,483 @@ +const Fastify = require("fastify"); + +const { AppError } = require("../../../src/utils/AppError"); +const projectsRoutes = require("../../../src/routes/projects"); + +const PROJECT_ID = "11111111-1111-4111-8111-111111111111"; +const OWNER_ID = "22222222-2222-4222-8222-222222222222"; +const MANAGER_ID = "33333333-3333-4333-8333-333333333333"; +const MEMBER_ID = "44444444-4444-4444-8444-444444444444"; +const OUTSIDER_ID = "55555555-5555-4555-8555-555555555555"; +const NOW = new Date("2026-03-22T09:30:00.000Z"); + +function buildFeatureFlagRecord(flagKey) { + return { + id: `flag-${flagKey}`, + flag_key: flagKey, + override_mode: "force_on", + rollout_type: "global", + department_ids: [], + note: "unit-test", + version: 1, + created_at: NOW, + created_by: null, + updated_at: NOW, + updated_by: null, + }; +} + +function buildEmployee(id, name, employeeNo) { + return { id, name, employee_no: employeeNo }; +} + +function buildProjectState(overrides = {}) { + return { + id: PROJECT_ID, + project_no: "PRJ-001", + name: "试点项目", + client_name: "测试客户", + client_alias: "测试客户简称", + owner_employee_id: OWNER_ID, + manager_employee_id: MANAGER_ID, + status: "active", + starts_at: new Date("2026-03-01T00:00:00.000Z"), + target_end_at: new Date("2026-03-31T00:00:00.000Z"), + actual_end_at: null, + summary: "用于 R23 单测的项目", + is_confidential: false, + created_at: NOW, + updated_at: NOW, + owner: buildEmployee(OWNER_ID, "Owner", "E001"), + manager: buildEmployee(MANAGER_ID, "Manager", "E002"), + members: [ + { + id: "aaaaaaa1-1111-4111-8111-111111111111", + employee_id: MEMBER_ID, + role: "member", + joined_at: NOW, + left_at: null, + employee: buildEmployee(MEMBER_ID, "Member", "E003"), + }, + ], + milestones: [], + tasks: [], + risks: [], + ...overrides, + }; +} + +function createPrismaState({ communicationLogs = [], closureChecklistItems = [] } = {}) { + const state = { + project: buildProjectState(), + communicationLogs: communicationLogs.map((item) => ({ ...item })), + closureChecklistItems: closureChecklistItems.map((item) => ({ ...item })), + openRiskCount: 0, + openReimbursementCount: 0, + openTravelCount: 0, + deliveryRecordCount: 0, + contractCount: 0, + confidentialDocRefCount: 0, + auditEntries: [], + }; + + function withProjectCounts(project) { + return { + ...project, + _count: { + members: (project.members ?? []).length, + milestones: (project.milestones ?? []).length, + tasks: (project.tasks ?? []).length, + risks: (project.risks ?? []).length, + reimbursements: 0, + travel_requests: 0, + todo_items: 0, + weekly_reports: 0, + communication_logs: state.communicationLogs.length, + closure_checklist_items: state.closureChecklistItems.length, + contracts: state.contractCount, + deliveries: state.deliveryRecordCount, + document_refs: state.confidentialDocRefCount, + }, + }; + } + + function nextUuid(seed) { + return `99999999-9999-4999-8999-${String(seed).padStart(12, "0")}`; + } + + function attachChecklistCompleter(item) { + return { + ...item, + completer: item.completed_by ? buildEmployee(item.completed_by, item.completed_by === OWNER_ID ? "Owner" : "Manager", "E999") : null, + }; + } + + function attachCommunicationCreator(item) { + const creators = { + [OWNER_ID]: buildEmployee(OWNER_ID, "Owner", "E001"), + [MANAGER_ID]: buildEmployee(MANAGER_ID, "Manager", "E002"), + [MEMBER_ID]: buildEmployee(MEMBER_ID, "Member", "E003"), + }; + return { + ...item, + creator: creators[item.created_by] ?? buildEmployee(item.created_by, "Unknown", "E000"), + }; + } + + const prisma = { + featureFlag: { + findUnique: vi.fn().mockImplementation(async ({ where }) => buildFeatureFlagRecord(where.flag_key)), + }, + project: { + findUnique: vi.fn().mockImplementation(async () => withProjectCounts(state.project)), + update: vi.fn().mockImplementation(async ({ data }) => { + state.project = { + ...state.project, + ...(data.name !== undefined ? { name: data.name } : {}), + ...(data.client_name !== undefined ? { client_name: data.client_name } : {}), + ...(data.client_alias !== undefined ? { client_alias: data.client_alias } : {}), + ...(data.owner_employee_id !== undefined ? { owner_employee_id: data.owner_employee_id } : {}), + ...(data.manager_employee_id !== undefined ? { manager_employee_id: data.manager_employee_id } : {}), + ...(data.status !== undefined ? { status: data.status } : {}), + ...(data.starts_at !== undefined ? { starts_at: data.starts_at } : {}), + ...(data.target_end_at !== undefined ? { target_end_at: data.target_end_at } : {}), + ...(data.actual_end_at !== undefined ? { actual_end_at: data.actual_end_at } : {}), + ...(data.summary !== undefined ? { summary: data.summary } : {}), + ...(data.is_confidential !== undefined ? { is_confidential: data.is_confidential } : {}), + updated_at: NOW, + }; + return withProjectCounts(state.project); + }), + }, + projectCommunicationLog: { + count: vi.fn().mockImplementation(async ({ where }) => { + const items = state.communicationLogs.filter((item) => item.project_id === where.project_id); + if (!where.next_follow_up_date) { + return items.length; + } + return items.filter((item) => { + if (!item.next_follow_up_date) { + return false; + } + const next = new Date(item.next_follow_up_date).getTime(); + if (where.next_follow_up_date.gte && where.next_follow_up_date.lte) { + return next >= new Date(where.next_follow_up_date.gte).getTime() && next <= new Date(where.next_follow_up_date.lte).getTime(); + } + if (where.next_follow_up_date.lt) { + return next < new Date(where.next_follow_up_date.lt).getTime(); + } + return false; + }).length; + }), + findFirst: vi.fn().mockImplementation(async ({ where }) => { + const items = state.communicationLogs + .filter((item) => item.project_id === where.project_id) + .sort((left, right) => new Date(right.happened_at).getTime() - new Date(left.happened_at).getTime()); + return items[0] ? attachCommunicationCreator(items[0]) : null; + }), + findMany: vi.fn().mockImplementation(async ({ where }) => { + return state.communicationLogs + .filter((item) => item.project_id === where.project_id) + .sort((left, right) => new Date(right.happened_at).getTime() - new Date(left.happened_at).getTime()) + .map((item) => attachCommunicationCreator(item)); + }), + create: vi.fn().mockImplementation(async ({ data }) => { + const created = { + id: nextUuid(state.communicationLogs.length + 1), + project_id: data.project_id, + happened_at: data.happened_at, + channel: data.channel, + external_contact_name: data.external_contact_name, + external_org_name: data.external_org_name, + summary: data.summary, + next_action: data.next_action, + next_follow_up_date: data.next_follow_up_date, + created_by: data.created_by, + created_at: NOW, + updated_at: NOW, + }; + state.communicationLogs.unshift(created); + return attachCommunicationCreator(created); + }), + findUnique: vi.fn().mockImplementation(async ({ where }) => { + const item = state.communicationLogs.find((entry) => entry.id === where.id); + return item ? attachCommunicationCreator(item) : null; + }), + update: vi.fn().mockImplementation(async ({ where, data }) => { + const index = state.communicationLogs.findIndex((entry) => entry.id === where.id); + if (index < 0) { + return null; + } + state.communicationLogs[index] = { + ...state.communicationLogs[index], + ...(data.happened_at !== undefined ? { happened_at: data.happened_at } : {}), + ...(data.channel !== undefined ? { channel: data.channel } : {}), + ...(data.external_contact_name !== undefined ? { external_contact_name: data.external_contact_name } : {}), + ...(data.external_org_name !== undefined ? { external_org_name: data.external_org_name } : {}), + ...(data.summary !== undefined ? { summary: data.summary } : {}), + ...(data.next_action !== undefined ? { next_action: data.next_action } : {}), + ...(data.next_follow_up_date !== undefined ? { next_follow_up_date: data.next_follow_up_date } : {}), + updated_at: NOW, + }; + return attachCommunicationCreator(state.communicationLogs[index]); + }), + }, + projectClosureChecklistItem: { + count: vi.fn().mockImplementation(async ({ where }) => state.closureChecklistItems.filter((item) => item.project_id === where.project_id).length), + createMany: vi.fn().mockImplementation(async ({ data }) => { + data.forEach((item, index) => { + if (state.closureChecklistItems.some((current) => current.project_id === item.project_id && current.item_key === item.item_key)) { + return; + } + state.closureChecklistItems.push({ + id: nextUuid(100 + state.closureChecklistItems.length + index), + project_id: item.project_id, + item_key: item.item_key, + title: item.title, + status: item.status, + note: null, + sort_order: item.sort_order, + completed_at: null, + completed_by: null, + created_at: NOW, + updated_at: NOW, + }); + }); + return { count: data.length }; + }), + findMany: vi.fn().mockImplementation(async ({ where, select }) => { + const items = state.closureChecklistItems + .filter((item) => item.project_id === where.project_id) + .sort((left, right) => left.sort_order - right.sort_order); + if (select?.status) { + return items.map((item) => ({ status: item.status })); + } + return items.map((item) => attachChecklistCompleter(item)); + }), + findUnique: vi.fn().mockImplementation(async ({ where }) => { + const item = state.closureChecklistItems.find((entry) => entry.id === where.id); + return item ? attachChecklistCompleter(item) : null; + }), + update: vi.fn().mockImplementation(async ({ where, data }) => { + const index = state.closureChecklistItems.findIndex((entry) => entry.id === where.id); + if (index < 0) { + return null; + } + state.closureChecklistItems[index] = { + ...state.closureChecklistItems[index], + ...(data.status !== undefined ? { status: data.status } : {}), + ...(data.note !== undefined ? { note: data.note } : {}), + ...(data.completed_at !== undefined ? { completed_at: data.completed_at } : {}), + ...(data.completed_by !== undefined ? { completed_by: data.completed_by } : {}), + updated_at: NOW, + }; + return attachChecklistCompleter(state.closureChecklistItems[index]); + }), + }, + projectRisk: { count: vi.fn().mockImplementation(async () => state.openRiskCount) }, + reimbursementForm: { count: vi.fn().mockImplementation(async () => state.openReimbursementCount) }, + travelRequest: { count: vi.fn().mockImplementation(async () => state.openTravelCount) }, + projectDeliveryRecord: { count: vi.fn().mockImplementation(async () => state.deliveryRecordCount) }, + contractRegistry: { count: vi.fn().mockImplementation(async () => state.contractCount) }, + confidentialDocumentRef: { count: vi.fn().mockImplementation(async () => state.confidentialDocRefCount) }, + auditLog: { + create: vi.fn().mockImplementation(async ({ data }) => { + state.auditEntries.push(data); + return { id: nextUuid(500 + state.auditEntries.length) }; + }), + }, + }; + + return { state, prisma }; +} + +async function buildApp({ employee, prisma }) { + const app = Fastify(); + + app.decorate("prisma", prisma); + app.decorate("config", { + projectDomainEnabled: true, + projectLinkingEnabled: true, + projectCommunicationLogEnabled: true, + projectClosureChecklistEnabled: true, + confidentialDocRefsEnabled: true, + }); + app.decorate("authenticate", async (request) => { + if (!request.headers.authorization) { + throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); + } + request.employee = employee; + }); + app.decorate("hasPermission", (actor, permissionCode) => { + return (actor?.permissionCodes ?? []).includes("*") || (actor?.permissionCodes ?? []).includes(permissionCode); + }); + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + requestId: request.id, + error: { code: error.code, message: error.message, details: error.details ?? {} }, + }); + } + return reply.status(500).send({ + requestId: request.id, + error: { code: "SYS_001", message: "服务器内部错误", details: {} }, + }); + }); + + await app.register(projectsRoutes, { prefix: "/api" }); + return app; +} + +describe("project R23 routes", () => { + it("allows project member to create communication logs and blocks outsiders", async () => { + const { prisma } = createPrismaState(); + const memberApp = await buildApp({ + employee: { id: MEMBER_ID, department_id: null, permissionCodes: ["project.view_own"] }, + prisma, + }); + + const createResponse = await memberApp.inject({ + method: "POST", + url: `/api/projects/${PROJECT_ID}/communication-logs`, + headers: { authorization: "Bearer test-token" }, + payload: { + happenedAt: "2026-03-22T08:00:00.000Z", + channel: "wechat", + externalContactName: "王总", + externalOrgName: "测试客户", + summary: "客户确认下周一现场启动。", + nextAction: "准备启动材料", + nextFollowUpDate: "2026-03-24", + }, + }); + + expect(createResponse.statusCode).toBe(201); + expect(createResponse.json().data.item).toEqual( + expect.objectContaining({ + channel: "wechat", + summary: "客户确认下周一现场启动。", + canEdit: true, + }), + ); + + const outsiderApp = await buildApp({ + employee: { id: OUTSIDER_ID, department_id: null, permissionCodes: ["project.view_own"] }, + prisma, + }); + const outsiderResponse = await outsiderApp.inject({ + method: "GET", + url: `/api/projects/${PROJECT_ID}/communication-logs`, + headers: { authorization: "Bearer test-token" }, + }); + + expect(outsiderResponse.statusCode).toBe(403); + expect(outsiderResponse.json().error.code).toBe("PERM_001"); + + await Promise.all([memberApp.close(), outsiderApp.close()]); + }); + + it("enforces 24h edit window for creators while allowing owners to edit anytime", async () => { + const { state, prisma } = createPrismaState({ + communicationLogs: [ + { + id: "66666666-6666-4666-8666-666666666666", + project_id: PROJECT_ID, + happened_at: new Date("2026-03-21T08:00:00.000Z"), + channel: "phone", + external_contact_name: "李总", + external_org_name: "测试客户", + summary: "客户电话确认测试安排。", + next_action: "同步排期", + next_follow_up_date: new Date("2026-03-23T00:00:00.000Z"), + created_by: MEMBER_ID, + created_at: new Date("2026-03-20T08:00:00.000Z"), + updated_at: new Date("2026-03-20T08:00:00.000Z"), + }, + ], + }); + + const staleCreatorApp = await buildApp({ + employee: { id: MEMBER_ID, department_id: null, permissionCodes: ["project.view_own"] }, + prisma, + }); + const staleResponse = await staleCreatorApp.inject({ + method: "PATCH", + url: `/api/projects/${PROJECT_ID}/communication-logs/66666666-6666-4666-8666-666666666666`, + headers: { authorization: "Bearer test-token" }, + payload: { summary: "尝试修改过期记录" }, + }); + + expect(staleResponse.statusCode).toBe(403); + + const ownerApp = await buildApp({ + employee: { id: OWNER_ID, department_id: null, permissionCodes: ["project.view_own"] }, + prisma, + }); + const ownerResponse = await ownerApp.inject({ + method: "PATCH", + url: `/api/projects/${PROJECT_ID}/communication-logs/66666666-6666-4666-8666-666666666666`, + headers: { authorization: "Bearer test-token" }, + payload: { summary: "Owner 已补充最新沟通结果" }, + }); + + expect(ownerResponse.statusCode).toBe(200); + expect(ownerResponse.json().data.item.summary).toBe("Owner 已补充最新沟通结果"); + expect(state.communicationLogs[0].summary).toBe("Owner 已补充最新沟通结果"); + + await Promise.all([staleCreatorApp.close(), ownerApp.close()]); + }); + + it("bootstraps closure checklist and requires note when waiving", async () => { + const { prisma } = createPrismaState(); + const app = await buildApp({ + employee: { id: OWNER_ID, department_id: null, permissionCodes: ["project.view_own"] }, + prisma, + }); + + const listResponse = await app.inject({ + method: "GET", + url: `/api/projects/${PROJECT_ID}/closure-checklist`, + headers: { authorization: "Bearer test-token" }, + }); + + expect(listResponse.statusCode).toBe(200); + expect(listResponse.json().data.items).toHaveLength(6); + expect(prisma.projectClosureChecklistItem.createMany).toHaveBeenCalledTimes(1); + + const firstItemId = listResponse.json().data.items[0].id; + const waiveResponse = await app.inject({ + method: "PATCH", + url: `/api/projects/${PROJECT_ID}/closure-checklist/${firstItemId}`, + headers: { authorization: "Bearer test-token" }, + payload: { status: "waived", note: "" }, + }); + + expect(waiveResponse.statusCode).toBe(400); + expect(waiveResponse.json().error.details).toEqual(expect.objectContaining({ note: "waived_requires_note" })); + + await app.close(); + }); + + it("returns closureWarning when project is completed with pending checklist items", async () => { + const { prisma } = createPrismaState(); + const app = await buildApp({ + employee: { id: OWNER_ID, department_id: null, permissionCodes: ["project.view_own"] }, + prisma, + }); + + const response = await app.inject({ + method: "PATCH", + url: `/api/projects/${PROJECT_ID}`, + headers: { authorization: "Bearer test-token" }, + payload: { status: "completed" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.item.status).toBe("completed"); + expect(response.json().data.closureWarning).toEqual( + expect.objectContaining({ pendingCount: 6 }), + ); + + await app.close(); + }); +}); diff --git a/server/tests/unit/routes/search.test.js b/server/tests/unit/routes/search.test.js new file mode 100644 index 00000000..71015044 --- /dev/null +++ b/server/tests/unit/routes/search.test.js @@ -0,0 +1,153 @@ +const Fastify = require("fastify"); + +const { AppError } = require("../../../src/utils/AppError"); +const searchRoutes = require("../../../src/routes/search"); + +const NOW = new Date("2026-03-22T10:00:00.000Z"); + +function buildFeatureFlagRecord(flagKey) { + return { + id: `flag-${flagKey}`, + flag_key: flagKey, + override_mode: "force_on", + rollout_type: "global", + department_ids: [], + note: "unit-test", + version: 1, + created_at: NOW, + created_by: null, + updated_at: NOW, + updated_by: null, + }; +} + +function createPrisma() { + const contractRow = { + id: "11111111-1111-4111-8111-111111111111", + contract_no: "HT-001", + counterparty_name: "涉密客户", + confidentiality_level: "restricted", + summary: "极密摘要内容", + updated_at: NOW, + project: { project_no: "PRJ-001" }, + }; + const docRow = { + id: "22222222-2222-4222-8222-222222222222", + title: "极密技术方案", + document_no: "DOC-001", + offline_archive_no: "ARC-001", + confidentiality_level: "confidential", + updated_at: NOW, + project: { project_no: "PRJ-001" }, + }; + + return { + featureFlag: { + findUnique: vi.fn().mockImplementation(async ({ where }) => buildFeatureFlagRecord(where.flag_key)), + }, + employee: { findMany: vi.fn().mockResolvedValue([]) }, + portalAnnouncement: { findMany: vi.fn().mockResolvedValue([]) }, + assetCard: { findMany: vi.fn().mockResolvedValue([]) }, + reimbursementForm: { findMany: vi.fn().mockResolvedValue([]) }, + leaveRequest: { findMany: vi.fn().mockResolvedValue([]) }, + weeklyReport: { findMany: vi.fn().mockResolvedValue([]) }, + todoItem: { findMany: vi.fn().mockResolvedValue([]) }, + purchaseRequest: { findMany: vi.fn().mockResolvedValue([]) }, + travelRequest: { findMany: vi.fn().mockResolvedValue([]) }, + project: { findMany: vi.fn().mockResolvedValue([]) }, + contractRegistry: { + findMany: vi.fn().mockImplementation(async ({ where }) => { + const query = where?.OR?.[0]?.contract_no?.contains ?? ""; + return ["HT-001", "PRJ-001"].includes(query) ? [contractRow] : []; + }), + }, + confidentialDocumentRef: { + findMany: vi.fn().mockImplementation(async ({ where }) => { + const query = where?.OR?.[0]?.document_no?.contains ?? ""; + return ["DOC-001", "PRJ-001", "ARC-001"].includes(query) ? [docRow] : []; + }), + }, + }; +} + +async function buildApp({ employee, prisma }) { + const app = Fastify(); + app.decorate("prisma", prisma); + app.decorate("config", { + globalSearchEnabled: true, + announcementsEnabled: false, + weeklyReportEnabled: false, + todoBoardEnabled: false, + projectDomainEnabled: false, + projectLinkingEnabled: true, + confidentialDocRefsEnabled: true, + }); + app.decorate("authenticate", async (request) => { + if (!request.headers.authorization) { + throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); + } + request.employee = employee; + }); + app.decorate("hasPermission", (actor, permissionCode) => { + return (actor?.permissionCodes ?? []).includes("*") || (actor?.permissionCodes ?? []).includes(permissionCode); + }); + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + requestId: request.id, + error: { code: error.code, message: error.message, details: error.details ?? {} }, + }); + } + return reply.status(500).send({ + requestId: request.id, + error: { code: "SYS_001", message: "服务器内部错误", details: {} }, + }); + }); + + await app.register(searchRoutes, { prefix: "/api" }); + return app; +} + +describe("global search R23 confidential boundary", () => { + it("does not match sensitive summary text but still matches minimal identifiers", async () => { + const prisma = createPrisma(); + const app = await buildApp({ + employee: { id: "employee-1", department_id: null, permissionCodes: ["system.settings"] }, + prisma, + }); + + const sensitiveResponse = await app.inject({ + method: "GET", + url: "/api/search?q=%E6%9E%81%E5%AF%86%E6%91%98%E8%A6%81", + headers: { authorization: "Bearer test-token" }, + }); + expect(sensitiveResponse.statusCode).toBe(200); + expect(sensitiveResponse.json().data.items).toEqual([]); + + const contractResponse = await app.inject({ + method: "GET", + url: "/api/search?q=HT-001", + headers: { authorization: "Bearer test-token" }, + }); + expect(contractResponse.statusCode).toBe(200); + expect(contractResponse.json().data.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ entityType: "contract_registry", title: "HT-001", subtitle: "涉密合同台账" }), + ]), + ); + + const docResponse = await app.inject({ + method: "GET", + url: "/api/search?q=DOC-001", + headers: { authorization: "Bearer test-token" }, + }); + expect(docResponse.statusCode).toBe(200); + expect(docResponse.json().data.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ entityType: "confidential_doc_ref", title: "DOC-001", subtitle: "涉密资料引用" }), + ]), + ); + + await app.close(); + }); +}); diff --git a/server/tests/unit/services/imEnqueue.test.js b/server/tests/unit/services/imEnqueue.test.js index 2e2f180a..8258d877 100644 --- a/server/tests/unit/services/imEnqueue.test.js +++ b/server/tests/unit/services/imEnqueue.test.js @@ -117,6 +117,61 @@ describe("IM enqueue", () => { ); }); + it("redacts project follow-up notifications under public security hardening", async () => { + const tx = { + employeeRole: { + findMany: vi.fn().mockResolvedValue([{ role_code: "manager" }]), + }, + notificationChannel: { + findMany: vi.fn().mockResolvedValue([ + { + id: "channel-project", + channel_type: "wecom", + name: "project-workflow", + enabled: true, + config_json: { + routing: { + roleCodes: ["manager"], + domainScopes: ["workflow"], + allowedTypes: ["workflow.task_assigned"], + }, + }, + template_json: null, + }, + ]), + }, + integrationOutbox: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockImplementation(async ({ data }) => ({ id: `${data.dedupe_key}-id`, status: "pending" })), + }, + }; + + const result = await enqueueImNotifications(tx, { + config: { + imNotifierEnabled: true, + integrationOutboxMaxAttempts: 5, + appOrigin: "https://app.example.com", + jwtSecret: "test-secret", + publicSecurityHardeningEnabled: true, + }, + logger: { warn: vi.fn() }, + recipientEmployeeIds: ["employee-1"], + type: "workflow.task_assigned", + title: "项目收口提醒:合同与沟通记录待补齐", + body: "请补齐沟通摘要、收口备注与合同要点。", + targetType: "workflow_task", + targetId: "task-project-1", + targetPath: "/projects/11111111-1111-4111-8111-111111111111", + entityType: "project", + entityId: "11111111-1111-4111-8111-111111111111", + actorId: "actor-1", + }); + + expect(result.queued).toBe(1); + expect(tx.integrationOutbox.create.mock.calls[0][0].data.payload.title).toBe("有项目待收口"); + expect(tx.integrationOutbox.create.mock.calls[0][0].data.payload.body).toBe(""); + }); + it("safely skips when no channel matches", async () => { const tx = { employeeRole: {