Skip to content

Commit 25c5056

Browse files
os-zhuangclaude
andcommitted
feat(showcase): actually exercise the per-group (会签) approval demo
`showcase_expense_signoff` is the only flow authoring `behavior: 'per_group'` (manager group ← position `manager`, finance group ← position `auditor`), but nothing ever opened a request against it, so #3358 §1 "Per-group sign-off (会签)" had nothing to look at: * the approval demo seed launched only the `unanimous` invoice flow and the `quorum` committee flow; and * nobody held `auditor`, so even if it had fired, the finance group would have resolved to an empty slate. Give the finance group a real holder and launch the flow: * add an `Ada Auditor (demo)` persona holding ONLY `auditor`. It has to be a *different* user from the admin — with one user in both groups a single decision satisfies both tallies at once and "one approval per group" is never observable. * launch `showcase_expense_signoff` on EXP-2001 ($1,500), deliberately UNDER the $5,000 committee threshold so the quorum flow does not also open a request on the same record and blur the two demos. Generalises the two single-purpose helpers (`ensurePhoneDemoUser` → `ensureDemoUser` returning the id; `assignAdminPositions` → `assignPositions`) and corrects a comment that labelled the `unanimous` invoice flow as 会签. Verified on a wiped dev DB — the request opens with the slate resolved across both groups, not collapsed onto one user: process_name = flow:showcase_expense_signoff behavior = per_group, minApprovals = 1 approvers = [position manager @group manager, position auditor @group finance] pending_approvers = <admin>, usr_showcase_auditor_demo status = pending Boot log stays at 0 ERROR; the other two demos are unchanged (3 pending requests now: unanimous, quorum, per_group). `tsc --noEmit` passes. From the #3358 §1 evidence pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f8c9594 commit 25c5056

1 file changed

Lines changed: 74 additions & 25 deletions

File tree

examples/app-showcase/src/security/seed-approval-demo.ts

Lines changed: 74 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@
2626
* inbox);
2727
* 2. provision a phone-based demo user so the "phone sign-in surfaces" show
2828
* a real number in the All Users list + record detail;
29-
* 3. launch the Invoice Dual Sign-off (finance ∧ legal — 会签) and the
30-
* High-Value Committee Quorum (2-of-3) flows through the real automation
31-
* engine, so genuine, resumable pending requests land in the inbox.
29+
* 3. launch one flow per approval behavior through the real automation engine,
30+
* so genuine, resumable pending requests land in the inbox — Invoice Dual
31+
* Sign-off (`unanimous`: finance ∧ legal), High-Value Committee
32+
* (`quorum`: 2-of-3), and Expense Sign-off (`per_group` 会签: one approval
33+
* from each of the manager / finance groups).
3234
*
3335
* Everything is idempotent: a persistent DB keeps the assignments/requests, and
3436
* `openNodeRequest` rejects a duplicate pending request per (object, record),
@@ -50,6 +52,18 @@ const PHONE_DEMO_USER = {
5052
phone_number: '+8613800138000',
5153
} as const;
5254

