Skip to content

Commit ffb83ae

Browse files
authored
Merge pull request #3364 from objectstack-ai/feat/showcase-approval-demo
feat(showcase): make v16 approvals demonstrable out of the box
2 parents ed61cd5 + 61908f0 commit ffb83ae

6 files changed

Lines changed: 351 additions & 5 deletions

File tree

examples/app-showcase/objectstack.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { ExternalCustomer, ExternalOrder } from './src/data/objects/external/ind
1919
import { setupShowcaseExternalDatasource } from './src/system/datasources/external-fixture.js';
2020
import { registerRecalcEndpoint } from './src/system/server/recalc-endpoint.js';
2121
import { registerShowcasePositionBindings } from './src/security/bind-position-sets.js';
22+
import { registerShowcaseApprovalDemo } from './src/security/seed-approval-demo.js';
2223
import { TaskViews, ProjectViews, InquiryViews, BusinessUnitViews } from './src/ui/views/index.js';
2324
import { ShowcaseApp } from './src/ui/apps/index.js';
2425
import { ChartGalleryDashboard, OpsDashboard, RevenuePulseDashboard } from './src/ui/dashboards/index.js';
@@ -248,4 +249,9 @@ export const onEnable = async (ctx: unknown): Promise<void> => {
248249
// [#2926 ②] Ensure the persona position↔permission-set bindings exist after
249250
// the security bootstraps (cannot be a seed — see bind-position-sets.ts).
250251
registerShowcasePositionBindings(ctx as Parameters<typeof registerShowcasePositionBindings>[0]);
252+
// Make the v16 approval features (会签 / quorum) demonstrable on a fresh boot:
253+
// assign the dev admin the approver positions and launch the signoff flows so
254+
// real pending requests land in the inbox (cannot be a seed — see
255+
// seed-approval-demo.ts).
256+
registerShowcaseApprovalDemo(ctx as Parameters<typeof registerShowcaseApprovalDemo>[0]);
251257
};

examples/app-showcase/src/data/seed/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,11 @@ const expenseReports = defineSeed(ExpenseReport, {
314314
{ name: 'EXP-2001', employee: 'Ada Lovelace', status: 'submitted', submitted_on: cel`daysAgo(5)` },
315315
{ name: 'EXP-2002', employee: 'Linus Torvalds', status: 'approved', submitted_on: cel`daysAgo(12)` },
316316
{ name: 'EXP-2003', employee: 'Grace Hopper', status: 'draft' },
317+
// High-value (> $5000) submitted report — the trigger record for the
318+
// High-Value Committee Quorum (2-of-3) flow, launched on boot by
319+
// src/security/seed-approval-demo.ts so a real pending request lands in the
320+
// inbox out of the box.
321+
{ name: 'EXP-DEMO', employee: 'Grace Hopper', status: 'submitted', submitted_on: cel`daysAgo(1)` },
317322
],
318323
});
319324

@@ -333,6 +338,11 @@ const expenseLines = defineSeed(ExpenseLine, {
333338
// EXP-2003 (draft) → total 225.75 · approved 0 · reimbursable 0 · rejected 0 · over$500 0
334339
{ merchant: 'Hilton Garden Inn', expense_report: 'EXP-2003', category: 'lodging', amount: 210, billable: false, status: 'submitted', incurred_on: cel`daysAgo(3)` },
335340
{ merchant: 'Starbucks', expense_report: 'EXP-2003', category: 'meals', amount: 15.75, billable: false, status: 'submitted', incurred_on: cel`daysAgo(2)` },
341+
// EXP-DEMO → total 8900 (≥ $5000, trips the committee-quorum threshold)
342+
{ merchant: 'Dreamforce Conference', expense_report: 'EXP-DEMO', category: 'other', amount: 3200, billable: true, status: 'submitted', incurred_on: cel`daysAgo(4)` },
343+
{ merchant: 'Lufthansa', expense_report: 'EXP-DEMO', category: 'travel', amount: 2400, billable: true, status: 'submitted', incurred_on: cel`daysAgo(4)` },
344+
{ merchant: 'Grand Hyatt', expense_report: 'EXP-DEMO', category: 'lodging', amount: 1800, billable: true, status: 'submitted', incurred_on: cel`daysAgo(3)` },
345+
{ merchant: 'Apple Store', expense_report: 'EXP-DEMO', category: 'software', amount: 1500, billable: true, status: 'submitted', incurred_on: cel`daysAgo(3)` },
336346
],
337347
});
338348

examples/app-showcase/src/security/positions.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,30 @@ export const ClientPortalUserPosition = definePosition({
7777
description: 'External client admitted to the Client Portal.',
7878
});
7979

