@objectstack/plugin-approvals 16.1.0 → 17.0.0-rc.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.
@@ -1,2360 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { createHash, randomBytes } from 'node:crypto';
4
- import {
5
- APPROVAL_BRANCH_LABELS,
6
- canonicalApproverType,
7
- type ApprovalNodeConfig,
8
- } from '@objectstack/spec/automation';
9
- import type {
10
- IApprovalService,
11
- ApprovalRequestRow,
12
- ApprovalActionRow,
13
- ApprovalDecisionInput,
14
- ApprovalDecisionResult,
15
- ApprovalRecallInput,
16
- ApprovalRecallResult,
17
- ApprovalSendBackInput,
18
- ApprovalSendBackResult,
19
- ApprovalResubmitInput,
20
- ApprovalResubmitResult,
21
- ApprovalStatus,
22
- SharingExecutionContext,
23
- } from '@objectstack/spec/contracts';
24
- import { isGrantActive } from '@objectstack/core';
25
-
26
- /**
27
- * Node-era approval runtime (ADR-0019).
28
- *
29
- * Approval is no longer a standalone engine — it is a **flow node**. A flow's
30
- * Approval node opens a request via {@link ApprovalService.openNodeRequest} and
31
- * the run suspends; a human decision via {@link ApprovalService.decide}
32
- * finalises the request and resumes the owning run down the matching
33
- * `approve` / `reject` edge.
34
- *
35
- * This service owns the durable approval *state* — `sys_approval_request` /
36
- * `sys_approval_action`, approver resolution (team / department / position /
37
- * role / manager graph), and the optional status-field mirror — plus the decision
38
- * API. It does not author processes, submit, or walk multi-step machinery
39
- * anymore; that orchestration lives on the one automation engine.
40
- */
41
- export interface ApprovalEngine {
42
- find(object: string, options?: any): Promise<any[]>;
43
- insert(object: string, data: any, options?: any): Promise<any>;
44
- update(object: string, idOrData: any, dataOrOptions?: any, options?: any): Promise<any>;
45
- delete(object: string, options?: any): Promise<any>;
46
- }
47
-
48
- export interface ApprovalClock { now(): Date }
49
-
50
- /**
51
- * Minimal automation surface the service uses to resume a suspended flow run
52
- * once a decision finalises a node-driven request. Optional — attached by the
53
- * plugin when an automation engine is present (see `approval-node.ts`).
54
- */
55
- export interface ApprovalResumeSurface {
56
- resume?(runId: string, signal?: { output?: Record<string, unknown>; branchLabel?: string }): Promise<unknown>;
57
- /** Flow definition lookup, used to derive step-progress display data. */
58
- getFlow?(name: string): Promise<any | null>;
59
- /**
60
- * Terminally cancel a suspended run (ADR-0044). Used when a recall lands
61
- * during a revision window — the run is paused at the revise wait node,
62
- * which has no reject edge to resume down.
63
- */
64
- cancelRun?(runId: string, reason?: string): Promise<unknown>;
65
- }
66
-
67
- /**
68
- * Optional messaging surface (ADR-0012 `messaging` service). When attached,
69
- * thread interactions (reassign / remind / request-info / comment) notify the
70
- * affected users; without it they degrade to audit-only.
71
- */
72
- export interface ApprovalMessagingSurface {
73
- emit(input: {
74
- topic: string;
75
- audience: string[];
76
- payload?: Record<string, unknown>;
77
- severity?: string;
78
- dedupKey?: string;
79
- source?: { object: string; id: string };
80
- actorId?: string;
81
- }): Promise<unknown>;
82
- }
83
-
84
- /** Minimum time between submitter reminders on one request. */
85
- export const REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1000;
86
-
87
- /** Named job under which the SLA escalation scan is registered (ADR-0042). */
88
- export const ESCALATION_JOB_NAME = 'approvals-sla-escalation';
89
- /** Default interval between SLA escalation scans. */
90
- export const ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1000;
91
- /** Reserved actor id for machine decisions made by the SLA scanner. */
92
- export const SLA_ACTOR_ID = 'system:sla';
93
-
94
- /** Default lifetime of an actionable-link token (ADR-0043). */
95
- export const ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1000;
96
-
97
- /** Outcome of redeeming (or peeking) an actionable-link token. */
98
- export type ActionTokenOutcome =
99
- | { ok: true; action: 'approve' | 'reject'; request: ApprovalRequestRow; approverId: string }
100
- | { ok: false; reason: 'invalid' | 'expired' | 'consumed' | 'not_pending' | 'not_approver'; request?: ApprovalRequestRow };
101
-
102
- const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
103
-
104
- /**
105
- * Max hops when following an OOO delegation chain (#1322 M1): A out → B, B out
106
- * → C, … Bounds the walk so a mis-configured chain can't loop or resolve
107
- * unboundedly; a cycle or self-reference also stops it early.
108
- */
109
- const OOO_MAX_CHAIN = 8;
110
-
111
- /** One OOO delegation hop applied while resolving an approver (#1322 M1/M4). */
112
- interface OooSubstitution {
113
- /** The approver who was skipped (out of office). */
114
- from: string;
115
- /** The delegate the slot was routed to. */
116
- to: string;
117
- /** The delegator's declared reason, if any. */
118
- reason: string | null;
119
- }
120
-
121
- function uid(prefix: string): string {
122
- const g: any = globalThis as any;
123
- if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
124
- return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
125
- }
126
-
127
- function parseJson<T = any>(raw: unknown, fallback: T): T {
128
- if (raw == null || raw === '') return fallback;
129
- if (typeof raw === 'string') {
130
- try { return JSON.parse(raw) as T; } catch { return fallback; }
131
- }
132
- return raw as T;
133
- }
134
-
135
- function csvSplit(raw: unknown): string[] {
136
- if (!raw) return [];
137
- if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
138
- return String(raw).split(',').map(s => s.trim()).filter(Boolean);
139
- }
140
-
141
- /**
142
- * Humanize a machine name for display fallback: strips a `flow:` prefix and
143
- * title-cases underscore/dash segments (`flow:manager_review` → "Manager
144
- * Review"). Used only when no authored label was snapshotted on the row.
145
- */
146
- function prettifyMachineName(raw: string | null | undefined): string | undefined {
147
- if (!raw) return undefined;
148
- const base = String(raw).replace(/^flow:/, '').trim();
149
- if (!base) return undefined;
150
- return base
151
- .split(/[_\-\s]+/)
152
- .filter(Boolean)
153
- .map(w => w.charAt(0).toUpperCase() + w.slice(1))
154
- .join(' ');
155
- }
156
-
157
- function rowFromRequest(row: any): ApprovalRequestRow {
158
- // Authored display labels ride the node-config snapshot (`__flowLabel` /
159
- // `__nodeLabel`) so they survive without a schema migration; fall back to a
160
- // prettified machine name for rows written before labels were captured.
161
- const cfg = parseJson<any>(row.node_config_json, undefined);
162
- return {
163
- id: String(row.id),
164
- organization_id: row.organization_id ?? undefined,
165
- process_name: String(row.process_name ?? ''),
166
- object_name: String(row.object_name ?? ''),
167
- record_id: String(row.record_id ?? ''),
168
- submitter_id: row.submitter_id ?? undefined,
169
- submitter_comment: row.submitter_comment ?? undefined,
170
- status: (row.status as ApprovalStatus) ?? 'pending',
171
- current_step: row.current_step ?? undefined,
172
- current_step_index: row.current_step_index ?? undefined,
173
- pending_approvers: csvSplit(row.pending_approvers),
174
- payload: parseJson(row.payload_json, undefined),
175
- flow_run_id: row.flow_run_id ?? undefined,
176
- flow_node_id: row.flow_node_id ?? undefined,
177
- completed_at: row.completed_at ?? undefined,
178
- created_at: row.created_at ?? undefined,
179
- updated_at: row.updated_at ?? undefined,
180
- // The row is created at submission time; expose the stable inbox-facing name.
181
- submitted_at: row.created_at ?? undefined,
182
- process_label: cfg?.__flowLabel ?? prettifyMachineName(row.process_name),
183
- step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step),
184
- sla_due_at: slaDueAt(row.created_at, cfg),
185
- // ADR-0044 revision round (rides the config snapshot; absent ⇒ round 1).
186
- round: typeof cfg?.__round === 'number' ? cfg.__round : undefined,
187
- } as any;
188
- }
189
-
190
- /** `created_at + escalation.timeoutHours`, when the node declares an SLA. */
191
- function slaDueAt(createdAt: unknown, cfg: any): string | undefined {
192
- const hours = cfg?.escalation?.timeoutHours;
193
- if (typeof hours !== 'number' || hours <= 0 || !createdAt) return undefined;
194
- const t = Date.parse(String(createdAt));
195
- if (Number.isNaN(t)) return undefined;
196
- return new Date(t + hours * 3600_000).toISOString();
197
- }
198
-
199
- function rowFromAction(row: any): ApprovalActionRow {
200
- return {
201
- id: String(row.id),
202
- request_id: String(row.request_id),
203
- step_name: row.step_name ?? undefined,
204
- step_index: row.step_index ?? undefined,
205
- action: row.action,
206
- actor_id: row.actor_id ?? undefined,
207
- comment: row.comment ?? undefined,
208
- // Decision attachments (#3266). The column shipped in #3268 but this
209
- // contract mapping didn't — the raw engine row carried the fileIds while
210
- // every consumer of listActions saw none (caught by browser verification).
211
- attachments: Array.isArray(row.attachments) && row.attachments.length ? row.attachments.map(String) : undefined,
212
- created_at: row.created_at ?? undefined,
213
- };
214
- }
215
-
216
- export interface ApprovalServiceOptions {
217
- engine: ApprovalEngine;
218
- clock?: ApprovalClock;
219
- 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 };
220
- /**
221
- * Optional automation surface used to resume a suspended flow run when a
222
- * decision finalises a request. Usually attached after construction via
223
- * {@link ApprovalService.attachAutomation} once the automation engine is
224
- * available.
225
- */
226
- automation?: ApprovalResumeSurface;
227
- /** Optional messaging service for thread notifications. */
228
- messaging?: ApprovalMessagingSurface;
229
- /**
230
- * Absolute origin prefixed onto actionable links (ADR-0043), e.g.
231
- * `https://app.example.com`. Defaults to relative URLs, which work inside
232
- * the Console and IM webviews; outbound email needs the absolute form.
233
- */
234
- publicBaseUrl?: string;
235
- }
236
-
237
- export class ApprovalService implements IApprovalService {
238
- private readonly engine: ApprovalEngine;
239
- private readonly clock: ApprovalClock;
240
- private readonly logger?: ApprovalServiceOptions['logger'];
241
- private automation?: ApprovalResumeSurface;
242
- private messaging?: ApprovalMessagingSurface;
243
- private publicBaseUrl: string;
244
-
245
- constructor(opts: ApprovalServiceOptions) {
246
- this.engine = opts.engine;
247
- this.clock = opts.clock ?? { now: () => new Date() };
248
- this.logger = opts.logger;
249
- this.automation = opts.automation;
250
- this.messaging = opts.messaging;
251
- this.publicBaseUrl = (opts.publicBaseUrl ?? '').replace(/\/$/, '');
252
- }
253
-
254
- /** Attach (or replace) the automation surface used to resume flow runs. */
255
- attachAutomation(automation: ApprovalResumeSurface): void {
256
- this.automation = automation;
257
- }
258
-
259
- /** Attach (or replace) the messaging surface used for thread notifications. */
260
- attachMessaging(messaging: ApprovalMessagingSurface): void {
261
- this.messaging = messaging;
262
- }
263
-
264
- /** Best-effort notification fan-out — failures only log. */
265
- private async notify(input: {
266
- topic: string;
267
- audience: string[];
268
- payload?: Record<string, unknown>;
269
- dedupKey?: string;
270
- source?: { object: string; id: string };
271
- actorId?: string;
272
- }): Promise<number> {
273
- const audience = input.audience.filter(a => a && !a.includes(':'));
274
- if (!this.messaging || !audience.length) return 0;
275
- // Deep-link the inbox (#2678 P1.5): a notification about one request should
276
- // land on that request, not the bare inbox. Rewritten centrally so every
277
- // call site — and any future one — inherits it; the query param is read by
278
- // the console inbox to auto-open the drawer.
279
- let payload = input.payload;
280
- if (
281
- payload?.actionUrl === '/system/approvals'
282
- && input.source?.object === 'sys_approval_request'
283
- && input.source.id
284
- ) {
285
- payload = { ...payload, actionUrl: `/system/approvals?request=${encodeURIComponent(input.source.id)}` };
286
- }
287
- try {
288
- await this.messaging.emit({ severity: 'info', ...input, payload, audience });
289
- return audience.length;
290
- } catch (err: any) {
291
- this.logger?.warn?.('[approvals] notification failed', {
292
- topic: input.topic, error: err?.message ?? String(err),
293
- });
294
- return 0;
295
- }
296
- }
297
-
298
- /** Load a request row and assert it is still pending. */
299
- private async loadPendingRow(requestId: string): Promise<any> {
300
- if (!requestId) throw new Error('VALIDATION_FAILED: requestId is required');
301
- const rows = await this.engine.find('sys_approval_request', {
302
- where: { id: requestId }, limit: 1, context: SYSTEM_CTX,
303
- });
304
- const raw: any = Array.isArray(rows) ? rows[0] : null;
305
- if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
306
- if (raw.status !== 'pending') throw new Error(`INVALID_STATE: request is ${raw.status}`);
307
- return raw;
308
- }
309
-
310
- /**
311
- * Expand the approvers on an Approval node into user IDs by querying the
312
- * graph tables for `team:` / `department:` / `position:` /
313
- * `org_membership_level:` / `manager:` approver types. Falls back to a
314
- * prefixed literal (`type:value`) when graph lookups produce nothing — so
315
- * existing fixtures and flows that rely on substring matching keep working.
316
- *
317
- * **Graph semantics:**
318
- * - `team` → flat members of `sys_team` (better-auth; no BFS)
319
- * - `department` → recursive BFS of `sys_business_unit.parent_business_unit_id`
320
- * → members of every descendant via `sys_business_unit_member`
321
- * - `position` → holders via `sys_user_position` ∪ `sys_member.role`
322
- * transition source (ADR-0090 D3 / ADR-0057 D4)
323
- * - `org_membership_level`
324
- * → users with `sys_member.role = value` in tenant — the
325
- * better-auth MEMBERSHIP TIER (owner/admin/member), not a
326
- * position; author `position` for org positions
327
- * - `manager` → `sys_user.manager_id` of `record[value] ?? record.owner_id`
328
- * - `field` → literal user id stored in `record[value]`
329
- * - `user` → literal value
330
- *
331
- * `role` is accepted as the deprecated spelling of `org_membership_level`
332
- * (ADR-0090 D3) for one window: it resolves identically and logs a warning.
333
- *
334
- * **Out-of-office (#1322 M1):** individually-routed approvers — the ones that
335
- * resolve to a specific person (`user` / `field` / `manager`) — are passed
336
- * through {@link ApprovalService.applyOooDelegation}, which reroutes them onto
337
- * an active delegate when the resolved user has declared OOO. Group/graph
338
- * approvers (`team` / `department` / `position` / `org_membership_level`) are
339
- * left untouched: a group still has its other members, and position-routed
340
- * leave is already covered by ADR-0091 job delegation. Pass an `opts.now` /
341
- * `opts.substitutions` collector to record the hops for audit + notification.
342
- */
343
- private async expandApprovers(
344
- step: any,
345
- record?: any,
346
- organizationId?: string | null,
347
- opts?: { now?: number; substitutions?: OooSubstitution[]; groups?: Record<string, string[]> },
348
- ): Promise<string[]> {
349
- if (!step || !Array.isArray(step.approvers)) return [];
350
- const now = opts?.now ?? this.clock.now().getTime();
351
- const out: string[] = [];
352
- const specs: any[] = step.approvers;
353
- for (let idx = 0; idx < specs.length; idx++) {
354
- const a = specs[idx];
355
- if (!a) continue;
356
- const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
357
- // per_group (#3266): tag each resolved id with this spec's group. An
358
- // approver without an explicit `group` forms its own group keyed by
359
- // position, so a plain per-approver list still behaves predictably.
360
- const groupKey = a.group != null && String(a.group) !== '' ? String(a.group) : `#${idx}`;
361
- for (const u of ids) {
362
- if (!u) continue;
363
- out.push(u);
364
- if (opts?.groups) (opts.groups[u] ??= []).push(groupKey);
365
- }
366
- }
367
- return out.filter(Boolean);
368
- }
369
-
370
- /**
371
- * Resolve ONE approver spec to concrete approver identities, applying OOO
372
- * substitution (#1322) to individually-routed types. Extracted from
373
- * {@link ApprovalService.expandApprovers} so the caller can tag each spec's
374
- * resolved ids with a group (#3266) without duplicating the resolution logic.
375
- * Returns the `type:value` literal as a single-element fallback when a graph
376
- * lookup yields nothing — same behaviour as before the extraction.
377
- */
378
- private async resolveApproverSpec(
379
- a: any,
380
- record: any,
381
- organizationId: string | null | undefined,
382
- now: number,
383
- substitutions?: OooSubstitution[],
384
- ): Promise<string[]> {
385
- // ADR-0090 D3: `role` is the deprecated spelling of `org_membership_level`.
386
- // Resolve on the canonical type, but keep the AUTHORED spelling in the
387
- // `type:value` fallback below — stored `sys_approval_approver` rows and
388
- // `pending_approvers` slots from 15.x carry the old literal.
389
- const type = canonicalApproverType(String(a.type));
390
- if (type !== a.type) {
391
- this.logger?.warn?.(
392
- `[approvals] approver type '${a.type}' is deprecated (ADR-0090 D3) — author '${type}' instead`,
393
- { deprecated: a.type, canonical: type },
394
- );
395
- }
396
- if (type === 'user') {
397
- return this.applyOooDelegation(String(a.value), now, organizationId, substitutions);
398
- }
399
- if (type === 'field' && record) {
400
- return this.applyOooDelegation(String((record as any)[a.value] ?? ''), now, organizationId, substitutions);
401
- }
402
- try {
403
- if (type === 'team') {
404
- const users = await this.expandTeamUsers(String(a.value));
405
- if (users.length) return users;
406
- } else if (type === 'department' || type === 'business_unit' || type === 'bu') {
407
- const users = await this.expandBusinessUnitUsers(String(a.value), organizationId);
408
- if (users.length) return users;
409
- } else if (type === 'position') {
410
- const users = await this.expandPositionUsers(String(a.value), organizationId);
411
- if (users.length) return users;
412
- } else if (type === 'org_membership_level') {
413
- const users = await this.expandMembershipTierUsers(String(a.value), organizationId);
414
- if (users.length) return users;
415
- } else if (type === 'manager' && record) {
416
- const subject = (record as any)[a.value] ?? (record as any).owner_id;
417
- if (subject) {
418
- const mgr = await this.lookupManager(String(subject));
419
- if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
420
- }
421
- }
422
- } catch { /* fall through */ }
423
- return [`${a.type}:${a.value}`];
424
- }
425
-
426
- /** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
427
- private async expandTeamUsers(teamId: string): Promise<string[]> {
428
- if (!teamId) return [];
429
- let rows: any[] = [];
430
- try {
431
- rows = await this.engine.find('sys_team_member', {
432
- filter: { team_id: teamId },
433
- fields: ['user_id'],
434
- limit: 10000,
435
- context: SYSTEM_CTX,
436
- } as any);
437
- } catch { rows = []; }
438
- return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
439
- }
440
-
441
- /** Recursive department — walks `sys_business_unit.parent_business_unit_id`. */
442
- private async expandBusinessUnitUsers(businessUnitId: string, organizationId?: string | null): Promise<string[]> {
443
- if (!businessUnitId) return [];
444
- // Seed sanity check: skip if dept doesn't exist or is inactive within tenant.
445
- try {
446
- const seed = await this.engine.find('sys_business_unit', {
447
- filter: organizationId
448
- ? { id: businessUnitId, organization_id: organizationId }
449
- : { id: businessUnitId },
450
- fields: ['id', 'active'],
451
- limit: 1,
452
- context: SYSTEM_CTX,
453
- } as any);
454
- const seedRow: any = Array.isArray(seed) ? seed[0] : null;
455
- if (!seedRow || seedRow.active === false) return [];
456
- } catch { return []; }
457
-
458
- const seen = new Set<string>([businessUnitId]);
459
- const queue: string[] = [businessUnitId];
460
- while (queue.length) {
461
- const parent = queue.shift()!;
462
- let kids: any[] = [];
463
- try {
464
- const filter: any = { parent_business_unit_id: parent, active: { $ne: false } };
465
- if (organizationId) filter.organization_id = organizationId;
466
- kids = await this.engine.find('sys_business_unit', { filter, fields: ['id'], limit: 1000, context: SYSTEM_CTX } as any);
467
- } catch { kids = []; }
468
- for (const k of kids ?? []) {
469
- const kid = String((k as any).id ?? '');
470
- if (kid && !seen.has(kid)) { seen.add(kid); queue.push(kid); }
471
- }
472
- }
473
- let rows: any[] = [];
474
- try {
475
- rows = await this.engine.find('sys_business_unit_member', {
476
- filter: { business_unit_id: { $in: Array.from(seen) } },
477
- fields: ['user_id'],
478
- limit: 10000,
479
- context: SYSTEM_CTX,
480
- } as any);
481
- } catch { rows = []; }
482
- return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
483
- }
484
-
485
- /**
486
- * Position holders (ADR-0090 D3): `sys_user_position` is the platform-owned
487
- * assignment table, keyed by the position's machine name (ADR-0057 D4),
488
- * unioned with the better-auth membership string (`sys_member.role`) as a
489
- * transition source — the same semantics as `PositionGraphService` in
490
- * `plugin-sharing`, so an approval routes to exactly the users the sharing
491
- * engine would expand for the same position.
492
- */
493
- private async expandPositionUsers(positionName: string, organizationId?: string | null): Promise<string[]> {
494
- if (!positionName) return [];
495
- const users = new Set<string>();
496
- const filter: any = { position: positionName };
497
- if (organizationId) filter.organization_id = organizationId;
498
- try {
499
- const rows = await this.engine.find('sys_user_position', {
500
- filter, fields: ['user_id'], limit: 10000, context: SYSTEM_CTX,
501
- } as any);
502
- for (const r of (rows ?? []) as any[]) {
503
- const uid = String(r.user_id ?? '');
504
- if (uid) users.add(uid);
505
- }
506
- } catch { /* table may not exist on minimal stacks — union source below still applies */ }
507
- // ADR-0057 D4 transition source: pre-migration stacks still carry the
508
- // position name in better-auth's `sys_member.role` column, so the same
509
- // lookup serves a position name here and a membership tier for
510
- // `org_membership_level` — the column is one, the two concepts are not.
511
- for (const uid of await this.expandMembershipTierUsers(positionName, organizationId)) users.add(uid);
512
- return Array.from(users);
513
- }
514
-
515
- /**
516
- * better-auth org-membership tier (`sys_member.role`: owner/admin/member) —
517
- * NOT positions. Named for the projection (`org_membership_level`, ADR-0057
518
- * D7 / ADR-0090 D3), not for better-auth's column: the column name is theirs
519
- * and stays, the platform-facing word does not.
520
- */
521
- private async expandMembershipTierUsers(tier: string, organizationId?: string | null): Promise<string[]> {
522
- if (!tier) return [];
523
- const filter: any = { role: tier };
524
- if (organizationId) filter.organization_id = organizationId;
525
- let rows: any[] = [];
526
- try {
527
- rows = await this.engine.find('sys_member', { filter, fields: ['user_id'], limit: 10000, context: SYSTEM_CTX } as any);
528
- } catch { rows = []; }
529
- return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
530
- }
531
-
532
- private async lookupManager(userId: string): Promise<string | null> {
533
- try {
534
- const rows = await this.engine.find('sys_user', {
535
- filter: { id: userId }, fields: ['id', 'manager_id'], limit: 1, context: SYSTEM_CTX,
536
- } as any);
537
- const row: any = Array.isArray(rows) ? rows[0] : null;
538
- return row?.manager_id ? String(row.manager_id) : null;
539
- } catch { return null; }
540
- }
541
-
542
- /**
543
- * Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
544
- * id, follow any active `sys_approval_delegation` chain and return the id the
545
- * slot should actually go to — the delegate acts under their own identity, so
546
- * no impersonation is involved. Returns `[userId]` unchanged when there is no
547
- * active delegation. Each hop is appended to `collector` (when supplied) so
548
- * the caller can audit + notify (M4).
549
- *
550
- * The chain (A out → B, B out → C, …) is bounded by {@link OOO_MAX_CHAIN} and
551
- * stops on a self-reference or a cycle, so a mis-declared loop degrades to the
552
- * last reachable delegate rather than hanging.
553
- */
554
- private async applyOooDelegation(
555
- userId: string,
556
- now: number,
557
- organizationId?: string | null,
558
- collector?: OooSubstitution[],
559
- ): Promise<string[]> {
560
- const start = String(userId ?? '').trim();
561
- if (!start) return [];
562
- let current = start;
563
- const visited = new Set<string>([current]);
564
- for (let hop = 0; hop < OOO_MAX_CHAIN; hop++) {
565
- const del = await this.lookupActiveDelegation(current, now, organizationId);
566
- if (!del) break;
567
- const to = String(del.delegate_id ?? '').trim();
568
- if (!to || to === current || visited.has(to)) break; // no-op / self / cycle
569
- collector?.push({ from: current, to, reason: del.reason != null ? String(del.reason) : null });
570
- visited.add(to);
571
- current = to;
572
- }
573
- return [current];
574
- }
575
-
576
- /**
577
- * The active OOO delegation for a delegator at `now`, or null. Validity is the
578
- * shared `isGrantActive` half-open window (ADR-0091 D2), enforced here at
579
- * resolution time — never by a background job. When several rows are active,
580
- * the one expiring soonest wins (the most specific coverage window).
581
- */
582
- private async lookupActiveDelegation(
583
- delegatorId: string,
584
- now: number,
585
- organizationId?: string | null,
586
- ): Promise<any | null> {
587
- if (!delegatorId) return null;
588
- let rows: any[] = [];
589
- try {
590
- rows = await this.engine.find('sys_approval_delegation', {
591
- filter: { delegator_id: delegatorId },
592
- fields: ['id', 'delegator_id', 'delegate_id', 'valid_from', 'valid_until', 'reason', 'organization_id'],
593
- limit: 50,
594
- context: SYSTEM_CTX,
595
- } as any);
596
- } catch { return null; } // table absent on minimal stacks — no OOO, resolve as-is
597
- const active = (rows ?? []).filter((r: any) =>
598
- isGrantActive(r, now)
599
- // A null-org rule applies across tenants; a scoped rule only within its tenant.
600
- && (organizationId == null || r.organization_id == null || String(r.organization_id) === String(organizationId)));
601
- if (!active.length) return null;
602
- active.sort((a: any, b: any) => {
603
- const au = a.valid_until ? Date.parse(String(a.valid_until)) : Number.POSITIVE_INFINITY;
604
- const bu = b.valid_until ? Date.parse(String(b.valid_until)) : Number.POSITIVE_INFINITY;
605
- return au - bu;
606
- });
607
- return active[0];
608
- }
609
-
610
- /** Mirror a request status onto a business-object field, if configured. */
611
- private async mirrorStatusField(object: string, recordId: string, field: string, status: string): Promise<void> {
612
- try {
613
- await this.engine.update(object, { id: recordId, [field]: status }, { context: SYSTEM_CTX });
614
- } catch (err: any) {
615
- this.logger?.warn?.(`[approvals] mirrorStatusField failed: ${err?.message ?? err}`);
616
- }
617
- }
618
-
619
- // ── ADR-0019: Approval-as-flow-node ──────────────────────────
620
- //
621
- // A flow's Approval node opens a request via `openNodeRequest` (carrying its
622
- // own approvers/behavior config and the suspended run id), then suspends. A
623
- // later `decide` finalizes it and resumes the flow run down the matching
624
- // `approve`/`reject` edge. The record lock is enforced by a beforeUpdate hook
625
- // keyed on a *pending* request, so finalizing auto-releases it.
626
-
627
- /**
628
- * Open a pending approval request on behalf of a flow's Approval node. The
629
- * node config (approvers / behavior / status field) is snapshotted on the row
630
- * so a decision can be made without any process to resolve against.
631
- */
632
- async openNodeRequest(
633
- input: {
634
- object: string;
635
- recordId: string;
636
- runId: string;
637
- nodeId: string;
638
- config: ApprovalNodeConfig;
639
- flowName?: string;
640
- /** Authored flow label, snapshotted for inbox display. */
641
- flowLabel?: string;
642
- /** Authored node label, snapshotted for inbox display. */
643
- nodeLabel?: string;
644
- submitterId?: string | null;
645
- record?: any;
646
- organizationId?: string | null;
647
- },
648
- context: SharingExecutionContext,
649
- ): Promise<ApprovalRequestRow> {
650
- if (!input.object) throw new Error('VALIDATION_FAILED: object is required');
651
- if (!input.recordId) throw new Error('VALIDATION_FAILED: recordId is required');
652
- if (!input.runId) throw new Error('VALIDATION_FAILED: runId is required');
653
-
654
- // One pending request per (object, record).
655
- const existing = await this.engine.find('sys_approval_request', {
656
- where: { object_name: input.object, record_id: input.recordId, status: 'pending' },
657
- limit: 1, context: SYSTEM_CTX,
658
- });
659
- if (Array.isArray(existing) && existing[0]) {
660
- throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
661
- }
662
-
663
- const ctxOrg = (context as any)?.organizationId ?? (context as any)?.tenantId ?? input.organizationId ?? null;
664
- const nowDate = this.clock.now();
665
- // OOO auto-skip (#1322 M1): reroute individually-routed approvers who are
666
- // out of office. Collected hops drive the audit + notification below (M4).
667
- const substitutions: OooSubstitution[] = [];
668
- // Group membership per resolved approver (#3266) — snapshotted so quorum /
669
- // per_group finalization is decided against the slate resolved at OPEN time
670
- // (OOO-substituted), not re-resolved live at each decision.
671
- const groups: Record<string, string[]> = {};
672
- const approvers = await this.expandApprovers(
673
- { approvers: input.config.approvers }, input.record, ctxOrg, { now: nowDate.getTime(), substitutions, groups },
674
- );
675
-
676
- const now = nowDate.toISOString();
677
- const id = uid('areq');
678
- const processName = `flow:${input.flowName ?? input.nodeId}`;
679
- // Display labels ride the config snapshot (no schema migration needed);
680
- // `rowFromRequest` surfaces them as `process_label` / `step_label`.
681
- const configSnapshot: any = { ...input.config };
682
- if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
683
- if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
684
- // Snapshot the resolved approver→group map for quorum/per_group tallying.
685
- if (input.config.behavior === 'quorum' || input.config.behavior === 'per_group') {
686
- configSnapshot.__approverGroups = groups;
687
- }
688
- // ADR-0044 round numbering: rounds of a revise loop share the run — count
689
- // this (run, node)'s prior requests; the new one is round N+1. Stamped on
690
- // the snapshot (precedent: __flowLabel), so no schema migration.
691
- try {
692
- const prior = await this.engine.find('sys_approval_request', {
693
- where: { flow_run_id: input.runId, flow_node_id: input.nodeId }, limit: 500, context: SYSTEM_CTX,
694
- });
695
- const n = Array.isArray(prior) ? prior.length : 0;
696
- if (n > 0) configSnapshot.__round = n + 1;
697
- } catch { /* round display is best-effort */ }
698
- const row: any = {
699
- id,
700
- process_name: processName,
701
- object_name: input.object,
702
- record_id: input.recordId,
703
- submitter_id: input.submitterId ?? context.userId ?? null,
704
- status: 'pending',
705
- current_step: input.nodeId,
706
- current_step_index: 0,
707
- pending_approvers: approvers.join(','),
708
- payload_json: input.record != null ? JSON.stringify(input.record) : null,
709
- flow_run_id: input.runId,
710
- flow_node_id: input.nodeId,
711
- node_config_json: JSON.stringify(configSnapshot),
712
- organization_id: ctxOrg,
713
- created_at: now,
714
- updated_at: now,
715
- };
716
- await this.engine.insert('sys_approval_request', row, { context: SYSTEM_CTX });
717
- await this.syncApproverIndex(id, approvers, ctxOrg, now);
718
- await this.engine.insert('sys_approval_action', {
719
- id: uid('aact'), request_id: id, organization_id: ctxOrg,
720
- step_name: input.nodeId, step_index: 0, action: 'submit',
721
- actor_id: input.submitterId ?? context.userId ?? null, comment: null, created_at: now,
722
- }, { context: SYSTEM_CTX });
723
-
724
- // OOO substitution audit + notification (#1322 M4). Each hop that rerouted
725
- // an approver away from an out-of-office user is recorded on the request's
726
- // audit trail (a system action, no human actor) and notified to both the
727
- // delegate — who now owns the slot — and the skipped approver.
728
- for (const sub of substitutions) {
729
- await this.engine.insert('sys_approval_action', {
730
- id: uid('aact'), request_id: id, organization_id: ctxOrg,
731
- step_name: input.nodeId, step_index: 0, action: 'ooo_substitute',
732
- actor_id: null,
733
- comment: `${sub.from} → ${sub.to}${sub.reason ? ` — ${sub.reason}` : ''}`,
734
- created_at: now,
735
- }, { context: SYSTEM_CTX });
736
- await this.notify({
737
- topic: 'approval.ooo_substituted',
738
- audience: [sub.to],
739
- source: { object: 'sys_approval_request', id },
740
- dedupKey: `approval-ooo-${id}-${sub.to}`,
741
- payload: {
742
- title: 'Approval routed to you (out-of-office cover)',
743
- message: `You are covering an approval on ${input.object}/${input.recordId} while ${sub.from} is out of office.`,
744
- actionUrl: '/system/approvals',
745
- },
746
- });
747
- await this.notify({
748
- topic: 'approval.ooo_skipped',
749
- audience: [sub.from],
750
- source: { object: 'sys_approval_request', id },
751
- dedupKey: `approval-ooo-skip-${id}-${sub.from}`,
752
- payload: {
753
- title: 'Approval routed to your delegate',
754
- message: `An approval on ${input.object}/${input.recordId} was routed to ${sub.to} while you are out of office.`,
755
- actionUrl: '/system/approvals',
756
- },
757
- });
758
- }
759
-
760
- // Record lock (when `lockRecord !== false`) is enforced by the beforeUpdate
761
- // hook keyed on the now-pending request; no extra write needed here.
762
- if (input.config.approvalStatusField) {
763
- await this.mirrorStatusField(input.object, input.recordId, input.config.approvalStatusField, 'pending');
764
- }
765
-
766
- return rowFromRequest(row);
767
- }
768
-
769
- /**
770
- * True when the approve tally satisfies the node's `behavior` (#3266):
771
- * - `unanimous` — every resolved approver approved.
772
- * - `quorum` — at least `minApprovals` distinct approvals (default = all).
773
- * - `per_group` — every group reached `minApprovals` approvals (default 1).
774
- * Thresholds are clamped to the resolvable count / group size, so a mis-set
775
- * value can never deadlock a request.
776
- */
777
- private isApprovalSatisfied(
778
- behavior: string,
779
- config: ApprovalNodeConfig,
780
- original: string[],
781
- groupMap: Record<string, string[]>,
782
- approved: Set<string>,
783
- ): boolean {
784
- if (behavior === 'unanimous') {
785
- return original.length > 0 && original.every(a => approved.has(a));
786
- }
787
- if (behavior === 'quorum') {
788
- const n = original.length || 1;
789
- const need = Math.min(Math.max(1, config.minApprovals ?? n), n);
790
- // Count distinct approvals (robust to OOO/reassign changing who holds a slot).
791
- return approved.size >= need;
792
- }
793
- if (behavior === 'per_group') {
794
- const perGroupNeed = Math.max(1, config.minApprovals ?? 1);
795
- const size: Record<string, number> = {};
796
- for (const gs of Object.values(groupMap)) for (const g of gs) size[g] = (size[g] ?? 0) + 1;
797
- const groups = Object.keys(size);
798
- if (!groups.length) return true; // nothing to gate
799
- const got: Record<string, number> = {};
800
- for (const a of approved) for (const g of (groupMap[a] ?? [])) got[g] = (got[g] ?? 0) + 1;
801
- return groups.every(g => (got[g] ?? 0) >= Math.min(perGroupNeed, size[g]));
802
- }
803
- return true; // first_response and unknown → first approval finalizes
804
- }
805
-
806
- /**
807
- * Record a decision on a node-driven request. Honours the node's `behavior`
808
- * (#3266): `first_response` finalizes on the first approval; `unanimous`,
809
- * `quorum`, and `per_group` hold the request open until their tally is met
810
- * (see {@link ApprovalService.isApprovalSatisfied}). A rejection always
811
- * finalizes the node (one veto). When the request finalizes, returns the
812
- * suspended run id + node id so the caller (or {@link ApprovalService.decide})
813
- * can resume the flow down the matching branch.
814
- */
815
- async decideNode(
816
- requestId: string,
817
- input: { decision: 'approve' | 'reject'; actorId: string; comment?: string; attachments?: string[] },
818
- context: SharingExecutionContext,
819
- ): Promise<{ request: ApprovalRequestRow; runId: string | null; nodeId: string | null; finalized: boolean; decision: 'approve' | 'reject' }> {
820
- if (!requestId) throw new Error('VALIDATION_FAILED: requestId is required');
821
- if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
822
- if (input.decision !== 'approve' && input.decision !== 'reject') {
823
- throw new Error('VALIDATION_FAILED: decision must be approve|reject');
824
- }
825
-
826
- // Read the raw row to reach flow_* correlation + the node config snapshot.
827
- const rawRows = await this.engine.find('sys_approval_request', {
828
- where: { id: requestId }, limit: 1, context: SYSTEM_CTX,
829
- });
830
- const raw: any = Array.isArray(rawRows) ? rawRows[0] : null;
831
- if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
832
- if (raw.status !== 'pending') throw new Error(`INVALID_STATE: request is ${raw.status}`);
833
-
834
- const pendingApprovers = csvSplit(raw.pending_approvers);
835
- if (!context.isSystem && !pendingApprovers.includes(input.actorId)) {
836
- throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
837
- }
838
-
839
- const config = parseJson<ApprovalNodeConfig>(raw.node_config_json, { approvers: [], behavior: 'first_response' } as any);
840
- const org = raw.organization_id ?? null;
841
- const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null;
842
- const runId: string | null = raw.flow_run_id ?? null;
843
- const now = this.clock.now().toISOString();
844
-
845
- // Audit the decision first so the quorum/per_group tally below sees it.
846
- await this.engine.insert('sys_approval_action', {
847
- id: uid('aact'), request_id: requestId, organization_id: org,
848
- step_name: nodeId, step_index: 0, action: input.decision,
849
- actor_id: input.actorId, comment: input.comment ?? null,
850
- attachments: input.attachments?.length ? input.attachments : null,
851
- created_at: now,
852
- }, { context: SYSTEM_CTX });
853
-
854
- // Multi-approver aggregation on approve (#3266). A rejection always
855
- // finalizes the node (one veto), so only the approve path can hold it open.
856
- // `first_response` finalizes on the first approval (falls straight through).
857
- const behavior = config.behavior ?? 'first_response';
858
- if (input.decision === 'approve' && behavior !== 'first_response') {
859
- const acts = await this.engine.find('sys_approval_action', {
860
- where: { request_id: requestId, step_index: 0, action: 'approve' }, limit: 1000, context: SYSTEM_CTX,
861
- });
862
- const approved = new Set<string>((acts ?? []).map((a: any) => String(a.actor_id ?? '')).filter(Boolean));
863
-
864
- // quorum / per_group tally against the OPEN-time snapshot (already
865
- // OOO-substituted). unanimous re-resolves for back-compat with requests
866
- // opened before the snapshot existed.
867
- const snapshotGroups = (config as any).__approverGroups as Record<string, string[]> | undefined;
868
- let original: string[];
869
- let groupMap: Record<string, string[]>;
870
- if (snapshotGroups && (behavior === 'quorum' || behavior === 'per_group')) {
871
- groupMap = snapshotGroups;
872
- original = Object.keys(snapshotGroups);
873
- } else {
874
- original = await this.expandApprovers(
875
- { approvers: config.approvers }, parseJson(raw.payload_json, undefined), org,
876
- );
877
- groupMap = {};
878
- }
879
-
880
- if (!this.isApprovalSatisfied(behavior, config, original, groupMap, approved)) {
881
- const stillPending = original.filter(a => !approved.has(a));
882
- await this.engine.update('sys_approval_request', {
883
- id: requestId, pending_approvers: stillPending.join(','), updated_at: now,
884
- }, { context: SYSTEM_CTX });
885
- await this.syncApproverIndex(requestId, stillPending, org, now);
886
- const fresh = await this.getRequest(requestId, context);
887
- return { request: fresh!, runId, nodeId, finalized: false, decision: input.decision };
888
- }
889
- }
890
-
891
- const finalStatus = input.decision === 'approve' ? 'approved' : 'rejected';
892
- await this.engine.update('sys_approval_request', {
893
- id: requestId, status: finalStatus, pending_approvers: null, completed_at: now, updated_at: now,
894
- }, { context: SYSTEM_CTX });
895
- await this.syncApproverIndex(requestId, [], org, now);
896
- if (config.approvalStatusField) {
897
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, finalStatus);
898
- }
899
- const fresh = await this.getRequest(requestId, context);
900
- return { request: fresh!, runId, nodeId, finalized: true, decision: input.decision };
901
- }
902
-
903
- /**
904
- * Public contract entrypoint (ADR-0019). Records a decision on a node-driven
905
- * request via {@link ApprovalService.decideNode} and, when it finalizes,
906
- * resumes the owning flow run down the matching `approve` / `reject` edge.
907
- */
908
- async decide(
909
- requestId: string,
910
- input: ApprovalDecisionInput,
911
- context: SharingExecutionContext,
912
- ): Promise<ApprovalDecisionResult> {
913
- const result = await this.decideNode(requestId, input, context);
914
-
915
- let resumed = false;
916
- if (result.finalized && result.runId && typeof this.automation?.resume === 'function') {
917
- const branchLabel = result.decision === 'approve'
918
- ? APPROVAL_BRANCH_LABELS.approve
919
- : APPROVAL_BRANCH_LABELS.reject;
920
- try {
921
- await this.automation.resume(result.runId, {
922
- branchLabel,
923
- output: { decision: result.decision, requestId },
924
- });
925
- resumed = true;
926
- } catch (err: any) {
927
- this.logger?.warn?.('[approvals] resume after decision failed', {
928
- request: requestId, run: result.runId, error: err?.message ?? String(err),
929
- });
930
- }
931
- }
932
-
933
- return {
934
- request: result.request,
935
- finalized: result.finalized,
936
- decision: result.decision,
937
- runId: result.runId,
938
- resumed,
939
- };
940
- }
941
-
942
- /**
943
- * Withdraw a pending request (submitter only). Finalises the row as
944
- * `recalled`, releases the record lock (keyed on pending status), mirrors
945
- * the status field when configured, and resumes the owning flow run down
946
- * the `reject` branch with `output.decision = 'recall'` — leaving the run
947
- * suspended forever would leak it.
948
- *
949
- * ADR-0044: also valid on the LATEST `returned` request of its run — the
950
- * submitter abandons the revision window instead of resubmitting. The run
951
- * is then paused at the revise wait node (no reject edge), so it is
952
- * terminally cancelled via {@link ApprovalResumeSurface.cancelRun} rather
953
- * than resumed.
954
- */
955
- async recall(
956
- requestId: string,
957
- input: ApprovalRecallInput,
958
- context: SharingExecutionContext,
959
- ): Promise<ApprovalRecallResult> {
960
- if (!requestId) throw new Error('VALIDATION_FAILED: requestId is required');
961
- if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
962
-
963
- const rawRows = await this.engine.find('sys_approval_request', {
964
- where: { id: requestId }, limit: 1, context: SYSTEM_CTX,
965
- });
966
- const raw: any = Array.isArray(rawRows) ? rawRows[0] : null;
967
- if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
968
- const inReviseWindow = raw.status === 'returned';
969
- if (raw.status !== 'pending' && !inReviseWindow) {
970
- throw new Error(`INVALID_STATE: request is ${raw.status}`);
971
- }
972
- if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
973
- throw new Error(`FORBIDDEN: only the submitter may recall this request`);
974
- }
975
- // A returned request is only recallable while it is still the run's live
976
- // frontier — a resubmitted (or later-node) request supersedes it.
977
- if (inReviseWindow) await this.assertLatestForRun(raw);
978
-
979
- const config = parseJson<ApprovalNodeConfig>(raw.node_config_json, { approvers: [], behavior: 'first_response' } as any);
980
- const org = raw.organization_id ?? null;
981
- const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null;
982
- const runId: string | null = raw.flow_run_id ?? null;
983
- const now = this.clock.now().toISOString();
984
-
985
- await this.engine.insert('sys_approval_action', {
986
- id: uid('aact'), request_id: requestId, organization_id: org,
987
- step_name: nodeId, step_index: 0, action: 'recall',
988
- actor_id: input.actorId, comment: input.comment ?? null, created_at: now,
989
- }, { context: SYSTEM_CTX });
990
-
991
- await this.engine.update('sys_approval_request', {
992
- id: requestId, status: 'recalled', pending_approvers: null, completed_at: now, updated_at: now,
993
- }, { context: SYSTEM_CTX });
994
- await this.syncApproverIndex(requestId, [], org, now);
995
- if (config.approvalStatusField) {
996
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, 'recalled');
997
- }
998
-
999
- let resumed = false;
1000
- if (inReviseWindow) {
1001
- // ADR-0044: the run is paused at the revise wait node, which has no
1002
- // reject out-edge to resume down — terminally cancel it instead.
1003
- if (runId && typeof this.automation?.cancelRun === 'function') {
1004
- try {
1005
- await this.automation.cancelRun(runId, `approval request ${requestId} recalled during revision`);
1006
- } catch (err: any) {
1007
- this.logger?.warn?.('[approvals] cancelRun after revise-window recall failed', {
1008
- request: requestId, run: runId, error: err?.message ?? String(err),
1009
- });
1010
- }
1011
- }
1012
- } else if (runId && typeof this.automation?.resume === 'function') {
1013
- try {
1014
- await this.automation.resume(runId, {
1015
- branchLabel: APPROVAL_BRANCH_LABELS.reject,
1016
- output: { decision: 'recall', requestId },
1017
- });
1018
- resumed = true;
1019
- } catch (err: any) {
1020
- this.logger?.warn?.('[approvals] resume after recall failed', {
1021
- request: requestId, run: runId, error: err?.message ?? String(err),
1022
- });
1023
- }
1024
- }
1025
-
1026
- const fresh = await this.getRequest(requestId, context);
1027
- return { request: fresh!, runId, resumed };
1028
- }
1029
-
1030
- // ── Send back for revision / resubmit (ADR-0044) ─────────────
1031
-
1032
- /**
1033
- * ADR-0044 send back for revision. Finalises the pending request as
1034
- * `returned` (a third terminal state — approver-initiated rework, distinct
1035
- * from submitter-initiated `recalled`) and resumes the owning flow run down
1036
- * its `revise` edge to a wait point: the record lock (keyed on `pending`)
1037
- * releases, the submitter reworks the data, then {@link resubmit}s.
1038
- *
1039
- * Requires the approval node to declare a `revise` out-edge — validated
1040
- * BEFORE any mutation, because resuming with an unmatched `branchLabel`
1041
- * falls back to *all* out-edges. Past the node's `maxRevisions` budget the
1042
- * request auto-rejects instead (resumes down `reject` with
1043
- * `output.autoRejected = true`) so instances cannot orbit forever.
1044
- */
1045
- async sendBack(
1046
- requestId: string,
1047
- input: ApprovalSendBackInput,
1048
- context: SharingExecutionContext,
1049
- ): Promise<ApprovalSendBackResult> {
1050
- if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
1051
- const raw = await this.loadPendingRow(requestId);
1052
- const pending = csvSplit(raw.pending_approvers);
1053
- if (!context.isSystem && !pending.includes(input.actorId)) {
1054
- throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
1055
- }
1056
-
1057
- const config = parseJson<ApprovalNodeConfig>(raw.node_config_json, { approvers: [], behavior: 'first_response' } as any);
1058
- const org = raw.organization_id ?? null;
1059
- const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null;
1060
- const runId: string | null = raw.flow_run_id ?? null;
1061
-
1062
- await this.assertReviseEdge(raw, nodeId);
1063
-
1064
- const now = this.clock.now().toISOString();
1065
- const maxRevisions = typeof (config as any).maxRevisions === 'number' ? (config as any).maxRevisions : 3;
1066
- let priorSendBacks = 0;
1067
- if (runId && nodeId) {
1068
- const siblings = await this.engine.find('sys_approval_request', {
1069
- where: { flow_run_id: runId, flow_node_id: nodeId, status: 'returned' }, limit: 500, context: SYSTEM_CTX,
1070
- });
1071
- priorSendBacks = Array.isArray(siblings) ? siblings.length : 0;
1072
- }
1073
-
1074
- // Audit the revise intent first (audit-first, like decideNode) — on the
1075
- // auto-reject path the trail then reads `revise → reject`, preserving
1076
- // what the approver actually asked for.
1077
- await this.engine.insert('sys_approval_action', {
1078
- id: uid('aact'), request_id: requestId, organization_id: org,
1079
- step_name: nodeId, step_index: 0, action: 'revise',
1080
- actor_id: input.actorId, comment: input.comment ?? null, created_at: now,
1081
- }, { context: SYSTEM_CTX });
1082
-
1083
- if (priorSendBacks >= maxRevisions) {
1084
- // Revision budget exhausted — auto-reject (ADR-0044 loop guard).
1085
- await this.engine.insert('sys_approval_action', {
1086
- id: uid('aact'), request_id: requestId, organization_id: org,
1087
- step_name: nodeId, step_index: 0, action: 'reject',
1088
- actor_id: input.actorId,
1089
- comment: `Auto-rejected: revision limit (${maxRevisions}) exceeded`, created_at: now,
1090
- }, { context: SYSTEM_CTX });
1091
- await this.engine.update('sys_approval_request', {
1092
- id: requestId, status: 'rejected', pending_approvers: null, completed_at: now, updated_at: now,
1093
- }, { context: SYSTEM_CTX });
1094
- await this.syncApproverIndex(requestId, [], org, now);
1095
- if (config.approvalStatusField) {
1096
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, 'rejected');
1097
- }
1098
- let resumed = false;
1099
- if (runId && typeof this.automation?.resume === 'function') {
1100
- try {
1101
- await this.automation.resume(runId, {
1102
- branchLabel: APPROVAL_BRANCH_LABELS.reject,
1103
- output: { decision: 'reject', autoRejected: true, requestId },
1104
- });
1105
- resumed = true;
1106
- } catch (err: any) {
1107
- this.logger?.warn?.('[approvals] resume after auto-reject failed', {
1108
- request: requestId, run: runId, error: err?.message ?? String(err),
1109
- });
1110
- }
1111
- }
1112
- if (raw.submitter_id) {
1113
- await this.notify({
1114
- topic: 'approval.returned',
1115
- audience: [String(raw.submitter_id)],
1116
- actorId: input.actorId,
1117
- source: { object: 'sys_approval_request', id: requestId },
1118
- payload: {
1119
- title: 'Approval auto-rejected',
1120
- message: `Your ${raw.object_name}/${raw.record_id} exceeded the revision limit (${maxRevisions}) and was rejected.`,
1121
- actionUrl: '/system/approvals',
1122
- },
1123
- });
1124
- }
1125
- const fresh = await this.getRequest(requestId, context);
1126
- return { request: fresh!, runId, resumed, autoRejected: true };
1127
- }
1128
-
1129
- await this.engine.update('sys_approval_request', {
1130
- id: requestId, status: 'returned', pending_approvers: null, completed_at: now, updated_at: now,
1131
- }, { context: SYSTEM_CTX });
1132
- await this.syncApproverIndex(requestId, [], org, now);
1133
- if (config.approvalStatusField) {
1134
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, 'returned');
1135
- }
1136
-
1137
- let resumed = false;
1138
- if (runId && typeof this.automation?.resume === 'function') {
1139
- try {
1140
- await this.automation.resume(runId, {
1141
- branchLabel: APPROVAL_BRANCH_LABELS.revise,
1142
- output: { decision: 'revise', requestId },
1143
- });
1144
- resumed = true;
1145
- } catch (err: any) {
1146
- this.logger?.warn?.('[approvals] resume after send-back failed', {
1147
- request: requestId, run: runId, error: err?.message ?? String(err),
1148
- });
1149
- }
1150
- }
1151
-
1152
- if (raw.submitter_id) {
1153
- await this.notify({
1154
- topic: 'approval.returned',
1155
- audience: [String(raw.submitter_id)],
1156
- actorId: input.actorId,
1157
- source: { object: 'sys_approval_request', id: requestId },
1158
- payload: {
1159
- title: 'Sent back for revision',
1160
- message: input.comment?.trim() || `Your ${raw.object_name}/${raw.record_id} needs rework before it can be approved.`,
1161
- actionUrl: '/system/approvals',
1162
- },
1163
- });
1164
- }
1165
-
1166
- const fresh = await this.getRequest(requestId, context);
1167
- return { request: fresh!, runId, resumed };
1168
- }
1169
-
1170
- /**
1171
- * ADR-0044 resubmit after rework. Valid on the LATEST `returned` request of
1172
- * its run, submitter-only. Audits `resubmit` on the returned (round-N)
1173
- * request and resumes the run from the revise wait node; traversal walks
1174
- * the declared back-edge into the approval node, whose executor opens the
1175
- * round-N+1 request — fresh approver slate, record re-locks.
1176
- */
1177
- async resubmit(
1178
- requestId: string,
1179
- input: ApprovalResubmitInput,
1180
- context: SharingExecutionContext,
1181
- ): Promise<ApprovalResubmitResult> {
1182
- if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
1183
- const rawRows = await this.engine.find('sys_approval_request', {
1184
- where: { id: requestId }, limit: 1, context: SYSTEM_CTX,
1185
- });
1186
- const raw: any = Array.isArray(rawRows) ? rawRows[0] : null;
1187
- if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
1188
- if (raw.status !== 'returned') {
1189
- throw new Error(`INVALID_STATE: request is ${raw.status} (resubmit applies to returned requests)`);
1190
- }
1191
- if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
1192
- throw new Error('FORBIDDEN: only the submitter may resubmit');
1193
- }
1194
- await this.assertLatestForRun(raw);
1195
-
1196
- // A colliding pending request on the same record (e.g. a record-change
1197
- // trigger re-fired off an edit made inside the revise window) would make
1198
- // the approval node's re-entry fail AFTER the engine consumed the
1199
- // suspension — permanently killing the run. Refuse up front instead; the
1200
- // submitter resolves the collision (recall the other request) first.
1201
- const colliding = await this.engine.find('sys_approval_request', {
1202
- where: { object_name: raw.object_name, record_id: raw.record_id, status: 'pending' },
1203
- limit: 1, context: SYSTEM_CTX,
1204
- });
1205
- if (Array.isArray(colliding) && colliding[0]) {
1206
- throw new Error(
1207
- `DUPLICATE_REQUEST: another approval request is already pending on ${raw.object_name}/${raw.record_id} — resolve it before resubmitting`,
1208
- );
1209
- }
1210
-
1211
- const org = raw.organization_id ?? null;
1212
- const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null;
1213
- const runId: string | null = raw.flow_run_id ?? null;
1214
- const now = this.clock.now().toISOString();
1215
-
1216
- await this.engine.insert('sys_approval_action', {
1217
- id: uid('aact'), request_id: requestId, organization_id: org,
1218
- step_name: nodeId, step_index: 0, action: 'resubmit',
1219
- actor_id: input.actorId, comment: input.comment ?? null, created_at: now,
1220
- }, { context: SYSTEM_CTX });
1221
-
1222
- // The next round only exists if this resume lands — surface `resumed`
1223
- // honestly so a stuck run is visible instead of silently swallowed.
1224
- let resumed = false;
1225
- if (runId && typeof this.automation?.resume === 'function') {
1226
- try {
1227
- await this.automation.resume(runId, {
1228
- branchLabel: APPROVAL_BRANCH_LABELS.resubmit,
1229
- output: { resubmitted: true, requestId },
1230
- });
1231
- resumed = true;
1232
- } catch (err: any) {
1233
- this.logger?.warn?.('[approvals] resume after resubmit failed', {
1234
- request: requestId, run: runId, error: err?.message ?? String(err),
1235
- });
1236
- }
1237
- }
1238
-
1239
- const fresh = await this.getRequest(requestId, context);
1240
- return { request: fresh!, runId, resumed };
1241
- }
1242
-
1243
- /**
1244
- * ADR-0044 guard: the flow's approval node must declare a `revise`
1245
- * out-edge before send-back is allowed — the engine's branch-label fallback
1246
- * (no matching label ⇒ ALL out-edges) must never be reachable from a user
1247
- * action.
1248
- */
1249
- private async assertReviseEdge(raw: any, nodeId: string | null): Promise<void> {
1250
- const processName = String(raw.process_name ?? '');
1251
- const flowName = processName.startsWith('flow:') ? processName.slice('flow:'.length) : undefined;
1252
- if (!flowName || !nodeId || typeof this.automation?.getFlow !== 'function') {
1253
- throw new Error('VALIDATION_FAILED: send-back requires the owning flow definition (automation engine unavailable)');
1254
- }
1255
- const flow: any = await this.automation.getFlow(flowName);
1256
- const hasRevise = Array.isArray(flow?.edges)
1257
- && flow.edges.some((e: any) => e?.source === nodeId && e?.label === APPROVAL_BRANCH_LABELS.revise);
1258
- if (!hasRevise) {
1259
- throw new Error(
1260
- `VALIDATION_FAILED: approval node '${nodeId}' has no '${APPROVAL_BRANCH_LABELS.revise}' out-edge — ` +
1261
- 'the flow does not support send-back for revision',
1262
- );
1263
- }
1264
- }
1265
-
1266
- /**
1267
- * ADR-0044 guard: a `returned` request is only actionable (resubmit /
1268
- * recall) while it is still the newest request on its run — a later round
1269
- * or a later node's request supersedes it.
1270
- */
1271
- private async assertLatestForRun(raw: any): Promise<void> {
1272
- const runId = raw.flow_run_id;
1273
- if (!runId) return;
1274
- // SortNode's key is `order` (spec/data/query.zod.ts) — `direction` would
1275
- // silently default to ascending and return the OLDEST row.
1276
- const rows = await this.engine.find('sys_approval_request', {
1277
- where: { flow_run_id: runId },
1278
- orderBy: [{ field: 'created_at', order: 'desc' }], limit: 1, context: SYSTEM_CTX,
1279
- });
1280
- const latest: any = Array.isArray(rows) ? rows[0] : null;
1281
- if (latest && String(latest.id) !== String(raw.id)) {
1282
- throw new Error('INVALID_STATE: a newer approval request supersedes this one');
1283
- }
1284
- }
1285
-
1286
- // ── Thread interactions (no flow movement) ───────────────────
1287
-
1288
- /**
1289
- * Hand a pending-approver slot to someone else. `from` defaults to the
1290
- * actor itself; the actor must hold the slot being handed over (or be a
1291
- * system caller). Audits `reassign` and notifies the new approver.
1292
- */
1293
- async reassign(
1294
- requestId: string,
1295
- input: { actorId: string; to: string; from?: string; comment?: string },
1296
- context: SharingExecutionContext,
1297
- ): Promise<{ request: ApprovalRequestRow }> {
1298
- if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
1299
- const to = String(input?.to ?? '').trim();
1300
- if (!to) throw new Error('VALIDATION_FAILED: `to` (new approver) is required');
1301
- const raw = await this.loadPendingRow(requestId);
1302
-
1303
- const pending = csvSplit(raw.pending_approvers);
1304
- const from = String(input.from ?? input.actorId).trim();
1305
- if (!pending.includes(from)) {
1306
- throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
1307
- }
1308
- if (!context.isSystem && input.actorId !== from && !pending.includes(input.actorId)) {
1309
- throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
1310
- }
1311
- if (pending.includes(to)) {
1312
- throw new Error(`VALIDATION_FAILED: '${to}' is already a pending approver`);
1313
- }
1314
-
1315
- const next = pending.map(a => (a === from ? to : a));
1316
- const now = this.clock.now().toISOString();
1317
- // Audit first, then mutate — mirrors decideNode(), so a failed audit
1318
- // write can never leave a moved slot without a trail.
1319
- await this.engine.insert('sys_approval_action', {
1320
- id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
1321
- step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'reassign',
1322
- actor_id: input.actorId, comment: input.comment ?? `${from} → ${to}`, created_at: now,
1323
- }, { context: SYSTEM_CTX });
1324
- // per_group / quorum (#3266): carry the delegated slot's group membership to
1325
- // the new approver in the snapshot, so their approval still counts for the
1326
- // original group.
1327
- let configPatch: Record<string, unknown> = {};
1328
- try {
1329
- const cfg = parseJson<any>(raw.node_config_json, null);
1330
- const groups = cfg?.__approverGroups as Record<string, string[]> | undefined;
1331
- if (groups && groups[from] && !groups[to]) {
1332
- groups[to] = groups[from];
1333
- delete groups[from];
1334
- configPatch = { node_config_json: JSON.stringify(cfg) };
1335
- }
1336
- } catch { /* snapshot left untouched on parse failure */ }
1337
- await this.engine.update('sys_approval_request', {
1338
- id: requestId, pending_approvers: next.join(','), updated_at: now, ...configPatch,
1339
- }, { context: SYSTEM_CTX });
1340
- await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
1341
-
1342
- await this.notify({
1343
- topic: 'approval.reassigned',
1344
- audience: [to],
1345
- actorId: input.actorId,
1346
- source: { object: 'sys_approval_request', id: requestId },
1347
- dedupKey: `approval-reassign-${requestId}-${to}`,
1348
- payload: {
1349
- title: 'Approval handed to you',
1350
- message: `You are now an approver on ${raw.object_name}/${raw.record_id}.`,
1351
- actionUrl: '/system/approvals',
1352
- },
1353
- });
1354
-
1355
- const fresh = await this.getRequest(requestId, context);
1356
- return { request: fresh! };
1357
- }
1358
-
1359
- /**
1360
- * Submitter nudge — notify every pending approver. Throttled to one
1361
- * reminder per {@link REMIND_COOLDOWN_MS} per request.
1362
- */
1363
- async remind(
1364
- requestId: string,
1365
- input: { actorId: string; comment?: string },
1366
- context: SharingExecutionContext,
1367
- ): Promise<{ request: ApprovalRequestRow; notified: number }> {
1368
- if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
1369
- const raw = await this.loadPendingRow(requestId);
1370
- if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
1371
- throw new Error('FORBIDDEN: only the submitter may send reminders');
1372
- }
1373
-
1374
- const acts = await this.engine.find('sys_approval_action', {
1375
- where: { request_id: requestId, action: 'remind' },
1376
- orderBy: [{ field: 'created_at', order: 'desc' }], limit: 1, context: SYSTEM_CTX,
1377
- });
1378
- const last: any = Array.isArray(acts) ? acts[0] : null;
1379
- const now = this.clock.now();
1380
- if (last?.created_at && now.getTime() - Date.parse(last.created_at) < REMIND_COOLDOWN_MS) {
1381
- throw new Error('THROTTLED: a reminder was already sent recently');
1382
- }
1383
-
1384
- const pending = csvSplit(raw.pending_approvers);
1385
- const nowIso = now.toISOString();
1386
- await this.engine.insert('sys_approval_action', {
1387
- id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
1388
- step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'remind',
1389
- actor_id: input.actorId, comment: input.comment ?? null, created_at: nowIso,
1390
- }, { context: SYSTEM_CTX });
1391
-
1392
- // Per-approver fan-out: concrete identities (user ids / emails) each get
1393
- // their OWN one-tap approve/reject links (ADR-0043); `role:*`-style
1394
- // literals can't carry a personal token and fall back to a plain nudge.
1395
- let notified = 0;
1396
- const concrete = pending.filter(a => a && !a.includes(':'));
1397
- const literals = pending.filter(a => a && a.includes(':'));
1398
- for (const approver of concrete) {
1399
- try {
1400
- const tokens = await this.issueActionTokens(requestId, approver);
1401
- notified += await this.notify({
1402
- topic: 'approval.reminder',
1403
- audience: [approver],
1404
- actorId: input.actorId,
1405
- source: { object: 'sys_approval_request', id: requestId },
1406
- dedupKey: `approval-remind-${requestId}-${nowIso}-${approver}`,
1407
- payload: {
1408
- title: 'Approval reminder',
1409
- message: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`,
1410
- actionUrl: '/system/approvals',
1411
- actions: [
1412
- { label: 'Approve', url: this.actionLinkUrl(tokens.approve) },
1413
- { label: 'Reject', url: this.actionLinkUrl(tokens.reject) },
1414
- ],
1415
- },
1416
- });
1417
- } catch (err: any) {
1418
- this.logger?.warn?.('[approvals] reminder with action links failed', {
1419
- request: requestId, approver, error: err?.message ?? String(err),
1420
- });
1421
- }
1422
- }
1423
- if (literals.length) {
1424
- notified += await this.notify({
1425
- topic: 'approval.reminder',
1426
- audience: literals,
1427
- actorId: input.actorId,
1428
- source: { object: 'sys_approval_request', id: requestId },
1429
- dedupKey: `approval-remind-${requestId}-${nowIso}`,
1430
- payload: {
1431
- title: 'Approval reminder',
1432
- message: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`,
1433
- actionUrl: '/system/approvals',
1434
- },
1435
- });
1436
- }
1437
-
1438
- const fresh = await this.getRequest(requestId, context);
1439
- return { request: fresh!, notified };
1440
- }
1441
-
1442
- // ── Actionable links (ADR-0043) ──────────────────────────────
1443
-
1444
- /** Build the session-less confirm-page URL for a raw token. */
1445
- actionLinkUrl(rawToken: string): string {
1446
- return `${this.publicBaseUrl}/api/v1/approvals/act?token=${encodeURIComponent(rawToken)}`;
1447
- }
1448
-
1449
- /**
1450
- * Issue one-tap approve/reject tokens for one approver on one pending
1451
- * request. Raw tokens are returned ONCE; only SHA-256 hashes are stored
1452
- * (`sys_approval_token`), so a DB leak yields no usable links.
1453
- */
1454
- async issueActionTokens(
1455
- requestId: string,
1456
- approverId: string,
1457
- opts?: { ttlMs?: number },
1458
- ): Promise<{ approve: string; reject: string }> {
1459
- if (!approverId?.trim()) throw new Error('VALIDATION_FAILED: approverId is required');
1460
- const raw = await this.loadPendingRow(requestId);
1461
- const pending = csvSplit(raw.pending_approvers);
1462
- if (!pending.includes(approverId)) {
1463
- throw new Error(`FORBIDDEN: '${approverId}' is not a pending approver on this request`);
1464
- }
1465
- const now = this.clock.now();
1466
- const expires = new Date(now.getTime() + (opts?.ttlMs ?? ACTION_TOKEN_TTL_MS)).toISOString();
1467
- const out = { approve: '', reject: '' };
1468
- for (const action of ['approve', 'reject'] as const) {
1469
- const rawToken = randomBytes(32).toString('base64url');
1470
- await this.engine.insert('sys_approval_token', {
1471
- id: uid('atok'),
1472
- organization_id: raw.organization_id ?? null,
1473
- token_hash: createHash('sha256').update(rawToken).digest('hex'),
1474
- request_id: requestId,
1475
- action,
1476
- approver_id: approverId,
1477
- expires_at: expires,
1478
- consumed_at: null,
1479
- created_at: now.toISOString(),
1480
- }, { context: SYSTEM_CTX });
1481
- out[action] = rawToken;
1482
- }
1483
- return out;
1484
- }
1485
-
1486
- /** Shared validation chain for peek/redeem. Returns the token row when live. */
1487
- private async resolveActionToken(rawToken: string): Promise<
1488
- { ok: true; token: any; request: ApprovalRequestRow } | Extract<ActionTokenOutcome, { ok: false }>
1489
- > {
1490
- const trimmed = rawToken?.trim();
1491
- if (!trimmed) return { ok: false, reason: 'invalid' };
1492
- const hash = createHash('sha256').update(trimmed).digest('hex');
1493
- const rows = await this.engine.find('sys_approval_token', {
1494
- where: { token_hash: hash }, limit: 1, context: SYSTEM_CTX,
1495
- });
1496
- const token: any = Array.isArray(rows) ? rows[0] : null;
1497
- if (!token) return { ok: false, reason: 'invalid' };
1498
- if (token.consumed_at) return { ok: false, reason: 'consumed' };
1499
- if (Date.parse(token.expires_at) < this.clock.now().getTime()) {
1500
- return { ok: false, reason: 'expired' };
1501
- }
1502
- const request = await this.getRequest(token.request_id, SYSTEM_CTX as unknown as SharingExecutionContext);
1503
- if (!request || request.status !== 'pending') {
1504
- return { ok: false, reason: 'not_pending', request: request ?? undefined };
1505
- }
1506
- if (!(request.pending_approvers ?? []).includes(token.approver_id)) {
1507
- // Reassigned away / slot consumed by a unanimous round — the link died
1508
- // with the slot (ADR-0043 invalidation row).
1509
- return { ok: false, reason: 'not_approver', request };
1510
- }
1511
- return { ok: true, token, request };
1512
- }
1513
-
1514
- /** GET confirm page: validate WITHOUT consuming — never mutates. */
1515
- async peekActionToken(rawToken: string): Promise<ActionTokenOutcome> {
1516
- const res = await this.resolveActionToken(rawToken);
1517
- if (!res.ok) return res;
1518
- return { ok: true, action: res.token.action, request: res.request, approverId: res.token.approver_id };
1519
- }
1520
-
1521
- /**
1522
- * POST redemption: consume the token FIRST (a failed decide still burns
1523
- * it — replay-safe), then decide as the bound approver.
1524
- */
1525
- async redeemActionToken(rawToken: string): Promise<ActionTokenOutcome> {
1526
- const res = await this.resolveActionToken(rawToken);
1527
- if (!res.ok) return res;
1528
- await this.engine.update('sys_approval_token', {
1529
- id: res.token.id, consumed_at: this.clock.now().toISOString(),
1530
- }, { context: SYSTEM_CTX });
1531
- const out = await this.decide(res.token.request_id, {
1532
- decision: res.token.action,
1533
- actorId: res.token.approver_id,
1534
- comment: 'Via action link',
1535
- }, SYSTEM_CTX as unknown as SharingExecutionContext);
1536
- return { ok: true, action: res.token.action, request: out.request, approverId: res.token.approver_id };
1537
- }
1538
-
1539
- /**
1540
- * Approver asks the submitter for more information. The request stays
1541
- * pending — a thread interaction, not a flow decision.
1542
- */
1543
- async requestInfo(
1544
- requestId: string,
1545
- input: { actorId: string; comment: string },
1546
- context: SharingExecutionContext,
1547
- ): Promise<{ request: ApprovalRequestRow }> {
1548
- if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
1549
- if (!input?.comment?.trim()) throw new Error('VALIDATION_FAILED: comment is required');
1550
- const raw = await this.loadPendingRow(requestId);
1551
- const pending = csvSplit(raw.pending_approvers);
1552
- if (!context.isSystem && !pending.includes(input.actorId)) {
1553
- throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
1554
- }
1555
-
1556
- const now = this.clock.now().toISOString();
1557
- await this.engine.insert('sys_approval_action', {
1558
- id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
1559
- step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'request_info',
1560
- actor_id: input.actorId, comment: input.comment.trim(), created_at: now,
1561
- }, { context: SYSTEM_CTX });
1562
-
1563
- if (raw.submitter_id) {
1564
- await this.notify({
1565
- topic: 'approval.request_info',
1566
- audience: [String(raw.submitter_id)],
1567
- actorId: input.actorId,
1568
- source: { object: 'sys_approval_request', id: requestId },
1569
- payload: {
1570
- title: 'More information requested',
1571
- message: input.comment.trim(),
1572
- actionUrl: '/system/approvals',
1573
- },
1574
- });
1575
- }
1576
-
1577
- const fresh = await this.getRequest(requestId, context);
1578
- return { request: fresh! };
1579
- }
1580
-
1581
- /** Free-form reply on the thread (submitter or any pending approver). */
1582
- async comment(
1583
- requestId: string,
1584
- input: { actorId: string; comment: string; attachments?: string[] },
1585
- context: SharingExecutionContext,
1586
- ): Promise<{ request: ApprovalRequestRow }> {
1587
- if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
1588
- if (!input?.comment?.trim()) throw new Error('VALIDATION_FAILED: comment is required');
1589
- const raw = await this.loadPendingRow(requestId);
1590
- const pending = csvSplit(raw.pending_approvers);
1591
- const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(input.actorId);
1592
- if (!context.isSystem && !isSubmitter && !pending.includes(input.actorId)) {
1593
- throw new Error(`FORBIDDEN: actor '${input.actorId}' is not on this request`);
1594
- }
1595
-
1596
- const now = this.clock.now().toISOString();
1597
- await this.engine.insert('sys_approval_action', {
1598
- id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
1599
- step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'comment',
1600
- actor_id: input.actorId, comment: input.comment.trim(),
1601
- attachments: input.attachments?.length ? input.attachments : null,
1602
- created_at: now,
1603
- }, { context: SYSTEM_CTX });
1604
-
1605
- // Notify the other side of the thread.
1606
- const audience = isSubmitter ? pending : [String(raw.submitter_id ?? '')].filter(Boolean);
1607
- await this.notify({
1608
- topic: 'approval.comment',
1609
- audience,
1610
- actorId: input.actorId,
1611
- source: { object: 'sys_approval_request', id: requestId },
1612
- payload: {
1613
- title: 'New comment on an approval',
1614
- message: input.comment.trim(),
1615
- actionUrl: '/system/approvals',
1616
- },
1617
- });
1618
-
1619
- const fresh = await this.getRequest(requestId, context);
1620
- return { request: fresh! };
1621
- }
1622
-
1623
- // ── SLA escalation (ADR-0042) ─────────────────────────────────
1624
-
1625
- /**
1626
- * One escalation sweep: every *pending* request whose node config declares
1627
- * `escalation.timeoutHours` and whose deadline has passed is escalated
1628
- * **at most once, ever** — the `escalate` audit row is the idempotency
1629
- * marker, written before any mutation (audit-first, like reassign). One
1630
- * bad row never stops the sweep.
1631
- */
1632
- async runEscalations(): Promise<{ scanned: number; escalated: number }> {
1633
- let rows: any[] = [];
1634
- try {
1635
- rows = await this.engine.find('sys_approval_request', {
1636
- where: { status: 'pending' }, limit: 500, context: SYSTEM_CTX,
1637
- }) ?? [];
1638
- } catch (err: any) {
1639
- this.logger?.warn?.('[approvals] escalation scan failed to list requests', {
1640
- error: err?.message ?? String(err),
1641
- });
1642
- return { scanned: 0, escalated: 0 };
1643
- }
1644
-
1645
- let escalated = 0;
1646
- for (const raw of rows) {
1647
- try {
1648
- const cfg = parseJson<any>(raw.node_config_json, undefined);
1649
- const esc = cfg?.escalation;
1650
- if (!esc || typeof esc.timeoutHours !== 'number' || esc.timeoutHours <= 0) continue;
1651
- const due = slaDueAt(raw.created_at, cfg);
1652
- if (!due || Date.parse(due) > this.clock.now().getTime()) continue;
1653
-
1654
- // Single-shot: a prior 'escalate' action means this request is done.
1655
- const prior = await this.engine.find('sys_approval_action', {
1656
- where: { request_id: raw.id, action: 'escalate' }, limit: 1, context: SYSTEM_CTX,
1657
- });
1658
- if (Array.isArray(prior) && prior[0]) continue;
1659
-
1660
- await this.escalateRequest(raw, esc);
1661
- escalated++;
1662
- } catch (err: any) {
1663
- this.logger?.warn?.('[approvals] escalation failed for request', {
1664
- request: raw?.id, error: err?.message ?? String(err),
1665
- });
1666
- }
1667
- }
1668
- if (escalated > 0) {
1669
- this.logger?.info?.('[approvals] SLA escalation sweep', { scanned: rows.length, escalated });
1670
- }
1671
- return { scanned: rows.length, escalated };
1672
- }
1673
-
1674
- /** Execute the configured escalation action for one overdue request. */
1675
- private async escalateRequest(raw: any, esc: any): Promise<void> {
1676
- const action: string = esc.action ?? 'notify';
1677
- const escalateTo: string | undefined =
1678
- typeof esc.escalateTo === 'string' && esc.escalateTo.trim() ? esc.escalateTo.trim() : undefined;
1679
- const now = this.clock.now().toISOString();
1680
- const pending = csvSplit(raw.pending_approvers);
1681
-
1682
- // `escalateTo` is a position machine name or a user id (same contract as
1683
- // the `position` ApproverType, ADR-0090 D3). Position holders win; an
1684
- // empty expansion falls back to the literal, so a config naming a
1685
- // specific user id keeps working unchanged.
1686
- let escalatees: string[] = [];
1687
- if (escalateTo) {
1688
- try {
1689
- escalatees = await this.expandPositionUsers(escalateTo, raw.organization_id ?? null);
1690
- } catch { escalatees = []; }
1691
- if (!escalatees.length) escalatees = [escalateTo];
1692
- }
1693
-
1694
- // Audit first — this row IS the idempotency marker (ADR-0042 §1).
1695
- await this.engine.insert('sys_approval_action', {
1696
- id: uid('aact'), request_id: raw.id, organization_id: raw.organization_id ?? null,
1697
- step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'escalate',
1698
- actor_id: SLA_ACTOR_ID,
1699
- comment: `${action}${escalateTo ? ` → ${escalateTo}` : ''}`,
1700
- created_at: now,
1701
- }, { context: SYSTEM_CTX });
1702
-
1703
- if (action === 'reassign' && escalatees.length) {
1704
- await this.engine.update('sys_approval_request', {
1705
- id: raw.id, pending_approvers: escalatees.join(','), updated_at: now,
1706
- }, { context: SYSTEM_CTX });
1707
- await this.syncApproverIndex(raw.id, escalatees, raw.organization_id ?? null, now);
1708
- await this.notify({
1709
- topic: 'approval.escalated',
1710
- audience: escalatees,
1711
- actorId: SLA_ACTOR_ID,
1712
- source: { object: 'sys_approval_request', id: raw.id },
1713
- payload: {
1714
- title: 'Approval escalated to you',
1715
- message: `An overdue approval on ${raw.object_name}/${raw.record_id} was escalated to you.`,
1716
- actionUrl: '/system/approvals',
1717
- },
1718
- });
1719
- } else if (action === 'auto_approve' || action === 'auto_reject') {
1720
- await this.decide(raw.id, {
1721
- decision: action === 'auto_approve' ? 'approve' : 'reject',
1722
- actorId: SLA_ACTOR_ID,
1723
- comment: 'SLA escalation',
1724
- }, SYSTEM_CTX as unknown as SharingExecutionContext);
1725
- } else {
1726
- // 'notify' (and the reassign-without-target fallback)
1727
- await this.notify({
1728
- topic: 'approval.sla_breached',
1729
- audience: [...pending, ...escalatees],
1730
- actorId: SLA_ACTOR_ID,
1731
- source: { object: 'sys_approval_request', id: raw.id },
1732
- payload: {
1733
- title: 'Approval SLA breached',
1734
- message: `A decision on ${raw.object_name}/${raw.record_id} is overdue.`,
1735
- actionUrl: '/system/approvals',
1736
- },
1737
- });
1738
- }
1739
-
1740
- if (esc.notifySubmitter !== false && raw.submitter_id) {
1741
- await this.notify({
1742
- topic: 'approval.sla_breached',
1743
- audience: [String(raw.submitter_id)],
1744
- actorId: SLA_ACTOR_ID,
1745
- source: { object: 'sys_approval_request', id: raw.id },
1746
- payload: {
1747
- title: 'Your approval request breached its SLA',
1748
- message: `${raw.object_name}/${raw.record_id}: escalation action '${action}' was taken.`,
1749
- actionUrl: '/system/approvals',
1750
- },
1751
- });
1752
- }
1753
- }
1754
-
1755
- // ── Display enrichment ───────────────────────────────────────
1756
-
1757
- /**
1758
- * Resolve the schema-declared display field for an object, when the engine
1759
- * exposes schema metadata (`getSchema`). Falls back to common title-ish
1760
- * field names so plain `ApprovalEngine` fakes still enrich sensibly.
1761
- */
1762
- private resolveDisplayField(object: string): string | undefined {
1763
- try {
1764
- const schema: any = (this.engine as any).getSchema?.(object);
1765
- const fields = schema?.fields ?? {};
1766
- // [ADR-0079] `nameField` is the canonical primary-title pointer;
1767
- // `displayNameField` is the deprecated alias (still honored).
1768
- const declared = schema?.nameField ?? schema?.displayNameField;
1769
- if (declared && declared !== 'id' && fields[declared]) return declared;
1770
- for (const cand of ['name', 'title', 'subject', 'label']) {
1771
- if (fields[cand]) return cand;
1772
- }
1773
- } catch { /* schema unavailable — heuristics below still apply */ }
1774
- return undefined;
1775
- }
1776
-
1777
- private static pickTitle(rec: any, displayField?: string): string | undefined {
1778
- const candidates = displayField
1779
- ? [displayField, 'name', 'title', 'subject', 'label']
1780
- : ['name', 'title', 'subject', 'label'];
1781
- for (const f of candidates) {
1782
- const v = rec?.[f];
1783
- if (v != null && String(v).trim() && f !== 'id') return String(v);
1784
- }
1785
- return undefined;
1786
- }
1787
-
1788
- /**
1789
- * Batch-resolve `sys_user` display names for identifiers that may be user
1790
- * ids or emails. Best-effort — failures leave entries unresolved.
1791
- */
1792
- private async resolveUserNames(identifiers: Array<string | null | undefined>): Promise<Map<string, string>> {
1793
- const names = new Map<string, string>();
1794
- const targets = Array.from(new Set(identifiers.filter(Boolean))) as string[];
1795
- if (!targets.length) return names;
1796
- try {
1797
- const users = await this.engine.find('sys_user', {
1798
- where: { id: { $in: targets } }, fields: ['id', 'name', 'email'],
1799
- limit: targets.length, context: SYSTEM_CTX,
1800
- });
1801
- for (const u of (users ?? []) as any[]) {
1802
- if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
1803
- }
1804
- } catch { /* best-effort */ }
1805
- const unresolvedEmails = targets.filter(t => !names.has(t) && t.includes('@'));
1806
- if (unresolvedEmails.length) {
1807
- try {
1808
- const users = await this.engine.find('sys_user', {
1809
- where: { email: { $in: unresolvedEmails } }, fields: ['email', 'name'],
1810
- limit: unresolvedEmails.length, context: SYSTEM_CTX,
1811
- });
1812
- for (const u of (users ?? []) as any[]) {
1813
- if (u?.email && u.name) names.set(String(u.email), String(u.name));
1814
- }
1815
- } catch { /* best-effort */ }
1816
- }
1817
- return names;
1818
- }
1819
-
1820
- /** Lookup-typed fields (key + referenced object) of an object's schema. */
1821
- private resolveLookupFields(object: string): Array<{ key: string; reference: string }> {
1822
- try {
1823
- const schema: any = (this.engine as any).getSchema?.(object);
1824
- const fields = schema?.fields ?? {};
1825
- const out: Array<{ key: string; reference: string }> = [];
1826
- for (const [key, f] of Object.entries<any>(fields)) {
1827
- if ((f?.type === 'lookup' || f?.type === 'master_detail' || f?.type === 'user') && f?.reference) {
1828
- out.push({ key, reference: String(f.reference) });
1829
- }
1830
- }
1831
- return out;
1832
- } catch { return []; }
1833
- }
1834
-
1835
- /**
1836
- * Attach inbox display fields to rows so clients never render a raw
1837
- * identifier: `record_title`, `submitter_name`, `object_label`,
1838
- * `pending_approver_names` (user-id approvers), and `payload_display`
1839
- * (lookup foreign keys in the snapshot → referenced record titles).
1840
- * Batched: one query per distinct object (target + referenced) plus one
1841
- * `sys_user` lookup. Best-effort — a deleted record falls back to the
1842
- * payload snapshot, and any failure leaves the field unset rather than
1843
- * failing the list.
1844
- */
1845
- private async enrichRows(rows: ApprovalRequestRow[]): Promise<void> {
1846
- if (!rows.length) return;
1847
-
1848
- // Record titles + object labels, batched per object.
1849
- const byObject = new Map<string, Set<string>>();
1850
- for (const r of rows) {
1851
- if (!r.object_name || !r.record_id) continue;
1852
- let set = byObject.get(r.object_name);
1853
- if (!set) { set = new Set(); byObject.set(r.object_name, set); }
1854
- set.add(r.record_id);
1855
- }
1856
- const titles = new Map<string, string>();
1857
- const objectLabels = new Map<string, string>();
1858
- for (const [object, idSet] of byObject) {
1859
- try {
1860
- const schema: any = (this.engine as any).getSchema?.(object);
1861
- if (schema?.label) objectLabels.set(object, String(schema.label));
1862
- } catch { /* label optional */ }
1863
- const ids = Array.from(idSet);
1864
- const displayField = this.resolveDisplayField(object);
1865
- try {
1866
- const recs = await this.engine.find(object, {
1867
- where: { id: { $in: ids } }, limit: ids.length, context: SYSTEM_CTX,
1868
- });
1869
- for (const rec of (recs ?? []) as any[]) {
1870
- const title = ApprovalService.pickTitle(rec, displayField);
1871
- if (rec?.id && title) titles.set(`${object} ${rec.id}`, title);
1872
- }
1873
- } catch { /* object may be unregistered — payload fallback below */ }
1874
- }
1875
-
1876
- // Lookup foreign keys inside payload snapshots → referenced record titles.
1877
- const lookupFieldsByObject = new Map<string, Array<{ key: string; reference: string }>>();
1878
- for (const object of byObject.keys()) {
1879
- const lookups = this.resolveLookupFields(object);
1880
- if (lookups.length) lookupFieldsByObject.set(object, lookups);
1881
- }
1882
- const refIds = new Map<string, Set<string>>();
1883
- for (const r of rows) {
1884
- const lookups = lookupFieldsByObject.get(r.object_name);
1885
- const payload: any = r.payload;
1886
- if (!lookups || !payload || typeof payload !== 'object') continue;
1887
- for (const { key, reference } of lookups) {
1888
- const v = payload[key];
1889
- if (v == null || typeof v === 'object' || !String(v).trim()) continue;
1890
- let set = refIds.get(reference);
1891
- if (!set) { set = new Set(); refIds.set(reference, set); }
1892
- set.add(String(v));
1893
- }
1894
- }
1895
- const refTitles = new Map<string, string>();
1896
- for (const [object, idSet] of refIds) {
1897
- const ids = Array.from(idSet);
1898
- const displayField = this.resolveDisplayField(object);
1899
- try {
1900
- const recs = await this.engine.find(object, {
1901
- where: { id: { $in: ids } }, limit: ids.length, context: SYSTEM_CTX,
1902
- });
1903
- for (const rec of (recs ?? []) as any[]) {
1904
- const title = ApprovalService.pickTitle(rec, displayField);
1905
- if (rec?.id && title) refTitles.set(`${object} ${rec.id}`, title);
1906
- }
1907
- } catch { /* referenced object unreadable — leave unresolved */ }
1908
- }
1909
-
1910
- // Display names for submitters AND user-id approvers in one lookup.
1911
- // `role:<r>` (and other `type:value` literals) are already readable.
1912
- const userIdentifiers: Array<string | null | undefined> = [];
1913
- for (const r of rows) {
1914
- userIdentifiers.push(r.submitter_id);
1915
- for (const a of r.pending_approvers ?? []) {
1916
- if (a && !a.includes(':')) userIdentifiers.push(a);
1917
- }
1918
- }
1919
- const names = await this.resolveUserNames(userIdentifiers);
1920
-
1921
- for (const r of rows as any[]) {
1922
- const title = titles.get(`${r.object_name} ${r.record_id}`)
1923
- ?? ApprovalService.pickTitle(r.payload, undefined);
1924
- if (title) r.record_title = title;
1925
- const name = r.submitter_id ? names.get(String(r.submitter_id)) : undefined;
1926
- if (name) r.submitter_name = name;
1927
- const label = objectLabels.get(r.object_name);
1928
- if (label) r.object_label = label;
1929
-
1930
- const approverNames: Record<string, string> = {};
1931
- for (const a of r.pending_approvers ?? []) {
1932
- const n = names.get(String(a));
1933
- if (n) approverNames[a] = n;
1934
- }
1935
- if (Object.keys(approverNames).length) r.pending_approver_names = approverNames;
1936
-
1937
- const lookups = lookupFieldsByObject.get(r.object_name);
1938
- if (lookups && r.payload && typeof r.payload === 'object') {
1939
- const display: Record<string, string> = {};
1940
- for (const { key, reference } of lookups) {
1941
- const v = (r.payload as any)[key];
1942
- if (v == null) continue;
1943
- const t = refTitles.get(`${reference} ${String(v)}`);
1944
- if (t) display[key] = t;
1945
- }
1946
- if (Object.keys(display).length) r.payload_display = display;
1947
- }
1948
- }
1949
- }
1950
-
1951
- // ── Pending-approver index (issue #1745) ─────────────────────
1952
-
1953
- /**
1954
- * Mirror one request's `pending_approvers` CSV into the normalized
1955
- * `sys_approval_approver` index. Called by every write path that changes
1956
- * the approver set; an empty `approvers` clears the request's rows (the
1957
- * request left `pending`). Diff-based so reassign/unanimous churn doesn't
1958
- * rewrite untouched rows.
1959
- */
1960
- private async syncApproverIndex(
1961
- requestId: string,
1962
- approvers: string[],
1963
- org: string | null,
1964
- now: string,
1965
- ): Promise<void> {
1966
- const desired = new Set(approvers.map(a => String(a).trim()).filter(Boolean));
1967
- const existing = await this.engine.find('sys_approval_approver', {
1968
- where: { request_id: requestId }, limit: 500, context: SYSTEM_CTX,
1969
- });
1970
- const rows: any[] = Array.isArray(existing) ? existing : [];
1971
- for (const row of rows) {
1972
- if (desired.has(String(row.approver))) desired.delete(String(row.approver));
1973
- else await this.engine.delete('sys_approval_approver', { where: { id: row.id }, context: SYSTEM_CTX });
1974
- }
1975
- for (const approver of desired) {
1976
- await this.engine.insert('sys_approval_approver', {
1977
- id: uid('aapr'), request_id: requestId, approver,
1978
- organization_id: org, created_at: now,
1979
- }, { context: SYSTEM_CTX });
1980
- }
1981
- }
1982
-
1983
- /**
1984
- * Rebuild the whole `sys_approval_approver` index from the CSV source of
1985
- * truth. Idempotent; run at plugin start so rows written before the index
1986
- * existed (or drifted past a crashed sync) become queryable. Cost tracks
1987
- * the number of *pending* requests, not the request history.
1988
- */
1989
- async rebuildApproverIndex(): Promise<{ requests: number; inserted: number; deleted: number }> {
1990
- // Desired state: every pending request's CSV entries.
1991
- const desired = new Map<string, { approvers: Set<string>; org: string | null }>();
1992
- const PAGE = 500;
1993
- for (let offset = 0; ; offset += PAGE) {
1994
- const batch = await this.engine.find('sys_approval_request', {
1995
- where: { status: 'pending' },
1996
- fields: ['id', 'pending_approvers', 'organization_id'],
1997
- limit: PAGE, offset, context: SYSTEM_CTX,
1998
- });
1999
- const rows: any[] = Array.isArray(batch) ? batch : [];
2000
- for (const r of rows) {
2001
- desired.set(String(r.id), {
2002
- approvers: new Set(csvSplit(r.pending_approvers)),
2003
- org: r.organization_id ?? null,
2004
- });
2005
- }
2006
- if (rows.length < PAGE) break;
2007
- }
2008
-
2009
- // Current state: read the whole index first (bounded by the live work
2010
- // queue), THEN mutate — deleting while paginating would shift the cursor.
2011
- const indexRows: any[] = [];
2012
- for (let offset = 0; ; offset += PAGE) {
2013
- const batch = await this.engine.find('sys_approval_approver', {
2014
- orderBy: [{ field: 'created_at', order: 'asc' }],
2015
- limit: PAGE, offset, context: SYSTEM_CTX,
2016
- });
2017
- const rows: any[] = Array.isArray(batch) ? batch : [];
2018
- indexRows.push(...rows);
2019
- if (rows.length < PAGE) break;
2020
- }
2021
- let inserted = 0; let deleted = 0;
2022
- const seen = new Map<string, Set<string>>();
2023
- for (const row of indexRows) {
2024
- const reqId = String(row.request_id);
2025
- const want = desired.get(reqId);
2026
- const have = seen.get(reqId) ?? seen.set(reqId, new Set()).get(reqId)!;
2027
- // Orphan (request no longer pending), stale entry, or duplicate → drop.
2028
- if (!want || !want.approvers.has(String(row.approver)) || have.has(String(row.approver))) {
2029
- await this.engine.delete('sys_approval_approver', { where: { id: row.id }, context: SYSTEM_CTX });
2030
- deleted++;
2031
- continue;
2032
- }
2033
- have.add(String(row.approver));
2034
- }
2035
-
2036
- const now = this.clock.now().toISOString();
2037
- for (const [reqId, want] of desired) {
2038
- const have = seen.get(reqId);
2039
- for (const approver of want.approvers) {
2040
- if (have?.has(approver)) continue;
2041
- await this.engine.insert('sys_approval_approver', {
2042
- id: uid('aapr'), request_id: reqId, approver,
2043
- organization_id: want.org, created_at: now,
2044
- }, { context: SYSTEM_CTX });
2045
- inserted++;
2046
- }
2047
- }
2048
- return { requests: desired.size, inserted, deleted };
2049
- }
2050
-
2051
- // ── Read API ─────────────────────────────────────────────────
2052
-
2053
- /** Filter type accepted by {@link listRequests} / {@link countRequests}. */
2054
- private buildRequestWhere(
2055
- filter: {
2056
- object?: string;
2057
- recordId?: string;
2058
- status?: ApprovalStatus | ApprovalStatus[];
2059
- submitterId?: string;
2060
- q?: string;
2061
- } | undefined,
2062
- context: SharingExecutionContext,
2063
- ): { where: any; tenantOrg: string | null } {
2064
- const f: any = {};
2065
- if (filter?.object) f.object_name = filter.object;
2066
- if (filter?.recordId) f.record_id = filter.recordId;
2067
- if (filter?.submitterId) f.submitter_id = filter.submitterId;
2068
- // Tenant isolation: when a caller context carries a tenant identifier
2069
- // (organizationId / tenantId), scope the query to that tenant. SYSTEM
2070
- // callers (no tenant) see all rows. This prevents the bespoke endpoint
2071
- // from leaking other-tenant rows since we deliberately query with
2072
- // SYSTEM_CTX to bypass RLS on the engine (the approver-visibility rule
2073
- // spans three identity forms, which RLS can't model cleanly).
2074
- const tenantOrg = (context as any)?.organizationId ?? (context as any)?.tenantId ?? null;
2075
- if (tenantOrg) f.organization_id = tenantOrg;
2076
- // Free-text search, pushed down: `payload_json` carries the record
2077
- // snapshot, so record titles match without any join. `$contains` is the
2078
- // driver's escaped-LIKE operator.
2079
- const q = filter?.q?.trim();
2080
- if (q) {
2081
- f.$or = [
2082
- { process_name: { $contains: q } },
2083
- { object_name: { $contains: q } },
2084
- { record_id: { $contains: q } },
2085
- { submitter_id: { $contains: q } },
2086
- { payload_json: { $contains: q } },
2087
- ];
2088
- }
2089
- // Status pushes down whole: `$in` for arrays (all bundled drivers
2090
- // support it), equality for a single value.
2091
- if (Array.isArray(filter?.status)) {
2092
- const statuses = (filter!.status as ApprovalStatus[]).filter(Boolean);
2093
- if (statuses.length === 1) f.status = statuses[0];
2094
- else if (statuses.length > 1) f.status = { $in: statuses };
2095
- } else if (filter?.status) {
2096
- f.status = filter.status;
2097
- }
2098
- return { where: f, tenantOrg };
2099
- }
2100
-
2101
- /** Window the approver-index probe — pending queues live far below this. */
2102
- private static readonly APPROVER_INDEX_CAP = 10_000;
2103
-
2104
- /**
2105
- * Resolve an approver filter to matching request ids via the normalized
2106
- * `sys_approval_approver` index — the indexed replacement for the old
2107
- * in-memory CSV scan, and what makes approver-filtered pagination correct
2108
- * past any scan window (issue #1745). A request matches when ANY of the
2109
- * caller's identities (user id / email / role:<r>) holds a pending slot.
2110
- * Returns null when the filter is absent (callers skip the id constraint).
2111
- */
2112
- private async approverRequestIds(
2113
- targets: string[],
2114
- tenantOrg: string | null,
2115
- ): Promise<string[] | null> {
2116
- if (!targets.length) return null;
2117
- const where: any = targets.length === 1
2118
- ? { approver: targets[0] }
2119
- : { approver: { $in: targets } };
2120
- if (tenantOrg) where.organization_id = tenantOrg;
2121
- const rows = await this.engine.find('sys_approval_approver', {
2122
- where, fields: ['request_id'],
2123
- limit: ApprovalService.APPROVER_INDEX_CAP, context: SYSTEM_CTX,
2124
- });
2125
- const list: any[] = Array.isArray(rows) ? rows : [];
2126
- if (list.length >= ApprovalService.APPROVER_INDEX_CAP) {
2127
- this.logger?.warn?.('[approvals] approver index probe hit its window — results may be truncated', {
2128
- cap: ApprovalService.APPROVER_INDEX_CAP, targets: targets.length,
2129
- });
2130
- }
2131
- return [...new Set<string>(list.map(r => String(r.request_id)))];
2132
- }
2133
-
2134
- async listRequests(
2135
- filter: {
2136
- object?: string;
2137
- recordId?: string;
2138
- status?: ApprovalStatus | ApprovalStatus[];
2139
- approverId?: string | string[];
2140
- submitterId?: string;
2141
- q?: string;
2142
- limit?: number;
2143
- offset?: number;
2144
- } | undefined,
2145
- context: SharingExecutionContext,
2146
- ): Promise<ApprovalRequestRow[]> {
2147
- const { where, tenantOrg } = this.buildRequestWhere(filter, context);
2148
- const approverTargets = (Array.isArray(filter?.approverId) ? filter!.approverId : filter?.approverId ? [filter.approverId] : [])
2149
- .map(t => String(t).trim())
2150
- .filter(Boolean);
2151
-
2152
- // Every filter now pushes into the engine (issue #1745): approver via
2153
- // the normalized index, status arrays via $in — so the page window is
2154
- // always engine-side and correct at any table size.
2155
- const ids = await this.approverRequestIds(approverTargets, tenantOrg);
2156
- if (ids) {
2157
- if (ids.length === 0) return [];
2158
- where.id = ids.length === 1 ? ids[0] : { $in: ids };
2159
- }
2160
-
2161
- const findOpts: any = {
2162
- where,
2163
- orderBy: [{ field: 'created_at', order: 'desc' }],
2164
- context: SYSTEM_CTX,
2165
- };
2166
- if (filter?.limit != null || filter?.offset != null) {
2167
- findOpts.limit = Math.min(Math.max(filter?.limit ?? 50, 1), 200);
2168
- if (filter?.offset) findOpts.offset = Math.max(filter.offset, 0);
2169
- } else {
2170
- // Unpaginated callers keep the legacy bounded window.
2171
- findOpts.limit = 500;
2172
- }
2173
-
2174
- const rows = await this.engine.find('sys_approval_request', findOpts);
2175
- const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
2176
- await this.enrichRows(list);
2177
- this.attachViewers(list, context);
2178
- return list;
2179
- }
2180
-
2181
- async countRequests(
2182
- filter: Parameters<IApprovalService['listRequests']>[0],
2183
- context: SharingExecutionContext,
2184
- ): Promise<number> {
2185
- const { where, tenantOrg } = this.buildRequestWhere(filter, context);
2186
- const approverTargets = (Array.isArray(filter?.approverId) ? filter!.approverId : filter?.approverId ? [filter.approverId] : [])
2187
- .map(t => String(t).trim())
2188
- .filter(Boolean);
2189
-
2190
- const ids = await this.approverRequestIds(approverTargets, tenantOrg);
2191
- if (ids) {
2192
- if (ids.length === 0) return 0;
2193
- where.id = ids.length === 1 ? ids[0] : { $in: ids };
2194
- }
2195
-
2196
- const countFn = (this.engine as any).count;
2197
- if (typeof countFn === 'function') {
2198
- try {
2199
- const n = await countFn.call(this.engine, 'sys_approval_request', { where, context: SYSTEM_CTX });
2200
- if (typeof n === 'number') return n;
2201
- } catch { /* fall through to scan */ }
2202
- }
2203
- // Engine without count(): bounded scan. The approver-filtered case is
2204
- // exact (the id set bounds it); the unfiltered case keeps the legacy
2205
- // 500 window.
2206
- const rows = await this.engine.find('sys_approval_request', {
2207
- where, fields: ['id'], limit: ids ? Math.max(500, ids.length) : 500, context: SYSTEM_CTX,
2208
- });
2209
- return Array.isArray(rows) ? rows.length : 0;
2210
- }
2211
-
2212
- async getRequest(requestId: string, context: SharingExecutionContext): Promise<ApprovalRequestRow | null> {
2213
- if (!requestId) return null;
2214
- const where: any = { id: requestId };
2215
- const tenantOrg = (context as any)?.organizationId ?? (context as any)?.tenantId;
2216
- if (tenantOrg) where.organization_id = tenantOrg;
2217
- const rows = await this.engine.find('sys_approval_request', {
2218
- where, limit: 1, context: SYSTEM_CTX,
2219
- });
2220
- if (!Array.isArray(rows) || !rows[0]) return null;
2221
- const row = rowFromRequest(rows[0]);
2222
- await this.enrichRows([row]);
2223
- await this.attachFlowSteps(row);
2224
- await this.attachDecisionProgress(row, rows[0]);
2225
- this.attachViewers([row], context);
2226
- return row;
2227
- }
2228
-
2229
- /**
2230
- * Server-computed decision aggregation progress (#3266 / objectui#2678 P1.5).
2231
- * Single-read enrichment only (like {@link ApprovalService.attachFlowSteps}):
2232
- * for a PENDING request whose behavior aggregates multiple approvals
2233
- * (`unanimous` / `quorum` / `per_group`), expose
2234
- * `decision_progress: { behavior, got, need, groups? }` so any client renders
2235
- * "2 of 3" or per-group ticks without re-deriving the engine's tally rules.
2236
- * `first_response` requests carry no progress (one approval finalizes).
2237
- * Display-only and best-effort — errors leave the row untouched.
2238
- */
2239
- private async attachDecisionProgress(row: ApprovalRequestRow, raw: any): Promise<void> {
2240
- try {
2241
- if (row.status !== 'pending') return;
2242
- const cfg = parseJson<any>(raw.node_config_json, undefined);
2243
- const behavior = cfg?.behavior ?? 'first_response';
2244
- if (behavior !== 'unanimous' && behavior !== 'quorum' && behavior !== 'per_group') return;
2245
-
2246
- const acts = await this.engine.find('sys_approval_action', {
2247
- where: { request_id: row.id, step_index: 0, action: 'approve' }, limit: 1000, context: SYSTEM_CTX,
2248
- });
2249
- const approved = new Set<string>((acts ?? []).map((a: any) => String(a.actor_id ?? '')).filter(Boolean));
2250
-
2251
- const snapshot = cfg?.__approverGroups as Record<string, string[]> | undefined;
2252
- const slate = snapshot ? Object.keys(snapshot) : [...approved, ...(row.pending_approvers ?? [])];
2253
- const total = slate.length || 1;
2254
-
2255
- const progress: any = { behavior, got: approved.size, need: total };
2256
- if (behavior === 'quorum') {
2257
- progress.need = Math.min(Math.max(1, cfg?.minApprovals ?? total), total);
2258
- } else if (behavior === 'per_group' && snapshot) {
2259
- const perGroupNeed = Math.max(1, cfg?.minApprovals ?? 1);
2260
- const size: Record<string, number> = {};
2261
- for (const gs of Object.values(snapshot)) for (const g of gs) size[g] = (size[g] ?? 0) + 1;
2262
- const got: Record<string, number> = {};
2263
- for (const a of approved) for (const g of (snapshot[a] ?? [])) got[g] = (got[g] ?? 0) + 1;
2264
- progress.groups = Object.keys(size).sort().map(g => {
2265
- const need = Math.min(perGroupNeed, size[g]);
2266
- return { group: g, got: Math.min(got[g] ?? 0, need), need, satisfied: (got[g] ?? 0) >= need };
2267
- });
2268
- progress.got = progress.groups.filter((g: any) => g.satisfied).length;
2269
- progress.need = progress.groups.length;
2270
- }
2271
- (row as any).decision_progress = progress;
2272
- } catch { /* display-only enrichment */ }
2273
- }
2274
-
2275
- /**
2276
- * Attach the per-viewer capability block (#3310) from the caller's context.
2277
- * `can_act` mirrors the exact authorization the decision methods enforce — the
2278
- * caller's user id is in the resolved `pending_approvers` while the request is
2279
- * still `pending` (position/team/manager approvers are already resolved to
2280
- * concrete user ids at open time, so a plain membership test is faithful).
2281
- * `is_submitter` is a straight owner check. System/tokenless contexts get a
2282
- * both-false block. Cheap + synchronous — safe on list reads.
2283
- */
2284
- private attachViewers(rows: ApprovalRequestRow[], context: SharingExecutionContext): void {
2285
- const uid = (context as any)?.userId != null ? String((context as any).userId) : null;
2286
- for (const row of rows) {
2287
- const pending = row.pending_approvers ?? [];
2288
- (row as any).viewer = {
2289
- can_act: row.status === 'pending' && !!uid && pending.includes(uid),
2290
- is_submitter: !!uid && row.submitter_id != null && String(row.submitter_id) === uid,
2291
- };
2292
- }
2293
- }
2294
-
2295
- /**
2296
- * Derive approval-step progress from the owning flow's graph (single-read
2297
- * enrichment only — list reads skip it). Walks from the start node
2298
- * preferring `approve`/`true` edges, so the result is the flow's main
2299
- * approval trunk; conditional side-steps show as part of the potential
2300
- * path. Display-only and best-effort.
2301
- */
2302
- private async attachFlowSteps(row: ApprovalRequestRow): Promise<void> {
2303
- try {
2304
- const flowName = row.process_name?.startsWith('flow:') ? row.process_name.slice(5) : undefined;
2305
- if (!flowName || typeof this.automation?.getFlow !== 'function') return;
2306
- const flow: any = await this.automation.getFlow(flowName);
2307
- if (!flow?.nodes?.length) return;
2308
- const nodesById = new Map<string, any>(flow.nodes.map((n: any) => [n.id, n]));
2309
- const steps: Array<{ id: string; label: string }> = [];
2310
- const seen = new Set<string>();
2311
- let cur: any = flow.nodes.find((n: any) => n.type === 'start');
2312
- while (cur && !seen.has(cur.id)) {
2313
- seen.add(cur.id);
2314
- if (cur.type === 'approval') steps.push({ id: cur.id, label: cur.label || cur.id });
2315
- const out = (flow.edges ?? []).filter((e: any) => e.source === cur.id);
2316
- if (!out.length) break;
2317
- const pick = out.find((e: any) => e.label === 'approve')
2318
- ?? out.find((e: any) => e.label === 'true')
2319
- ?? out[0];
2320
- cur = nodesById.get(pick.target);
2321
- }
2322
- if (steps.length === 0) return;
2323
- const currentId = row.flow_node_id ?? row.current_step;
2324
- const currentIdx = steps.findIndex(s => s.id === currentId);
2325
- (row as any).flow_steps = steps.map((s, i) => ({
2326
- ...s,
2327
- state: currentIdx < 0 ? 'upcoming'
2328
- : i < currentIdx ? 'done'
2329
- : i === currentIdx ? (row.status === 'approved' ? 'done' : 'current')
2330
- : 'upcoming',
2331
- }));
2332
- } catch { /* display-only — never fail the read */ }
2333
- }
2334
-
2335
- async listActions(requestId: string, context: SharingExecutionContext): Promise<ApprovalActionRow[]> {
2336
- if (!requestId) return [];
2337
- // Tenant gate: ensure the caller can see the parent request before
2338
- // returning its action history. Skipping this would leak history rows
2339
- // across tenants the same way the unscoped list-requests path did.
2340
- const req = await this.getRequest(requestId, context);
2341
- if (!req) return [];
2342
- const rows = await this.engine.find('sys_approval_action', {
2343
- where: { request_id: requestId },
2344
- limit: 500,
2345
- orderBy: [{ field: 'created_at', order: 'asc' }],
2346
- context: SYSTEM_CTX,
2347
- });
2348
- const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
2349
- // Timeline display: resolve actor ids to names so the audit trail never
2350
- // shows a raw identifier. Role/team literals are already readable.
2351
- const names = await this.resolveUserNames(
2352
- actions.map(a => a.actor_id).filter(id => id && !id.includes(':')),
2353
- );
2354
- for (const a of actions as any[]) {
2355
- const n = a.actor_id ? names.get(String(a.actor_id)) : undefined;
2356
- if (n) a.actor_name = n;
2357
- }
2358
- return actions;
2359
- }
2360
- }