From f34b6b44875bc487b4c7e6c62c99a5445c36f519 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:24:58 +0800 Subject: [PATCH] feat(showcase): stamp real submitters on the seeded approval requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every seeded request had a null submitter, because the demo launches flows as SYS and the approval node stamps the requester from the flow context (`submitterId: context?.userId ?? null`, approval-node.ts). Consequences, all invisible until you go looking: * 申请人 rendered as `—` on every row and in the drawer; * the "我发起的" inbox tab was empty for every user, so that whole surface was dead; and * the submitter-only affordances never rendered anywhere — `Send reminder` and `Recall` are gated on `submitter_id == ctx.user.id`, so with no submitter they were unreachable and only 5 of the 7 declared approval actions could ever appear. Pass `userId` on the engine context and route it deliberately: * Invoice Dual Sign-off → submitted by the **admin**, so the logged-in dev admin owns one request and "我发起的" is non-empty with the submitter-only actions visible; * Committee Quorum and Expense Sign-off → submitted by **Mei Phone (demo)**, who holds no approval position and is therefore a clean requester who is never one of her own approvers. Verified in the running console: 申请人 now shows real names, "我发起的" holds the admin's request, and the same viewer sees `Approve / Reject / Reassign / Send back / Request info` on Mei's requests but those **plus** `Send reminder` and `Recall` on his own — i.e. all 7 declared actions render, gated by role. `tsc --noEmit` passes; boot log stays at 0 ERROR. From the #3358 §1 evidence pass; follow-up to #3409. Co-Authored-By: Claude Opus 4.8 --- .../src/security/seed-approval-demo.ts | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/examples/app-showcase/src/security/seed-approval-demo.ts b/examples/app-showcase/src/security/seed-approval-demo.ts index cd8afe446..eaa4a66e9 100644 --- a/examples/app-showcase/src/security/seed-approval-demo.ts +++ b/examples/app-showcase/src/security/seed-approval-demo.ts @@ -192,6 +192,15 @@ async function launchSignoff( * start condition silently evaluates false and no request opens. */ previousStatus: string, + /** + * Who is asking. The approval node stamps the request from `context.userId` + * (`submitterId: context?.userId ?? null` in approval-node.ts). Without it + * every seeded request has a null submitter, which renders as 申请人 `—`, + * leaves the "我发起的" inbox tab empty for every user, and suppresses the + * submitter-only affordances (recall / remind) — so the submitter half of + * the approval UI is unreachable. + */ + submitterId: string | null, ): Promise { const recordId = String(record.id ?? ''); if (!recordId) return; @@ -213,6 +222,7 @@ async function launchSignoff( previous: { ...record, status: previousStatus }, object: objectName, organizationId, + ...(submitterId ? { userId: submitterId } : {}), })) as { success?: boolean; error?: string; output?: { skipped?: boolean; reason?: string } }; if (result?.success === false) { ctx.logger?.warn?.('[showcase] approval-demo flow returned an error', { flow: flowName, error: result.error }); @@ -250,7 +260,9 @@ export function registerShowcaseApprovalDemo(ctx: ApprovalDemoContext): void { const organizationId = (anyMember?.organization_id as string | undefined) ?? null; await assignPositions(ctx, adminId, ADMIN_APPROVAL_POSITIONS, organizationId, 'admin'); - await ensureDemoUser(ctx, PHONE_DEMO_USER); + // Mei holds no approval position, which makes her a clean *submitter* — a + // requester who is never also one of her own approvers. + const submitterId = (await ensureDemoUser(ctx, PHONE_DEMO_USER)) ?? null; // The auditor persona backs the `finance` group of the per-group demo. It // deliberately holds ONLY `auditor`, so the two groups have distinct // holders and the request stays open until each group has answered. @@ -272,9 +284,21 @@ export function registerShowcaseApprovalDemo(ctx: ApprovalDemoContext): void { // is `status == "sent" && previous.status != "sent"`, so it entered from // `draft`. Both named approvers must answer (not one-per-group — that is // the expense demo below). + // Submitted by the ADMIN on purpose: it is the one request the logged-in dev + // admin owns, so the "我发起的" tab is non-empty and the submitter-only + // affordances (recall / remind) have somewhere to appear. const sentInvoice = await findOne(ctx, 'showcase_invoice', { status: 'sent' }); if (sentInvoice) { - await launchSignoff(ctx, engine, 'showcase_invoice_signoff', 'showcase_invoice', sentInvoice, organizationId, 'draft'); + await launchSignoff( + ctx, + engine, + 'showcase_invoice_signoff', + 'showcase_invoice', + sentInvoice, + organizationId, + 'draft', + adminId, + ); } // Quorum (2-of-3): High-Value Committee needs a `submitted` report ≥ $5000; @@ -282,7 +306,16 @@ export function registerShowcaseApprovalDemo(ctx: ApprovalDemoContext): void { // && total_amount >= 5000`, so it entered from `draft`. const demoExpense = await findOne(ctx, 'showcase_expense_report', { name: 'EXP-DEMO' }); if (demoExpense) { - await launchSignoff(ctx, engine, 'showcase_committee_quorum', 'showcase_expense_report', demoExpense, organizationId, 'draft'); + await launchSignoff( + ctx, + engine, + 'showcase_committee_quorum', + 'showcase_expense_report', + demoExpense, + organizationId, + 'draft', + submitterId, + ); } // 会签 (per_group): Expense Sign-off needs one approval from EACH of the @@ -299,6 +332,7 @@ export function registerShowcaseApprovalDemo(ctx: ApprovalDemoContext): void { perGroupExpense, organizationId, 'draft', + submitterId, ); } };