80+
/**
81+
* Approval-routing positions (会签 / quorum demos). The `approval` flow nodes
82+
* in src/automation/flows route to `{ type: 'position', value: 'finance' | 'legal' }`
83+
* (Invoice Dual Sign-off → finance AND legal; High-Value Committee Quorum →
84+
* manager + finance + legal, 2-of-3). Without these declared — and without a
85+
* holder assigned (see src/security/seed-approval-demo.ts) — those requests
86+
* resolve to an empty approver slate and wait forever, so the marquee v16
87+
* approval features could not be demonstrated out of the box.
88+
*
89+
* They carry no permission-set binding: they exist purely to route approvals,
90+
* so a holder gets no extra data access from holding one.
91+
*/
92+
export const FinancePosition = definePosition({
93+
name: 'finance',
94+
label: 'Finance',
95+
description: 'Finance sign-off authority on invoices and high-value expenses (approval routing only).',
96+
});
97+
98+
export const LegalPosition = definePosition({
99+
name: 'legal',
100+
label: 'Legal',
101+
description: 'Legal sign-off authority on invoices and high-value expenses (approval routing only).',
102+
});
103+
80104
export const allPositions = [
81105
ContributorPosition,
82106
ManagerPosition,
@@ -85,4 +109,6 @@ export const allPositions = [
85109
OpsPosition,
86110
FieldOpsDelegatePosition,
87111
ClientPortalUserPosition,
112+
FinancePosition,
113+
LegalPosition,
88114
];
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Approval demo bootstrap — makes the marquee v16 approval features
5+
* (M-of-N quorum + per-group 会签, server-computed progress, decision
6+
* attachments, `?request=` deep links, viewer gating, the reassign picker)
7+
* demonstrable on a FRESH boot, with no manual setup.
8+
*
9+
* Why this exists (and can't be a seed):
10+
* - The `approval` flow nodes route to `{ type: 'position', value: 'finance' |
11+
* 'legal' | 'manager' }`. Approver resolution reads `sys_user_position`
12+
* (ADR-0090 D3), but users can't be seeded (they sign up) and position
13+
* assignments are runtime admin actions — so out of the box NO ONE holds
14+
* those positions and every request resolves to an empty slate and waits
15+
* forever.
16+
* - The seed loader SUPPRESSES record-change flows (#2661), so seeding an
17+
* invoice as `sent` (or an expense as `submitted`) never opens a request.
18+
* - `sys_approval_request` is engine-owned (ADR-0103: get/list only), so a
19+
* request can't be inserted through the generic data API either.
20+
*
21+
* So we play the admin's part imperatively, exactly like `bind-position-sets.ts`:
22+
* on `kernel:bootstrapped` (after the security bootstrap has created the
23+
* position/permission rows and the automation engine is wired) we
24+
* 1. assign the dev-seeded admin to `manager` / `finance` / `legal` so they
25+
* are a resolvable approver on every demo request (and can act in the
26+
* inbox);
27+
* 2. provision a phone-based demo user so the "phone sign-in surfaces" show
28+
* 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.
32+
*
33+
* Everything is idempotent: a persistent DB keeps the assignments/requests, and
34+
* `openNodeRequest` rejects a duplicate pending request per (object, record),
35+
* which we swallow.
36+
*/
37+
38+
const SYS = { isSystem: true } as const;
39+
40+
const ADMIN_EMAIL = 'admin@objectos.ai';
41+
42+
/** Positions the admin is granted so they resolve as an approver on the demos. */
43+
const ADMIN_APPROVAL_POSITIONS = ['manager', 'finance', 'legal'] as const;
44+
45+
/** A phone-based demo persona (§6 "phone sign-in surfaces"). */
46+
const PHONE_DEMO_USER = {
47+
id: 'usr_showcase_phone_demo',
48+
name: 'Mei Phone (demo)',
49+
email: 'phone.demo@example.com',
50+
phone_number: '+8613800138000',
51+
} as const;
52+
53+
interface ApprovalDemoContext {
54+
ql: {
55+
find: (object: string, query: unknown, options?: unknown) => Promise<unknown>;
56+
insert: (object: string, data: Record<string, unknown>, options?: unknown) => Promise<unknown>;
57+
};
58+
getService?: <T = unknown>(name: string) => Promise<T>;
59+
logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void };
60+
hook?: (event: string, handler: () => Promise<void> | void) => void;
61+
}
62+
63+
/** Minimal shape of the automation engine we drive (see service-automation). */
64+
interface AutomationEngineLike {
65+
execute: (
66+
flowName: string,
67+
context?: { record?: unknown; previous?: unknown; object?: string; organizationId?: string | null; [k: string]: unknown },
68+
) => Promise<{ success?: boolean; error?: string; output?: unknown } | unknown>;
69+
}
70+
71+
function asRows(res: unknown): Array<Record<string, unknown>> {
72+
if (Array.isArray(res)) return res as Array<Record<string, unknown>>;
73+
const r = res as { records?: unknown[] } | null;
74+
return (r?.records as Array<Record<string, unknown>>) ?? [];
75+
}
76+
77+
async function findOne(
78+
ctx: ApprovalDemoContext,
79+
object: string,
80+
where: Record<string, unknown>,
81+
): Promise<Record<string, unknown> | undefined> {
82+
try {
83+
const rows = asRows(await ctx.ql.find(object, { where, limit: 1, context: SYS }));
84+
return rows[0];
85+
} catch (err) {
86+
ctx.logger?.warn?.('[showcase] approval-demo lookup failed', {
87+
object,
88+
error: err instanceof Error ? err.message : String(err),
89+
});
90+
return undefined;
91+
}
92+
}
93+
94+
/** Grant the admin the approval-routing positions (idempotent by stable id). */
95+
async function assignAdminPositions(
96+
ctx: ApprovalDemoContext,
97+
adminId: string,
98+
organizationId: string | null,
99+
): Promise<void> {
100+
for (const position of ADMIN_APPROVAL_POSITIONS) {
101+
const existing = await findOne(ctx, 'sys_user_position', {
102+
user_id: adminId,
103+
position,
104+
...(organizationId ? { organization_id: organizationId } : {}),
105+
});
106+
if (existing) continue;
107+
try {
108+
await ctx.ql.insert(
109+
'sys_user_position',
110+
{
111+
id: `usp_showcase_admin_${position}`,
112+
user_id: adminId,
113+
position,
114+
...(organizationId ? { organization_id: organizationId } : {}),
115+
reason: 'Showcase approval demo — admin holds every approver position so requests are actionable.',
116+
},
117+
{ context: SYS },
118+
);
119+
} catch (err) {
120+
ctx.logger?.warn?.('[showcase] approval-demo position assign failed', {
121+
position,
122+
error: err instanceof Error ? err.message : String(err),
123+
});
124+
}
125+
}
126+
}
127+
128+
/** Provision a phone-based demo user (best-effort; renders the phone surfaces). */
129+
async function ensurePhoneDemoUser(ctx: ApprovalDemoContext, organizationId: string | null): Promise<void> {
130+
const existing = await findOne(ctx, 'sys_user', { email: PHONE_DEMO_USER.email });
131+
if (existing) return;
132+
try {
133+
await ctx.ql.insert(
134+
'sys_user',
135+
{
136+
...PHONE_DEMO_USER,
137+
...(organizationId ? { organization_id: organizationId } : {}),
138+
},
139+
{ context: SYS },
140+
);
141+
ctx.logger?.info?.('[showcase] approval-demo phone user provisioned', { email: PHONE_DEMO_USER.email });
142+
} catch (err) {
143+
// Non-fatal: sign-in still needs a better-auth account; this row just makes
144+
// the phone number visible in the All Users list + record detail.
145+
ctx.logger?.warn?.('[showcase] approval-demo phone user insert failed (surfaces only)', {
146+
error: err instanceof Error ? err.message : String(err),
147+
});
148+
}
149+
}
150+
151+
/**
152+
* Launch a signoff flow on a record through the real automation engine, unless
153+
* a pending request already exists for it.
154+
*/
155+
async function launchSignoff(
156+
ctx: ApprovalDemoContext,
157+
engine: AutomationEngineLike,
158+
flowName: string,
159+
objectName: string,
160+
record: Record<string, unknown>,
161+
organizationId: string | null,
162+
/**
163+
* The record's status BEFORE it entered the trigger state, supplied as
164+
* `context.previous` so the start-node transition gate — e.g.
165+
* `status == "sent" && previous.status != "sent"` — is satisfied. The engine
166+
* only binds `previous` when the caller provides it (engine.ts), and a
167+
* record-change trigger normally would; an explicit launch must too, or the
168+
* start condition silently evaluates false and no request opens.
169+
*/
170+
previousStatus: string,
171+
): Promise<void> {
172+
const recordId = String(record.id ?? '');
173+
if (!recordId) return;
174+
const pending = await findOne(ctx, 'sys_approval_request', {
175+
object_name: objectName,
176+
record_id: recordId,
177+
status: 'pending',
178+
});
179+
if (pending) {
180+
ctx.logger?.info?.('[showcase] approval-demo request already pending', { flow: flowName, record: recordId });
181+
return;
182+
}
183+
try {
184+
// The `object` + `organizationId` on the context are what a record-change
185+
// trigger supplies; the approval node reads `context.object` for its target
186+
// (approval-node.ts) and stamps the request's org from `context.organizationId`.
187+
const result = (await engine.execute(flowName, {
188+
record,
189+
previous: { ...record, status: previousStatus },
190+
object: objectName,
191+
organizationId,
192+
})) as { success?: boolean; error?: string; output?: { skipped?: boolean; reason?: string } };
193+
if (result?.success === false) {
194+
ctx.logger?.warn?.('[showcase] approval-demo flow returned an error', { flow: flowName, error: result.error });
195+
} else if (result?.output?.skipped) {
196+
ctx.logger?.warn?.('[showcase] approval-demo flow skipped (start condition not met)', {
197+
flow: flowName, reason: result.output.reason,
198+
});
199+
} else {
200+
ctx.logger?.info?.('[showcase] approval-demo launched', { flow: flowName, object: objectName, record: recordId });
201+
}
202+
} catch (err) {
203+
const msg = err instanceof Error ? err.message : String(err);
204+
if (msg.includes('DUPLICATE_REQUEST')) return; // raced another launcher — fine
205+
ctx.logger?.warn?.('[showcase] approval-demo flow launch failed', { flow: flowName, error: msg });
206+
}
207+
}
208+
209+
export function registerShowcaseApprovalDemo(ctx: ApprovalDemoContext): void {
210+
const run = async (): Promise<void> => {
211+
const admin = await findOne(ctx, 'sys_user', { email: ADMIN_EMAIL });
212+
if (!admin?.id) {
213+
// No dev-seeded admin (e.g. a real deployment) — nothing to demo against.
214+
ctx.logger?.info?.('[showcase] approval-demo skipped (no dev admin)');
215+
return;
216+
}
217+
const adminId = String(admin.id);
218+
// The active org lives on the better-auth membership (`sys_member`), NOT on
219+
// `sys_user.organization_id` (which is null for the dev admin). Both the
220+
// position rows AND the requests must carry this org, or the org-scoped
221+
// approver resolution (`sys_user_position` filtered by org) and `getRequest`
222+
// (org-scoped read that the inbox drawer uses) silently return nothing.
223+
let organizationId = (admin.organization_id as string | undefined) ?? null;
224+
if (!organizationId) {
225+
const ownerMember = await findOne(ctx, 'sys_member', { user_id: adminId, role: 'owner' });
226+
const anyMember = ownerMember ?? (await findOne(ctx, 'sys_member', { user_id: adminId }));
227+
organizationId = (anyMember?.organization_id as string | undefined) ?? null;
228+
}
229+
230+
await assignAdminPositions(ctx, adminId, organizationId);
231+
await ensurePhoneDemoUser(ctx, organizationId);
232+
233+
let engine: AutomationEngineLike | undefined;
234+
try {
235+
engine = await ctx.getService?.<AutomationEngineLike>('automation');
236+
} catch {
237+
engine = undefined;
238+
}
239+
if (!engine || typeof engine.execute !== 'function') {
240+
ctx.logger?.warn?.('[showcase] approval-demo: automation engine unavailable — requests not opened');
241+
return;
242+
}
243+
244+
// 会签 (per_group): Invoice Dual Sign-off needs a `sent` invoice; the start
245+
// gate is `status == "sent" && previous.status != "sent"`, so it entered
246+
// from `draft`.
247+
const sentInvoice = await findOne(ctx, 'showcase_invoice', { status: 'sent' });
248+
if (sentInvoice) {
249+
await launchSignoff(ctx, engine, 'showcase_invoice_signoff', 'showcase_invoice', sentInvoice, organizationId, 'draft');
250+
}
251+
252+
// Quorum (2-of-3): High-Value Committee needs a `submitted` report ≥ $5000;
253+
// the start gate is `status == "submitted" && previous.status != "submitted"
254+
// && total_amount >= 5000`, so it entered from `draft`.
255+
const demoExpense = await findOne(ctx, 'showcase_expense_report', { name: 'EXP-DEMO' });
256+
if (demoExpense) {
257+
await launchSignoff(ctx, engine, 'showcase_committee_quorum', 'showcase_expense_report', demoExpense, organizationId, 'draft');
258+
}
259+
};
260+
261+
if (typeof ctx.hook === 'function') {
262+
// `kernel:bootstrapped` — after every `kernel:ready` handler (the security
263+
// bootstrap that seeds positions, and the automation engine wiring) has
264+
// settled, so lookups resolve and the engine is ready.
265+
ctx.hook('kernel:bootstrapped', run);
266+
} else {
267+
setTimeout(() => void run(), 0);
268+
}
269+
}

0 commit comments

Comments
 (0)