55+
/**
56+
* A second persona holding ONLY `auditor`, which is the position behind the
57+
* `finance` group of the per-group (会签) demo. It has to be a *different* user
58+
* from the admin: with one user in both groups a single decision would satisfy
59+
* both tallies at once, and "one approval per group" would never be observable.
60+
*/
61+
const AUDITOR_DEMO_USER = {
62+
id: 'usr_showcase_auditor_demo',
63+
name: 'Ada Auditor (demo)',
64+
email: 'auditor.demo@example.com',
65+
} as const;
66+
5367
interface ApprovalDemoContext {
5468
ql: {
5569
find: (object: string, query: unknown, options?: unknown) => Promise<unknown>;
@@ -91,15 +105,17 @@ async function findOne(
91105
}
92106
}
93107

94-
/** Grant the admin the approval-routing positions (idempotent by stable id). */
95-
async function assignAdminPositions(
108+
/** Grant a user approval-routing positions (idempotent by stable id). */
109+
async function assignPositions(
96110
ctx: ApprovalDemoContext,
97-
adminId: string,
111+
userId: string,
112+
positions: readonly string[],
98113
organizationId: string | null,
114+
idPrefix: string,
99115
): Promise<void> {
100-
for (const position of ADMIN_APPROVAL_POSITIONS) {
116+
for (const position of positions) {
101117
const existing = await findOne(ctx, 'sys_user_position', {
102-
user_id: adminId,
118+
user_id: userId,
103119
position,
104120
...(organizationId ? { organization_id: organizationId } : {}),
105121
});
@@ -108,11 +124,11 @@ async function assignAdminPositions(
108124
await ctx.ql.insert(
109125
'sys_user_position',
110126
{
111-
id: `usp_showcase_admin_${position}`,
112-
user_id: adminId,
127+
id: `usp_showcase_${idPrefix}_${position}`,
128+
user_id: userId,
113129
position,
114130
...(organizationId ? { organization_id: organizationId } : {}),
115-
reason: 'Showcase approval demo — admin holds every approver position so requests are actionable.',
131+
reason: 'Showcase approval demo — demo personas hold the approver positions so requests are actionable.',
116132
},
117133
{ context: SYS },
118134
);
@@ -125,24 +141,34 @@ async function assignAdminPositions(
125141
}
126142
}
127143

128-
/** Provision a phone-based demo user (best-effort; renders the phone surfaces). */
129-
async function ensurePhoneDemoUser(ctx: ApprovalDemoContext): Promise<void> {
130-
const existing = await findOne(ctx, 'sys_user', { email: PHONE_DEMO_USER.email });
131-
if (existing) return;
144+
/**
145+
* Provision a demo persona row (best-effort). Returns the user id, whether it
146+
* was just created or already present, so callers can route positions at it.
147+
*/
148+
async function ensureDemoUser(
149+
ctx: ApprovalDemoContext,
150+
user: { id: string; name: string; email: string; phone_number?: string },
151+
): Promise<string | undefined> {
152+
const existing = await findOne(ctx, 'sys_user', { email: user.email });
153+
if (existing?.id) return String(existing.id);
132154
try {
133155
// `sys_user` carries NO org column — org membership lives on `sys_member`
134156
// (see the resolution in `run` below). An `organization_id` key here is not
135157
// silently dropped: it reaches SQL as a real column and the insert dies with
136158
// "table sys_user has no column named organization_id", so the demo user is
137-
// never provisioned and the phone surfaces render empty.
138-
await ctx.ql.insert('sys_user', { ...PHONE_DEMO_USER }, { context: SYS });
139-
ctx.logger?.info?.('[showcase] approval-demo phone user provisioned', { email: PHONE_DEMO_USER.email });
159+
// never provisioned and its surfaces render empty.
160+
await ctx.ql.insert('sys_user', { ...user }, { context: SYS });
161+
ctx.logger?.info?.('[showcase] approval-demo persona provisioned', { email: user.email });
162+
return user.id;
140163
} catch (err) {
141164
// Non-fatal: sign-in still needs a better-auth account; this row just makes
142-
// the phone number visible in the All Users list + record detail.
143-
ctx.logger?.warn?.('[showcase] approval-demo phone user insert failed (surfaces only)', {
165+
// the persona visible in the All Users list + record detail, and routable
166+
// as an approver.
167+
ctx.logger?.warn?.('[showcase] approval-demo persona insert failed (surfaces only)', {
168+
email: user.email,
144169
error: err instanceof Error ? err.message : String(err),
145170
});
171+
return undefined;
146172
}
147173
}
148174

@@ -223,8 +249,13 @@ export function registerShowcaseApprovalDemo(ctx: ApprovalDemoContext): void {
223249
const anyMember = ownerMember ?? (await findOne(ctx, 'sys_member', { user_id: adminId }));
224250
const organizationId = (anyMember?.organization_id as string | undefined) ?? null;
225251

226-
await assignAdminPositions(ctx, adminId, organizationId);
227-
await ensurePhoneDemoUser(ctx);
252+
await assignPositions(ctx, adminId, ADMIN_APPROVAL_POSITIONS, organizationId, 'admin');
253+
await ensureDemoUser(ctx, PHONE_DEMO_USER);
254+
// The auditor persona backs the `finance` group of the per-group demo. It
255+
// deliberately holds ONLY `auditor`, so the two groups have distinct
256+
// holders and the request stays open until each group has answered.
257+
const auditorId = await ensureDemoUser(ctx, AUDITOR_DEMO_USER);
258+
if (auditorId) await assignPositions(ctx, auditorId, ['auditor'], organizationId, 'auditor');
228259

229260
let engine: AutomationEngineLike | undefined;
230261
try {
@@ -237,9 +268,10 @@ export function registerShowcaseApprovalDemo(ctx: ApprovalDemoContext): void {
237268
return;
238269
}
239270

240-
// 会签 (per_group): Invoice Dual Sign-off needs a `sent` invoice; the start
241-
// gate is `status == "sent" && previous.status != "sent"`, so it entered
242-
// from `draft`.
271+
// `unanimous`: Invoice Dual Sign-off needs a `sent` invoice; the start gate
272+
// is `status == "sent" && previous.status != "sent"`, so it entered from
273+
// `draft`. Both named approvers must answer (not one-per-group — that is
274+
// the expense demo below).
243275
const sentInvoice = await findOne(ctx, 'showcase_invoice', { status: 'sent' });
244276
if (sentInvoice) {
245277
await launchSignoff(ctx, engine, 'showcase_invoice_signoff', 'showcase_invoice', sentInvoice, organizationId, 'draft');
@@ -252,6 +284,23 @@ export function registerShowcaseApprovalDemo(ctx: ApprovalDemoContext): void {
252284
if (demoExpense) {
253285
await launchSignoff(ctx, engine, 'showcase_committee_quorum', 'showcase_expense_report', demoExpense, organizationId, 'draft');
254286
}
287+
288+
// 会签 (per_group): Expense Sign-off needs one approval from EACH of the
289+
// `manager` and `finance` groups. Deliberately routed at EXP-2001 ($1,500),
290+
// which sits UNDER the $5,000 committee threshold, so the quorum flow above
291+
// does not also open a request on the same record and blur the two demos.
292+
const perGroupExpense = await findOne(ctx, 'showcase_expense_report', { name: 'EXP-2001' });
293+
if (perGroupExpense) {
294+
await launchSignoff(
295+
ctx,
296+
engine,
297+
'showcase_expense_signoff',
298+
'showcase_expense_report',
299+
perGroupExpense,
300+
organizationId,
301+
'draft',
302+
);
303+
}
255304
};
256305

257306
if (typeof ctx.hook === 'function') {

0 commit comments

Comments
 (0)