@objectstack/plugin-approvals 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,731 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { ApprovalProcessSchema } from '@objectstack/spec/automation';
4
+ import type {
5
+ IApprovalService,
6
+ ApprovalProcessRow,
7
+ ApprovalRequestRow,
8
+ ApprovalActionRow,
9
+ ApprovalDecisionInput,
10
+ ApprovalDecisionResult,
11
+ ApprovalStatus,
12
+ DefineApprovalProcessInput,
13
+ SubmitApprovalInput,
14
+ SharingExecutionContext,
15
+ } from '@objectstack/spec/contracts';
16
+ import { executeActions, type ApprovalTrigger, type FetchLike } from './action-executor.js';
17
+
18
+ /**
19
+ * Narrow engine surface — keeps the service testable without booting
20
+ * a real ObjectQL kernel.
21
+ */
22
+ export interface ApprovalEngine {
23
+ find(object: string, options?: any): Promise<any[]>;
24
+ insert(object: string, data: any, options?: any): Promise<any>;
25
+ update(object: string, idOrData: any, dataOrOptions?: any, options?: any): Promise<any>;
26
+ delete(object: string, options?: any): Promise<any>;
27
+ }
28
+
29
+ export interface ApprovalClock { now(): Date }
30
+
31
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
32
+
33
+ function uid(prefix: string): string {
34
+ const g: any = globalThis as any;
35
+ if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
36
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
37
+ }
38
+
39
+ function parseJson<T = any>(raw: unknown, fallback: T): T {
40
+ if (raw == null || raw === '') return fallback;
41
+ if (typeof raw === 'string') {
42
+ try { return JSON.parse(raw) as T; } catch { return fallback; }
43
+ }
44
+ return raw as T;
45
+ }
46
+
47
+ function csvSplit(raw: unknown): string[] {
48
+ if (!raw) return [];
49
+ if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
50
+ return String(raw).split(',').map(s => s.trim()).filter(Boolean);
51
+ }
52
+
53
+ function rowFromProcess(row: any): ApprovalProcessRow {
54
+ return {
55
+ id: String(row.id),
56
+ name: String(row.name ?? ''),
57
+ label: String(row.label ?? ''),
58
+ object_name: String(row.object_name ?? ''),
59
+ description: row.description ?? undefined,
60
+ active: row.active !== false,
61
+ definition: parseJson(row.definition_json, {}),
62
+ created_at: row.created_at ?? undefined,
63
+ updated_at: row.updated_at ?? undefined,
64
+ };
65
+ }
66
+
67
+ function rowFromRequest(row: any): ApprovalRequestRow {
68
+ return {
69
+ id: String(row.id),
70
+ organization_id: row.organization_id ?? undefined,
71
+ process_name: String(row.process_name ?? ''),
72
+ object_name: String(row.object_name ?? ''),
73
+ record_id: String(row.record_id ?? ''),
74
+ submitter_id: row.submitter_id ?? undefined,
75
+ submitter_comment: row.submitter_comment ?? undefined,
76
+ status: (row.status as ApprovalStatus) ?? 'pending',
77
+ current_step: row.current_step ?? undefined,
78
+ current_step_index: row.current_step_index ?? undefined,
79
+ pending_approvers: csvSplit(row.pending_approvers),
80
+ payload: parseJson(row.payload_json, undefined),
81
+ completed_at: row.completed_at ?? undefined,
82
+ created_at: row.created_at ?? undefined,
83
+ updated_at: row.updated_at ?? undefined,
84
+ } as any;
85
+ }
86
+
87
+ function rowFromAction(row: any): ApprovalActionRow {
88
+ return {
89
+ id: String(row.id),
90
+ request_id: String(row.request_id),
91
+ step_name: row.step_name ?? undefined,
92
+ step_index: row.step_index ?? undefined,
93
+ action: row.action,
94
+ actor_id: row.actor_id ?? undefined,
95
+ comment: row.comment ?? undefined,
96
+ created_at: row.created_at ?? undefined,
97
+ };
98
+ }
99
+
100
+ // Note: legacy synchronous `resolveApprovers` removed in M10.17.1 — replaced
101
+ // by the async `expandApprovers` member which routes through the team/dept
102
+ // graph tables (with prefixed-literal fallback for back-compat).
103
+
104
+ export interface ApprovalServiceOptions {
105
+ engine: ApprovalEngine;
106
+ clock?: ApprovalClock;
107
+ logger?: { info?: (msg: any, ...rest: any[]) => void; warn?: (msg: any, ...rest: any[]) => void; error?: (msg: any, ...rest: any[]) => void; debug?: (msg: any, ...rest: any[]) => void };
108
+ /** Optional fetch impl for `webhook` actions; defaults to global. */
109
+ fetch?: FetchLike;
110
+ /** Webhook timeout in ms; default 5000. */
111
+ webhookTimeoutMs?: number;
112
+ /**
113
+ * Called after the process registry changes (defineProcess / deleteProcess).
114
+ * The plugin uses this to re-bind lifecycle hooks for auto-trigger / lock.
115
+ */
116
+ onRegistryChange?: () => void | Promise<void>;
117
+ }
118
+
119
+ export class ApprovalService implements IApprovalService {
120
+ private readonly engine: ApprovalEngine;
121
+ private readonly clock: ApprovalClock;
122
+ private readonly logger?: ApprovalServiceOptions['logger'];
123
+ private readonly fetchImpl?: FetchLike;
124
+ private readonly webhookTimeoutMs?: number;
125
+ private readonly onRegistryChange?: () => void | Promise<void>;
126
+
127
+ constructor(opts: ApprovalServiceOptions) {
128
+ this.engine = opts.engine;
129
+ this.clock = opts.clock ?? { now: () => new Date() };
130
+ this.logger = opts.logger;
131
+ this.fetchImpl = opts.fetch;
132
+ this.webhookTimeoutMs = opts.webhookTimeoutMs;
133
+ this.onRegistryChange = opts.onRegistryChange;
134
+ }
135
+
136
+ /** Allow the plugin to attach a hook re-binding callback after construction. */
137
+ setRegistryChangeHandler(handler: () => void | Promise<void>): void {
138
+ (this as any).onRegistryChange = handler;
139
+ }
140
+
141
+ /**
142
+ * Expand the approvers on a step into user IDs by querying the graph
143
+ * tables for `team:` / `department:` / `role:` / `manager:` approver
144
+ * types. Falls back to a prefixed literal (`type:value`) when graph
145
+ * lookups produce nothing — so existing test fixtures and approver
146
+ * flows that rely on substring matching keep working.
147
+ *
148
+ * **Graph semantics (M10.17.1):**
149
+ * - `team` → flat members of `sys_team` (better-auth; no BFS)
150
+ * - `department` → recursive BFS of `sys_department.parent_department_id`
151
+ * → members of every descendant via `sys_department_member`
152
+ * - `role` → users with `sys_member.role = value` in tenant
153
+ * - `manager` → `sys_user.manager_id` of `record[value] ?? record.owner_id`
154
+ * - `field` → literal user id stored in `record[value]`
155
+ * - `user` → literal value
156
+ */
157
+ private async expandApprovers(step: any, record?: any, organizationId?: string | null): Promise<string[]> {
158
+ if (!step || !Array.isArray(step.approvers)) return [];
159
+ const out: string[] = [];
160
+ for (const a of step.approvers) {
161
+ if (!a) continue;
162
+ if (a.type === 'user') { out.push(String(a.value)); continue; }
163
+ if (a.type === 'field' && record) { out.push(String((record as any)[a.value] ?? '')); continue; }
164
+ try {
165
+ if (a.type === 'team') {
166
+ const users = await this.expandTeamUsers(String(a.value));
167
+ if (users.length) { for (const u of users) out.push(u); continue; }
168
+ } else if (a.type === 'department' || a.type === 'dept') {
169
+ const users = await this.expandDepartmentUsers(String(a.value), organizationId);
170
+ if (users.length) { for (const u of users) out.push(u); continue; }
171
+ } else if (a.type === 'role') {
172
+ const users = await this.expandRoleUsers(String(a.value), organizationId);
173
+ if (users.length) { for (const u of users) out.push(u); continue; }
174
+ } else if (a.type === 'manager' && record) {
175
+ const subject = (record as any)[a.value] ?? (record as any).owner_id;
176
+ if (subject) {
177
+ const mgr = await this.lookupManager(String(subject));
178
+ if (mgr) { out.push(mgr); continue; }
179
+ }
180
+ }
181
+ } catch { /* fall through */ }
182
+ out.push(`${a.type}:${a.value}`);
183
+ }
184
+ return out.filter(Boolean);
185
+ }
186
+
187
+ /** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
188
+ private async expandTeamUsers(teamId: string): Promise<string[]> {
189
+ if (!teamId) return [];
190
+ let rows: any[] = [];
191
+ try {
192
+ rows = await this.engine.find('sys_team_member', {
193
+ filter: { team_id: teamId },
194
+ fields: ['user_id'],
195
+ limit: 10000,
196
+ context: SYSTEM_CTX,
197
+ } as any);
198
+ } catch { rows = []; }
199
+ return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
200
+ }
201
+
202
+ /** Recursive department — walks `sys_department.parent_department_id`. */
203
+ private async expandDepartmentUsers(departmentId: string, organizationId?: string | null): Promise<string[]> {
204
+ if (!departmentId) return [];
205
+ // Seed sanity check: skip if dept doesn't exist or is inactive within tenant.
206
+ try {
207
+ const seed = await this.engine.find('sys_department', {
208
+ filter: organizationId
209
+ ? { id: departmentId, organization_id: organizationId }
210
+ : { id: departmentId },
211
+ fields: ['id', 'active'],
212
+ limit: 1,
213
+ context: SYSTEM_CTX,
214
+ } as any);
215
+ const seedRow: any = Array.isArray(seed) ? seed[0] : null;
216
+ if (!seedRow || seedRow.active === false) return [];
217
+ } catch { return []; }
218
+
219
+ const seen = new Set<string>([departmentId]);
220
+ const queue: string[] = [departmentId];
221
+ while (queue.length) {
222
+ const parent = queue.shift()!;
223
+ let kids: any[] = [];
224
+ try {
225
+ const filter: any = { parent_department_id: parent, active: { $ne: false } };
226
+ if (organizationId) filter.organization_id = organizationId;
227
+ kids = await this.engine.find('sys_department', { filter, fields: ['id'], limit: 1000, context: SYSTEM_CTX } as any);
228
+ } catch { kids = []; }
229
+ for (const k of kids ?? []) {
230
+ const kid = String((k as any).id ?? '');
231
+ if (kid && !seen.has(kid)) { seen.add(kid); queue.push(kid); }
232
+ }
233
+ }
234
+ let rows: any[] = [];
235
+ try {
236
+ rows = await this.engine.find('sys_department_member', {
237
+ filter: { department_id: { $in: Array.from(seen) } },
238
+ fields: ['user_id'],
239
+ limit: 10000,
240
+ context: SYSTEM_CTX,
241
+ } as any);
242
+ } catch { rows = []; }
243
+ return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
244
+ }
245
+
246
+ private async expandRoleUsers(roleName: string, organizationId?: string | null): Promise<string[]> {
247
+ if (!roleName) return [];
248
+ const filter: any = { role: roleName };
249
+ if (organizationId) filter.organization_id = organizationId;
250
+ let rows: any[] = [];
251
+ try {
252
+ rows = await this.engine.find('sys_member', { filter, fields: ['user_id'], limit: 10000, context: SYSTEM_CTX } as any);
253
+ } catch { rows = []; }
254
+ return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
255
+ }
256
+
257
+ private async lookupManager(userId: string): Promise<string | null> {
258
+ try {
259
+ const rows = await this.engine.find('sys_user', {
260
+ filter: { id: userId }, fields: ['id', 'manager_id'], limit: 1, context: SYSTEM_CTX,
261
+ } as any);
262
+ const row: any = Array.isArray(rows) ? rows[0] : null;
263
+ return row?.manager_id ? String(row.manager_id) : null;
264
+ } catch { return null; }
265
+ }
266
+
267
+
268
+ private async notifyRegistryChanged(): Promise<void> {
269
+ const cb = this.onRegistryChange ?? ((this as any).onRegistryChange as (() => void | Promise<void>) | undefined);
270
+ if (!cb) return;
271
+ try { await cb(); }
272
+ catch (err: any) { this.logger?.warn?.('[approvals] onRegistryChange handler failed', { error: err?.message }); }
273
+ }
274
+
275
+ /** Mirror request status onto `process.approvalStatusField` if configured. */
276
+ private async syncStatusField(process: ApprovalProcessRow, request: ApprovalRequestRow): Promise<void> {
277
+ const field = (process.definition as any)?.approvalStatusField;
278
+ if (!field) return;
279
+ try {
280
+ await this.engine.update(
281
+ process.object_name,
282
+ { id: request.record_id, [field]: request.status },
283
+ { context: SYSTEM_CTX },
284
+ );
285
+ } catch (err: any) {
286
+ this.logger?.warn?.(`[approvals] syncStatusField failed: ${err?.message ?? err}`);
287
+ }
288
+ }
289
+
290
+ /** Convenience wrapper that funnels every action invocation through the executor. */
291
+ private async runActions(
292
+ actions: any[] | undefined | null,
293
+ trigger: ApprovalTrigger,
294
+ process: ApprovalProcessRow,
295
+ request: ApprovalRequestRow,
296
+ step: any | undefined,
297
+ actorId: string | null | undefined,
298
+ comment: string | null | undefined,
299
+ ): Promise<void> {
300
+ if (!actions || actions.length === 0) return;
301
+ await executeActions(actions, {
302
+ trigger,
303
+ process: { ...process, object: process.object_name },
304
+ request,
305
+ step,
306
+ actorId: actorId ?? null,
307
+ comment: comment ?? null,
308
+ }, {
309
+ engine: this.engine,
310
+ logger: this.logger,
311
+ fetch: this.fetchImpl,
312
+ webhookTimeoutMs: this.webhookTimeoutMs,
313
+ });
314
+ }
315
+
316
+ // ── Process definitions ──────────────────────────────────────
317
+
318
+ async defineProcess(input: DefineApprovalProcessInput, _context: SharingExecutionContext): Promise<ApprovalProcessRow> {
319
+ if (!input.name) throw new Error('VALIDATION_FAILED: name is required');
320
+ if (!input.label) throw new Error('VALIDATION_FAILED: label is required');
321
+ if (!input.object) throw new Error('VALIDATION_FAILED: object is required');
322
+ if (!input.definition) throw new Error('VALIDATION_FAILED: definition is required');
323
+
324
+ const parsed = ApprovalProcessSchema.safeParse(input.definition);
325
+ if (!parsed.success) {
326
+ const msg = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ');
327
+ throw new Error(`VALIDATION_FAILED: ${msg}`);
328
+ }
329
+
330
+ const now = this.clock.now().toISOString();
331
+ const payload: any = {
332
+ name: input.name,
333
+ label: input.label,
334
+ object_name: input.object,
335
+ description: input.description ?? null,
336
+ active: input.active !== false,
337
+ definition_json: JSON.stringify(parsed.data),
338
+ updated_at: now,
339
+ };
340
+
341
+ // Upsert by name.
342
+ const existing = await this.engine.find('sys_approval_process', {
343
+ where: { name: input.name }, limit: 1, context: SYSTEM_CTX,
344
+ });
345
+ if (Array.isArray(existing) && existing[0]) {
346
+ const id = existing[0].id;
347
+ await this.engine.update('sys_approval_process', { id, ...payload }, { context: SYSTEM_CTX });
348
+ const row = rowFromProcess({ ...existing[0], ...payload, id });
349
+ await this.notifyRegistryChanged();
350
+ return row;
351
+ }
352
+
353
+ const id = input.id ?? uid('apv');
354
+ const row = { id, ...payload, created_at: now };
355
+ await this.engine.insert('sys_approval_process', row, { context: SYSTEM_CTX });
356
+ const out = rowFromProcess(row);
357
+ await this.notifyRegistryChanged();
358
+ return out;
359
+ }
360
+
361
+ async listProcesses(
362
+ filter: { object?: string; activeOnly?: boolean } | undefined,
363
+ _context: SharingExecutionContext,
364
+ ): Promise<ApprovalProcessRow[]> {
365
+ const f: any = {};
366
+ if (filter?.object) f.object_name = filter.object;
367
+ if (filter?.activeOnly) f.active = true;
368
+ const rows = await this.engine.find('sys_approval_process', {
369
+ where: f, limit: 500, orderBy: [{ field: 'updated_at', direction: 'desc' }], context: SYSTEM_CTX,
370
+ });
371
+ return Array.isArray(rows) ? rows.map(rowFromProcess) : [];
372
+ }
373
+
374
+ async getProcess(idOrName: string, _context: SharingExecutionContext): Promise<ApprovalProcessRow | null> {
375
+ if (!idOrName) return null;
376
+ let rows = await this.engine.find('sys_approval_process', {
377
+ where: { id: idOrName }, limit: 1, context: SYSTEM_CTX,
378
+ });
379
+ if (!Array.isArray(rows) || !rows[0]) {
380
+ rows = await this.engine.find('sys_approval_process', {
381
+ where: { name: idOrName }, limit: 1, context: SYSTEM_CTX,
382
+ });
383
+ }
384
+ return Array.isArray(rows) && rows[0] ? rowFromProcess(rows[0]) : null;
385
+ }
386
+
387
+ async deleteProcess(idOrName: string, context: SharingExecutionContext): Promise<void> {
388
+ if (!idOrName) throw new Error('VALIDATION_FAILED: idOrName is required');
389
+ const proc = await this.getProcess(idOrName, context);
390
+ if (!proc) return;
391
+ await this.engine.delete('sys_approval_process', { where: { id: proc.id }, context: SYSTEM_CTX });
392
+ await this.notifyRegistryChanged();
393
+ }
394
+
395
+ // ── Requests ─────────────────────────────────────────────────
396
+
397
+ async submit(input: SubmitApprovalInput, context: SharingExecutionContext): Promise<ApprovalRequestRow> {
398
+ if (!input.object) throw new Error('VALIDATION_FAILED: object is required');
399
+ if (!input.recordId) throw new Error('VALIDATION_FAILED: recordId is required');
400
+
401
+ // Find active process for the object (or by name when supplied).
402
+ let process: ApprovalProcessRow | null = null;
403
+ if (input.processName) {
404
+ process = await this.getProcess(input.processName, context);
405
+ if (process && !process.active) {
406
+ throw new Error(`NO_ACTIVE_PROCESS: process '${input.processName}' is not active`);
407
+ }
408
+ } else {
409
+ const list = await this.listProcesses({ object: input.object, activeOnly: true }, context);
410
+ process = list[0] ?? null;
411
+ }
412
+ if (!process) {
413
+ throw new Error(`NO_ACTIVE_PROCESS: no active approval process for object '${input.object}'`);
414
+ }
415
+
416
+ // De-duplicate: only one pending request per (object, record).
417
+ const existing = await this.engine.find('sys_approval_request', {
418
+ where: { object_name: input.object, record_id: input.recordId, status: 'pending' },
419
+ limit: 1, context: SYSTEM_CTX,
420
+ });
421
+ if (Array.isArray(existing) && existing[0]) {
422
+ throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
423
+ }
424
+
425
+ const steps: any[] = process.definition?.steps ?? [];
426
+ if (steps.length === 0) {
427
+ throw new Error('VALIDATION_FAILED: process definition has no steps');
428
+ }
429
+ const step0 = steps[0];
430
+ const ctxOrg = (context as any)?.organizationId ?? (context as any)?.tenantId ?? null;
431
+ const approvers = await this.expandApprovers(step0, input.payload, ctxOrg);
432
+
433
+ const now = this.clock.now().toISOString();
434
+ const id = uid('areq');
435
+ const row: any = {
436
+ id,
437
+ process_name: process.name,
438
+ object_name: input.object,
439
+ record_id: input.recordId,
440
+ submitter_id: input.submitterId ?? context.userId ?? null,
441
+ submitter_comment: input.comment ?? null,
442
+ status: 'pending',
443
+ current_step: step0.name,
444
+ current_step_index: 0,
445
+ pending_approvers: approvers.join(','),
446
+ payload_json: input.payload != null ? JSON.stringify(input.payload) : null,
447
+ organization_id: ctxOrg,
448
+ created_at: now,
449
+ updated_at: now,
450
+ };
451
+ await this.engine.insert('sys_approval_request', row, { context: SYSTEM_CTX });
452
+
453
+ // Audit: submit.
454
+ await this.engine.insert('sys_approval_action', {
455
+ id: uid('aact'),
456
+ request_id: id,
457
+ organization_id: ctxOrg,
458
+ step_name: step0.name,
459
+ step_index: 0,
460
+ action: 'submit',
461
+ actor_id: input.submitterId ?? context.userId ?? null,
462
+ comment: input.comment ?? null,
463
+ created_at: now,
464
+ }, { context: SYSTEM_CTX });
465
+
466
+ const requestRow = rowFromRequest(row);
467
+
468
+ // Phase B: status mirror + onSubmit actions.
469
+ await this.syncStatusField(process, requestRow);
470
+ const definition: any = process.definition ?? {};
471
+ await this.runActions(
472
+ definition.onSubmit,
473
+ 'submit',
474
+ process,
475
+ requestRow,
476
+ step0,
477
+ input.submitterId ?? context.userId ?? null,
478
+ input.comment ?? null,
479
+ );
480
+
481
+ return requestRow;
482
+ }
483
+
484
+ async listRequests(
485
+ filter: {
486
+ object?: string;
487
+ recordId?: string;
488
+ status?: ApprovalStatus | ApprovalStatus[];
489
+ approverId?: string;
490
+ submitterId?: string;
491
+ } | undefined,
492
+ context: SharingExecutionContext,
493
+ ): Promise<ApprovalRequestRow[]> {
494
+ const f: any = {};
495
+ if (filter?.object) f.object_name = filter.object;
496
+ if (filter?.recordId) f.record_id = filter.recordId;
497
+ if (filter?.submitterId) f.submitter_id = filter.submitterId;
498
+ // Tenant isolation: when a caller context carries a tenant identifier
499
+ // (organizationId / tenantId), scope the query to that tenant. SYSTEM
500
+ // callers (no tenant) see all rows. This prevents the bespoke endpoint
501
+ // from leaking other-tenant rows since we deliberately query with
502
+ // SYSTEM_CTX to bypass RLS on the engine (we need CSV substring match
503
+ // on pending_approvers which RLS can't model cleanly).
504
+ const tenantOrg = (context as any)?.organizationId ?? (context as any)?.tenantId;
505
+ if (tenantOrg) f.organization_id = tenantOrg;
506
+ // Status: when array, post-filter; when single, push into engine filter.
507
+ let statusFilter: ApprovalStatus[] | undefined;
508
+ if (Array.isArray(filter?.status)) statusFilter = filter!.status as ApprovalStatus[];
509
+ else if (filter?.status) f.status = filter.status;
510
+
511
+ const rows = await this.engine.find('sys_approval_request', {
512
+ where: f, limit: 500, orderBy: [{ field: 'updated_at', direction: 'desc' }], context: SYSTEM_CTX,
513
+ });
514
+ let list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
515
+ if (statusFilter) list = list.filter(r => statusFilter!.includes(r.status));
516
+ if (filter?.approverId) {
517
+ const target = filter.approverId;
518
+ list = list.filter(r => (r.pending_approvers ?? []).includes(target));
519
+ }
520
+ return list;
521
+ }
522
+
523
+ async getRequest(requestId: string, context: SharingExecutionContext): Promise<ApprovalRequestRow | null> {
524
+ if (!requestId) return null;
525
+ const where: any = { id: requestId };
526
+ const tenantOrg = (context as any)?.organizationId ?? (context as any)?.tenantId;
527
+ if (tenantOrg) where.organization_id = tenantOrg;
528
+ const rows = await this.engine.find('sys_approval_request', {
529
+ where, limit: 1, context: SYSTEM_CTX,
530
+ });
531
+ return Array.isArray(rows) && rows[0] ? rowFromRequest(rows[0]) : null;
532
+ }
533
+
534
+ async approve(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise<ApprovalDecisionResult> {
535
+ const req = await this.getRequest(requestId, context);
536
+ if (!req) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
537
+ if (req.status !== 'pending') throw new Error(`INVALID_STATE: request is ${req.status}`);
538
+ if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
539
+
540
+ if (!context.isSystem && !(req.pending_approvers ?? []).includes(input.actorId)) {
541
+ throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
542
+ }
543
+
544
+ const process = await this.getProcess(req.process_name, context);
545
+ if (!process) throw new Error(`PROCESS_NOT_FOUND: ${req.process_name}`);
546
+ const steps: any[] = process.definition?.steps ?? [];
547
+ const stepIndex = req.current_step_index ?? 0;
548
+ const step = steps[stepIndex];
549
+ if (!step) throw new Error(`INVALID_STATE: step index ${stepIndex} out of range`);
550
+
551
+ const now = this.clock.now().toISOString();
552
+ // Audit row first so unanimous tally sees it.
553
+ await this.engine.insert('sys_approval_action', {
554
+ id: uid('aact'),
555
+ request_id: req.id,
556
+ organization_id: (req as any).organization_id ?? null,
557
+ step_name: step.name,
558
+ step_index: stepIndex,
559
+ action: 'approve',
560
+ actor_id: input.actorId,
561
+ comment: input.comment ?? null,
562
+ created_at: now,
563
+ }, { context: SYSTEM_CTX });
564
+
565
+ // Unanimous: only advance once every original approver has approved at this step_index.
566
+ if (step.behavior === 'unanimous') {
567
+ const original = await this.expandApprovers(step, req.payload, (req as any).organization_id ?? null);
568
+ const acts = await this.engine.find('sys_approval_action', {
569
+ where: { request_id: req.id, step_index: stepIndex, action: 'approve' },
570
+ limit: 500, context: SYSTEM_CTX,
571
+ });
572
+ const approved = new Set<string>((acts ?? []).map((a: any) => String(a.actor_id ?? '')).filter(Boolean));
573
+ const stillPending = original.filter(a => !approved.has(a));
574
+ if (stillPending.length > 0) {
575
+ // Update pending_approvers to those who haven't voted yet.
576
+ await this.engine.update('sys_approval_request', {
577
+ id: req.id,
578
+ pending_approvers: stillPending.join(','),
579
+ updated_at: now,
580
+ }, { context: SYSTEM_CTX });
581
+ const fresh = await this.getRequest(req.id, context);
582
+ return { request: fresh!, finalized: false };
583
+ }
584
+ }
585
+
586
+ // Advance the request — either to next step or to finalized=approved.
587
+ if (stepIndex + 1 >= steps.length) {
588
+ await this.engine.update('sys_approval_request', {
589
+ id: req.id,
590
+ status: 'approved',
591
+ pending_approvers: null,
592
+ completed_at: now,
593
+ updated_at: now,
594
+ }, { context: SYSTEM_CTX });
595
+ const fresh = await this.getRequest(req.id, context);
596
+ // Phase B: step.onApprove + process.onFinalApprove + status mirror.
597
+ await this.runActions((step as any)?.onApprove, 'step_approve', process, fresh!, step, input.actorId, input.comment);
598
+ await this.syncStatusField(process, fresh!);
599
+ await this.runActions((process.definition as any)?.onFinalApprove, 'final_approve', process, fresh!, step, input.actorId, input.comment);
600
+ return { request: fresh!, finalized: true };
601
+ }
602
+
603
+ const nextStep = steps[stepIndex + 1];
604
+ const nextApprovers = await this.expandApprovers(nextStep, req.payload, (req as any).organization_id ?? null);
605
+ await this.engine.update('sys_approval_request', {
606
+ id: req.id,
607
+ current_step: nextStep.name,
608
+ current_step_index: stepIndex + 1,
609
+ pending_approvers: nextApprovers.join(','),
610
+ updated_at: now,
611
+ }, { context: SYSTEM_CTX });
612
+ const fresh = await this.getRequest(req.id, context);
613
+ // Phase B: step.onApprove fires when transitioning out of this step.
614
+ await this.runActions((step as any)?.onApprove, 'step_approve', process, fresh!, step, input.actorId, input.comment);
615
+ return { request: fresh!, finalized: false };
616
+ }
617
+
618
+ async reject(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise<ApprovalDecisionResult> {
619
+ const req = await this.getRequest(requestId, context);
620
+ if (!req) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
621
+ if (req.status !== 'pending') throw new Error(`INVALID_STATE: request is ${req.status}`);
622
+ if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
623
+ if (!context.isSystem && !(req.pending_approvers ?? []).includes(input.actorId)) {
624
+ throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
625
+ }
626
+
627
+ const process = await this.getProcess(req.process_name, context);
628
+ if (!process) throw new Error(`PROCESS_NOT_FOUND: ${req.process_name}`);
629
+ const steps: any[] = process.definition?.steps ?? [];
630
+ const stepIndex = req.current_step_index ?? 0;
631
+ const step = steps[stepIndex];
632
+
633
+ const now = this.clock.now().toISOString();
634
+ await this.engine.insert('sys_approval_action', {
635
+ id: uid('aact'),
636
+ request_id: req.id,
637
+ organization_id: (req as any).organization_id ?? null,
638
+ step_name: step?.name,
639
+ step_index: stepIndex,
640
+ action: 'reject',
641
+ actor_id: input.actorId,
642
+ comment: input.comment ?? null,
643
+ created_at: now,
644
+ }, { context: SYSTEM_CTX });
645
+
646
+ if (step?.rejectionBehavior === 'back_to_previous' && stepIndex > 0) {
647
+ const prev = steps[stepIndex - 1];
648
+ const prevApprovers = await this.expandApprovers(prev, req.payload, (req as any).organization_id ?? null);
649
+ await this.engine.update('sys_approval_request', {
650
+ id: req.id,
651
+ current_step: prev.name,
652
+ current_step_index: stepIndex - 1,
653
+ pending_approvers: prevApprovers.join(','),
654
+ updated_at: now,
655
+ }, { context: SYSTEM_CTX });
656
+ const fresh = await this.getRequest(req.id, context);
657
+ // Phase B: step-level onReject fires on non-final rejection too.
658
+ await this.runActions((step as any)?.onReject, 'step_reject', process, fresh!, step, input.actorId, input.comment);
659
+ return { request: fresh!, finalized: false };
660
+ }
661
+
662
+ await this.engine.update('sys_approval_request', {
663
+ id: req.id,
664
+ status: 'rejected',
665
+ pending_approvers: null,
666
+ completed_at: now,
667
+ updated_at: now,
668
+ }, { context: SYSTEM_CTX });
669
+ const fresh = await this.getRequest(req.id, context);
670
+ // Phase B: step.onReject + process.onFinalReject + status mirror.
671
+ await this.runActions((step as any)?.onReject, 'step_reject', process, fresh!, step, input.actorId, input.comment);
672
+ await this.syncStatusField(process, fresh!);
673
+ await this.runActions((process.definition as any)?.onFinalReject, 'final_reject', process, fresh!, step, input.actorId, input.comment);
674
+ return { request: fresh!, finalized: true };
675
+ }
676
+
677
+ async recall(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise<ApprovalDecisionResult> {
678
+ const req = await this.getRequest(requestId, context);
679
+ if (!req) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
680
+ if (req.status !== 'pending') throw new Error(`INVALID_STATE: request is ${req.status}`);
681
+ if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
682
+ if (!context.isSystem && req.submitter_id && req.submitter_id !== input.actorId) {
683
+ throw new Error(`FORBIDDEN: only the submitter can recall this request`);
684
+ }
685
+
686
+ const now = this.clock.now().toISOString();
687
+ await this.engine.insert('sys_approval_action', {
688
+ id: uid('aact'),
689
+ request_id: req.id,
690
+ organization_id: (req as any).organization_id ?? null,
691
+ step_name: req.current_step,
692
+ step_index: req.current_step_index,
693
+ action: 'recall',
694
+ actor_id: input.actorId,
695
+ comment: input.comment ?? null,
696
+ created_at: now,
697
+ }, { context: SYSTEM_CTX });
698
+
699
+ await this.engine.update('sys_approval_request', {
700
+ id: req.id,
701
+ status: 'recalled',
702
+ pending_approvers: null,
703
+ completed_at: now,
704
+ updated_at: now,
705
+ }, { context: SYSTEM_CTX });
706
+ const fresh = await this.getRequest(req.id, context);
707
+ // Phase B: process.onRecall + status mirror.
708
+ const process = await this.getProcess(req.process_name, context);
709
+ if (process) {
710
+ await this.syncStatusField(process, fresh!);
711
+ await this.runActions((process.definition as any)?.onRecall, 'recall', process, fresh!, undefined, input.actorId, input.comment);
712
+ }
713
+ return { request: fresh!, finalized: true };
714
+ }
715
+
716
+ async listActions(requestId: string, context: SharingExecutionContext): Promise<ApprovalActionRow[]> {
717
+ if (!requestId) return [];
718
+ // Tenant gate: ensure the caller can see the parent request before
719
+ // returning its action history. Skipping this would leak history rows
720
+ // across tenants the same way the unscoped list-requests path did.
721
+ const req = await this.getRequest(requestId, context);
722
+ if (!req) return [];
723
+ const rows = await this.engine.find('sys_approval_action', {
724
+ where: { request_id: requestId },
725
+ limit: 500,
726
+ orderBy: [{ field: 'created_at', direction: 'asc' }],
727
+ context: SYSTEM_CTX,
728
+ });
729
+ return Array.isArray(rows) ? rows.map(rowFromAction) : [];
730
+ }
731
+ }