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