@objectstack/plugin-approvals 7.3.0 → 7.4.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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +85 -0
- package/dist/index.d.mts +6431 -107
- package/dist/index.d.ts +6431 -107
- package/dist/index.js +1237 -776
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1244 -779
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -7
- package/scripts/i18n-extract.config.ts +32 -0
- package/src/approval-node.test.ts +182 -0
- package/src/approval-node.ts +131 -0
- package/src/approval-service.test.ts +205 -304
- package/src/approval-service.ts +208 -491
- package/src/approvals-plugin.ts +61 -53
- package/src/index.ts +12 -11
- package/src/lifecycle-hooks.ts +67 -202
- package/src/nav-contribution.test.ts +46 -0
- package/src/sys-approval-action.object.ts +120 -0
- package/src/sys-approval-request.object.ts +227 -0
- package/src/translations/en.objects.generated.ts +156 -0
- package/src/translations/es-ES.objects.generated.ts +156 -0
- package/src/translations/index.ts +23 -0
- package/src/translations/ja-JP.objects.generated.ts +156 -0
- package/src/translations/zh-CN.objects.generated.ts +156 -0
- package/src/action-executor.ts +0 -313
- package/src/phase-b.test.ts +0 -263
package/src/approval-service.ts
CHANGED
|
@@ -1,24 +1,33 @@
|
|
|
1
1
|
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
APPROVAL_BRANCH_LABELS,
|
|
5
|
+
type ApprovalNodeConfig,
|
|
6
|
+
} from '@objectstack/spec/automation';
|
|
4
7
|
import type {
|
|
5
8
|
IApprovalService,
|
|
6
|
-
ApprovalProcessRow,
|
|
7
9
|
ApprovalRequestRow,
|
|
8
10
|
ApprovalActionRow,
|
|
9
11
|
ApprovalDecisionInput,
|
|
10
12
|
ApprovalDecisionResult,
|
|
11
13
|
ApprovalStatus,
|
|
12
|
-
DefineApprovalProcessInput,
|
|
13
|
-
SubmitApprovalInput,
|
|
14
14
|
SharingExecutionContext,
|
|
15
15
|
} from '@objectstack/spec/contracts';
|
|
16
|
-
import type { MetadataRepository } from '@objectstack/metadata-core';
|
|
17
|
-
import { executeActions, type ApprovalTrigger, type FetchLike } from './action-executor.js';
|
|
18
16
|
|
|
19
17
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* Node-era approval runtime (ADR-0019).
|
|
19
|
+
*
|
|
20
|
+
* Approval is no longer a standalone engine — it is a **flow node**. A flow's
|
|
21
|
+
* Approval node opens a request via {@link ApprovalService.openNodeRequest} and
|
|
22
|
+
* the run suspends; a human decision via {@link ApprovalService.decide}
|
|
23
|
+
* finalises the request and resumes the owning run down the matching
|
|
24
|
+
* `approve` / `reject` edge.
|
|
25
|
+
*
|
|
26
|
+
* This service owns the durable approval *state* — `sys_approval_request` /
|
|
27
|
+
* `sys_approval_action`, approver resolution (team / department / role /
|
|
28
|
+
* manager graph), and the optional status-field mirror — plus the decision
|
|
29
|
+
* API. It does not author processes, submit, or walk multi-step machinery
|
|
30
|
+
* anymore; that orchestration lives on the one automation engine.
|
|
22
31
|
*/
|
|
23
32
|
export interface ApprovalEngine {
|
|
24
33
|
find(object: string, options?: any): Promise<any[]>;
|
|
@@ -29,6 +38,15 @@ export interface ApprovalEngine {
|
|
|
29
38
|
|
|
30
39
|
export interface ApprovalClock { now(): Date }
|
|
31
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Minimal automation surface the service uses to resume a suspended flow run
|
|
43
|
+
* once a decision finalises a node-driven request. Optional — attached by the
|
|
44
|
+
* plugin when an automation engine is present (see `approval-node.ts`).
|
|
45
|
+
*/
|
|
46
|
+
export interface ApprovalResumeSurface {
|
|
47
|
+
resume?(runId: string, signal?: { output?: Record<string, unknown>; branchLabel?: string }): Promise<unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
|
|
33
51
|
|
|
34
52
|
function uid(prefix: string): string {
|
|
@@ -51,26 +69,11 @@ function csvSplit(raw: unknown): string[] {
|
|
|
51
69
|
return String(raw).split(',').map(s => s.trim()).filter(Boolean);
|
|
52
70
|
}
|
|
53
71
|
|
|
54
|
-
function rowFromProcess(row: any): ApprovalProcessRow {
|
|
55
|
-
return {
|
|
56
|
-
id: String(row.id),
|
|
57
|
-
name: String(row.name ?? ''),
|
|
58
|
-
label: String(row.label ?? ''),
|
|
59
|
-
object_name: String(row.object_name ?? ''),
|
|
60
|
-
description: row.description ?? undefined,
|
|
61
|
-
active: row.active !== false,
|
|
62
|
-
definition: parseJson(row.definition_json, {}),
|
|
63
|
-
created_at: row.created_at ?? undefined,
|
|
64
|
-
updated_at: row.updated_at ?? undefined,
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
72
|
function rowFromRequest(row: any): ApprovalRequestRow {
|
|
69
73
|
return {
|
|
70
74
|
id: String(row.id),
|
|
71
75
|
organization_id: row.organization_id ?? undefined,
|
|
72
76
|
process_name: String(row.process_name ?? ''),
|
|
73
|
-
process_hash: row.process_hash ?? undefined,
|
|
74
77
|
object_name: String(row.object_name ?? ''),
|
|
75
78
|
record_id: String(row.record_id ?? ''),
|
|
76
79
|
submitter_id: row.submitter_id ?? undefined,
|
|
@@ -80,6 +83,8 @@ function rowFromRequest(row: any): ApprovalRequestRow {
|
|
|
80
83
|
current_step_index: row.current_step_index ?? undefined,
|
|
81
84
|
pending_approvers: csvSplit(row.pending_approvers),
|
|
82
85
|
payload: parseJson(row.payload_json, undefined),
|
|
86
|
+
flow_run_id: row.flow_run_id ?? undefined,
|
|
87
|
+
flow_node_id: row.flow_node_id ?? undefined,
|
|
83
88
|
completed_at: row.completed_at ?? undefined,
|
|
84
89
|
created_at: row.created_at ?? undefined,
|
|
85
90
|
updated_at: row.updated_at ?? undefined,
|
|
@@ -99,70 +104,45 @@ function rowFromAction(row: any): ApprovalActionRow {
|
|
|
99
104
|
};
|
|
100
105
|
}
|
|
101
106
|
|
|
102
|
-
// Note: legacy synchronous `resolveApprovers` removed in M10.17.1 — replaced
|
|
103
|
-
// by the async `expandApprovers` member which routes through the team/dept
|
|
104
|
-
// graph tables (with prefixed-literal fallback for back-compat).
|
|
105
|
-
|
|
106
107
|
export interface ApprovalServiceOptions {
|
|
107
108
|
engine: ApprovalEngine;
|
|
108
109
|
clock?: ApprovalClock;
|
|
109
110
|
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 };
|
|
110
|
-
/** Optional fetch impl for `webhook` actions; defaults to global. */
|
|
111
|
-
fetch?: FetchLike;
|
|
112
|
-
/** Webhook timeout in ms; default 5000. */
|
|
113
|
-
webhookTimeoutMs?: number;
|
|
114
|
-
/**
|
|
115
|
-
* Called after the process registry changes (defineProcess / deleteProcess).
|
|
116
|
-
* The plugin uses this to re-bind lifecycle hooks for auto-trigger / lock.
|
|
117
|
-
*/
|
|
118
|
-
onRegistryChange?: () => void | Promise<void>;
|
|
119
111
|
/**
|
|
120
|
-
* Optional
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* - `approve` / `reject` / `recall` resolve the pinned body via
|
|
125
|
-
* `MetadataRepository.getByHash` so process upgrades don't affect
|
|
126
|
-
* in-flight requests.
|
|
127
|
-
*
|
|
128
|
-
* When omitted, the service reads the current process from the
|
|
129
|
-
* `sys_approval_process` projection (pre-ADR-0009 behaviour).
|
|
112
|
+
* Optional automation surface used to resume a suspended flow run when a
|
|
113
|
+
* decision finalises a request. Usually attached after construction via
|
|
114
|
+
* {@link ApprovalService.attachAutomation} once the automation engine is
|
|
115
|
+
* available.
|
|
130
116
|
*/
|
|
131
|
-
|
|
117
|
+
automation?: ApprovalResumeSurface;
|
|
132
118
|
}
|
|
133
119
|
|
|
134
120
|
export class ApprovalService implements IApprovalService {
|
|
135
121
|
private readonly engine: ApprovalEngine;
|
|
136
122
|
private readonly clock: ApprovalClock;
|
|
137
123
|
private readonly logger?: ApprovalServiceOptions['logger'];
|
|
138
|
-
private
|
|
139
|
-
private readonly webhookTimeoutMs?: number;
|
|
140
|
-
private readonly onRegistryChange?: () => void | Promise<void>;
|
|
141
|
-
private readonly metadataRepo?: MetadataRepository;
|
|
124
|
+
private automation?: ApprovalResumeSurface;
|
|
142
125
|
|
|
143
126
|
constructor(opts: ApprovalServiceOptions) {
|
|
144
127
|
this.engine = opts.engine;
|
|
145
128
|
this.clock = opts.clock ?? { now: () => new Date() };
|
|
146
129
|
this.logger = opts.logger;
|
|
147
|
-
this.
|
|
148
|
-
this.webhookTimeoutMs = opts.webhookTimeoutMs;
|
|
149
|
-
this.onRegistryChange = opts.onRegistryChange;
|
|
150
|
-
this.metadataRepo = opts.metadataRepo;
|
|
130
|
+
this.automation = opts.automation;
|
|
151
131
|
}
|
|
152
132
|
|
|
153
|
-
/**
|
|
154
|
-
|
|
155
|
-
|
|
133
|
+
/** Attach (or replace) the automation surface used to resume flow runs. */
|
|
134
|
+
attachAutomation(automation: ApprovalResumeSurface): void {
|
|
135
|
+
this.automation = automation;
|
|
156
136
|
}
|
|
157
137
|
|
|
158
138
|
/**
|
|
159
|
-
* Expand the approvers on
|
|
160
|
-
* tables for `team:` / `department:` / `role:` / `manager:` approver
|
|
161
|
-
* types. Falls back to a prefixed literal (`type:value`) when graph
|
|
162
|
-
*
|
|
163
|
-
*
|
|
139
|
+
* Expand the approvers on an Approval node into user IDs by querying the
|
|
140
|
+
* graph tables for `team:` / `department:` / `role:` / `manager:` approver
|
|
141
|
+
* types. Falls back to a prefixed literal (`type:value`) when graph lookups
|
|
142
|
+
* produce nothing — so existing fixtures and flows that rely on substring
|
|
143
|
+
* matching keep working.
|
|
164
144
|
*
|
|
165
|
-
* **Graph semantics
|
|
145
|
+
* **Graph semantics:**
|
|
166
146
|
* - `team` → flat members of `sys_team` (better-auth; no BFS)
|
|
167
147
|
* - `department` → recursive BFS of `sys_department.parent_department_id`
|
|
168
148
|
* → members of every descendant via `sys_department_member`
|
|
@@ -281,222 +261,47 @@ export class ApprovalService implements IApprovalService {
|
|
|
281
261
|
} catch { return null; }
|
|
282
262
|
}
|
|
283
263
|
|
|
284
|
-
|
|
285
|
-
private async
|
|
286
|
-
const cb = this.onRegistryChange ?? ((this as any).onRegistryChange as (() => void | Promise<void>) | undefined);
|
|
287
|
-
if (!cb) return;
|
|
288
|
-
try { await cb(); }
|
|
289
|
-
catch (err: any) { this.logger?.warn?.('[approvals] onRegistryChange handler failed', { error: err?.message }); }
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Look up the HEAD checksum of an approval process from the metadata repo
|
|
294
|
-
* (ADR-0009). Returns null when no repo is wired, no metadata exists for
|
|
295
|
-
* the name, or the lookup fails — callers MUST treat null as "do not pin"
|
|
296
|
-
* and fall back to the projection table.
|
|
297
|
-
*/
|
|
298
|
-
private async resolveProcessHash(processName: string, organizationId?: string | null): Promise<string | null> {
|
|
299
|
-
if (!this.metadataRepo) return null;
|
|
300
|
-
if (!processName) return null;
|
|
301
|
-
const orgRef = { org: organizationId || 'system', type: 'approval' as const, name: processName };
|
|
264
|
+
/** Mirror a request status onto a business-object field, if configured. */
|
|
265
|
+
private async mirrorStatusField(object: string, recordId: string, field: string, status: string): Promise<void> {
|
|
302
266
|
try {
|
|
303
|
-
|
|
304
|
-
return head?.hash ?? null;
|
|
267
|
+
await this.engine.update(object, { id: recordId, [field]: status }, { context: SYSTEM_CTX });
|
|
305
268
|
} catch (err: any) {
|
|
306
|
-
this.logger?.
|
|
307
|
-
return null;
|
|
269
|
+
this.logger?.warn?.(`[approvals] mirrorStatusField failed: ${err?.message ?? err}`);
|
|
308
270
|
}
|
|
309
271
|
}
|
|
310
272
|
|
|
273
|
+
// ── ADR-0019: Approval-as-flow-node ──────────────────────────
|
|
274
|
+
//
|
|
275
|
+
// A flow's Approval node opens a request via `openNodeRequest` (carrying its
|
|
276
|
+
// own approvers/behavior config and the suspended run id), then suspends. A
|
|
277
|
+
// later `decide` finalizes it and resumes the flow run down the matching
|
|
278
|
+
// `approve`/`reject` edge. The record lock is enforced by a beforeUpdate hook
|
|
279
|
+
// keyed on a *pending* request, so finalizing auto-releases it.
|
|
280
|
+
|
|
311
281
|
/**
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
* Resolution order:
|
|
316
|
-
* 1. If `req.process_hash` AND `metadataRepo` are set, try
|
|
317
|
-
* `getByHash` — return a row whose `definition` is the pinned body.
|
|
318
|
-
* 2. Otherwise (or on lookup failure) fall back to the current
|
|
319
|
-
* projection via `getProcess(req.process_name)`.
|
|
282
|
+
* Open a pending approval request on behalf of a flow's Approval node. The
|
|
283
|
+
* node config (approvers / behavior / status field) is snapshotted on the row
|
|
284
|
+
* so a decision can be made without any process to resolve against.
|
|
320
285
|
*/
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
id: current?.id ?? `pinned_${hash.slice(7, 19)}`,
|
|
336
|
-
name: req.process_name,
|
|
337
|
-
label: body.label ?? current?.label ?? req.process_name,
|
|
338
|
-
object_name: req.object_name,
|
|
339
|
-
description: body.description ?? current?.description,
|
|
340
|
-
active: current?.active ?? true,
|
|
341
|
-
definition: body,
|
|
342
|
-
created_at: current?.created_at,
|
|
343
|
-
updated_at: current?.updated_at,
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
this.logger?.warn?.('[approvals] pinned process body not found; falling back to current', {
|
|
347
|
-
request: req.id, process: req.process_name, hash,
|
|
348
|
-
});
|
|
349
|
-
} catch (err: any) {
|
|
350
|
-
this.logger?.warn?.('[approvals] getByHash failed; falling back to current', {
|
|
351
|
-
request: req.id, error: err?.message,
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
return this.getProcess(req.process_name, context);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/** Mirror request status onto `process.approvalStatusField` if configured. */
|
|
359
|
-
private async syncStatusField(process: ApprovalProcessRow, request: ApprovalRequestRow): Promise<void> {
|
|
360
|
-
const field = (process.definition as any)?.approvalStatusField;
|
|
361
|
-
if (!field) return;
|
|
362
|
-
try {
|
|
363
|
-
await this.engine.update(
|
|
364
|
-
process.object_name,
|
|
365
|
-
{ id: request.record_id, [field]: request.status },
|
|
366
|
-
{ context: SYSTEM_CTX },
|
|
367
|
-
);
|
|
368
|
-
} catch (err: any) {
|
|
369
|
-
this.logger?.warn?.(`[approvals] syncStatusField failed: ${err?.message ?? err}`);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/** Convenience wrapper that funnels every action invocation through the executor. */
|
|
374
|
-
private async runActions(
|
|
375
|
-
actions: any[] | undefined | null,
|
|
376
|
-
trigger: ApprovalTrigger,
|
|
377
|
-
process: ApprovalProcessRow,
|
|
378
|
-
request: ApprovalRequestRow,
|
|
379
|
-
step: any | undefined,
|
|
380
|
-
actorId: string | null | undefined,
|
|
381
|
-
comment: string | null | undefined,
|
|
382
|
-
): Promise<void> {
|
|
383
|
-
if (!actions || actions.length === 0) return;
|
|
384
|
-
await executeActions(actions, {
|
|
385
|
-
trigger,
|
|
386
|
-
process: { ...process, object: process.object_name },
|
|
387
|
-
request,
|
|
388
|
-
step,
|
|
389
|
-
actorId: actorId ?? null,
|
|
390
|
-
comment: comment ?? null,
|
|
391
|
-
}, {
|
|
392
|
-
engine: this.engine,
|
|
393
|
-
logger: this.logger,
|
|
394
|
-
fetch: this.fetchImpl,
|
|
395
|
-
webhookTimeoutMs: this.webhookTimeoutMs,
|
|
396
|
-
});
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// ── Process definitions ──────────────────────────────────────
|
|
400
|
-
|
|
401
|
-
async defineProcess(input: DefineApprovalProcessInput, _context: SharingExecutionContext): Promise<ApprovalProcessRow> {
|
|
402
|
-
if (!input.name) throw new Error('VALIDATION_FAILED: name is required');
|
|
403
|
-
if (!input.label) throw new Error('VALIDATION_FAILED: label is required');
|
|
404
|
-
if (!input.object) throw new Error('VALIDATION_FAILED: object is required');
|
|
405
|
-
if (!input.definition) throw new Error('VALIDATION_FAILED: definition is required');
|
|
406
|
-
|
|
407
|
-
const parsed = ApprovalProcessSchema.safeParse(input.definition);
|
|
408
|
-
if (!parsed.success) {
|
|
409
|
-
const msg = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ');
|
|
410
|
-
throw new Error(`VALIDATION_FAILED: ${msg}`);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
const now = this.clock.now().toISOString();
|
|
414
|
-
const payload: any = {
|
|
415
|
-
name: input.name,
|
|
416
|
-
label: input.label,
|
|
417
|
-
object_name: input.object,
|
|
418
|
-
description: input.description ?? null,
|
|
419
|
-
active: input.active !== false,
|
|
420
|
-
definition_json: JSON.stringify(parsed.data),
|
|
421
|
-
updated_at: now,
|
|
422
|
-
};
|
|
423
|
-
|
|
424
|
-
// Upsert by name.
|
|
425
|
-
const existing = await this.engine.find('sys_approval_process', {
|
|
426
|
-
where: { name: input.name }, limit: 1, context: SYSTEM_CTX,
|
|
427
|
-
});
|
|
428
|
-
if (Array.isArray(existing) && existing[0]) {
|
|
429
|
-
const id = existing[0].id;
|
|
430
|
-
await this.engine.update('sys_approval_process', { id, ...payload }, { context: SYSTEM_CTX });
|
|
431
|
-
const row = rowFromProcess({ ...existing[0], ...payload, id });
|
|
432
|
-
await this.notifyRegistryChanged();
|
|
433
|
-
return row;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
const id = input.id ?? uid('apv');
|
|
437
|
-
const row = { id, ...payload, created_at: now };
|
|
438
|
-
await this.engine.insert('sys_approval_process', row, { context: SYSTEM_CTX });
|
|
439
|
-
const out = rowFromProcess(row);
|
|
440
|
-
await this.notifyRegistryChanged();
|
|
441
|
-
return out;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
async listProcesses(
|
|
445
|
-
filter: { object?: string; activeOnly?: boolean } | undefined,
|
|
446
|
-
_context: SharingExecutionContext,
|
|
447
|
-
): Promise<ApprovalProcessRow[]> {
|
|
448
|
-
const f: any = {};
|
|
449
|
-
if (filter?.object) f.object_name = filter.object;
|
|
450
|
-
if (filter?.activeOnly) f.active = true;
|
|
451
|
-
const rows = await this.engine.find('sys_approval_process', {
|
|
452
|
-
where: f, limit: 500, orderBy: [{ field: 'updated_at', direction: 'desc' }], context: SYSTEM_CTX,
|
|
453
|
-
});
|
|
454
|
-
return Array.isArray(rows) ? rows.map(rowFromProcess) : [];
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
async getProcess(idOrName: string, _context: SharingExecutionContext): Promise<ApprovalProcessRow | null> {
|
|
458
|
-
if (!idOrName) return null;
|
|
459
|
-
let rows = await this.engine.find('sys_approval_process', {
|
|
460
|
-
where: { id: idOrName }, limit: 1, context: SYSTEM_CTX,
|
|
461
|
-
});
|
|
462
|
-
if (!Array.isArray(rows) || !rows[0]) {
|
|
463
|
-
rows = await this.engine.find('sys_approval_process', {
|
|
464
|
-
where: { name: idOrName }, limit: 1, context: SYSTEM_CTX,
|
|
465
|
-
});
|
|
466
|
-
}
|
|
467
|
-
return Array.isArray(rows) && rows[0] ? rowFromProcess(rows[0]) : null;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
async deleteProcess(idOrName: string, context: SharingExecutionContext): Promise<void> {
|
|
471
|
-
if (!idOrName) throw new Error('VALIDATION_FAILED: idOrName is required');
|
|
472
|
-
const proc = await this.getProcess(idOrName, context);
|
|
473
|
-
if (!proc) return;
|
|
474
|
-
await this.engine.delete('sys_approval_process', { where: { id: proc.id }, context: SYSTEM_CTX });
|
|
475
|
-
await this.notifyRegistryChanged();
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
// ── Requests ─────────────────────────────────────────────────
|
|
479
|
-
|
|
480
|
-
async submit(input: SubmitApprovalInput, context: SharingExecutionContext): Promise<ApprovalRequestRow> {
|
|
286
|
+
async openNodeRequest(
|
|
287
|
+
input: {
|
|
288
|
+
object: string;
|
|
289
|
+
recordId: string;
|
|
290
|
+
runId: string;
|
|
291
|
+
nodeId: string;
|
|
292
|
+
config: ApprovalNodeConfig;
|
|
293
|
+
flowName?: string;
|
|
294
|
+
submitterId?: string | null;
|
|
295
|
+
record?: any;
|
|
296
|
+
organizationId?: string | null;
|
|
297
|
+
},
|
|
298
|
+
context: SharingExecutionContext,
|
|
299
|
+
): Promise<ApprovalRequestRow> {
|
|
481
300
|
if (!input.object) throw new Error('VALIDATION_FAILED: object is required');
|
|
482
301
|
if (!input.recordId) throw new Error('VALIDATION_FAILED: recordId is required');
|
|
302
|
+
if (!input.runId) throw new Error('VALIDATION_FAILED: runId is required');
|
|
483
303
|
|
|
484
|
-
//
|
|
485
|
-
let process: ApprovalProcessRow | null = null;
|
|
486
|
-
if (input.processName) {
|
|
487
|
-
process = await this.getProcess(input.processName, context);
|
|
488
|
-
if (process && !process.active) {
|
|
489
|
-
throw new Error(`NO_ACTIVE_PROCESS: process '${input.processName}' is not active`);
|
|
490
|
-
}
|
|
491
|
-
} else {
|
|
492
|
-
const list = await this.listProcesses({ object: input.object, activeOnly: true }, context);
|
|
493
|
-
process = list[0] ?? null;
|
|
494
|
-
}
|
|
495
|
-
if (!process) {
|
|
496
|
-
throw new Error(`NO_ACTIVE_PROCESS: no active approval process for object '${input.object}'`);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
// De-duplicate: only one pending request per (object, record).
|
|
304
|
+
// One pending request per (object, record).
|
|
500
305
|
const existing = await this.engine.find('sys_approval_request', {
|
|
501
306
|
where: { object_name: input.object, record_id: input.recordId, status: 'pending' },
|
|
502
307
|
limit: 1, context: SYSTEM_CTX,
|
|
@@ -505,67 +310,161 @@ export class ApprovalService implements IApprovalService {
|
|
|
505
310
|
throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
|
|
506
311
|
}
|
|
507
312
|
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
throw new Error('VALIDATION_FAILED: process definition has no steps');
|
|
511
|
-
}
|
|
512
|
-
const step0 = steps[0];
|
|
513
|
-
const ctxOrg = (context as any)?.organizationId ?? (context as any)?.tenantId ?? null;
|
|
514
|
-
const approvers = await this.expandApprovers(step0, input.payload, ctxOrg);
|
|
313
|
+
const ctxOrg = (context as any)?.organizationId ?? (context as any)?.tenantId ?? input.organizationId ?? null;
|
|
314
|
+
const approvers = await this.expandApprovers({ approvers: input.config.approvers }, input.record, ctxOrg);
|
|
515
315
|
|
|
516
316
|
const now = this.clock.now().toISOString();
|
|
517
317
|
const id = uid('areq');
|
|
518
|
-
const
|
|
318
|
+
const processName = `flow:${input.flowName ?? input.nodeId}`;
|
|
519
319
|
const row: any = {
|
|
520
320
|
id,
|
|
521
|
-
process_name:
|
|
522
|
-
process_hash: processHash,
|
|
321
|
+
process_name: processName,
|
|
523
322
|
object_name: input.object,
|
|
524
323
|
record_id: input.recordId,
|
|
525
324
|
submitter_id: input.submitterId ?? context.userId ?? null,
|
|
526
|
-
submitter_comment: input.comment ?? null,
|
|
527
325
|
status: 'pending',
|
|
528
|
-
current_step:
|
|
326
|
+
current_step: input.nodeId,
|
|
529
327
|
current_step_index: 0,
|
|
530
328
|
pending_approvers: approvers.join(','),
|
|
531
|
-
payload_json: input.
|
|
329
|
+
payload_json: input.record != null ? JSON.stringify(input.record) : null,
|
|
330
|
+
flow_run_id: input.runId,
|
|
331
|
+
flow_node_id: input.nodeId,
|
|
332
|
+
node_config_json: JSON.stringify(input.config),
|
|
532
333
|
organization_id: ctxOrg,
|
|
533
334
|
created_at: now,
|
|
534
335
|
updated_at: now,
|
|
535
336
|
};
|
|
536
337
|
await this.engine.insert('sys_approval_request', row, { context: SYSTEM_CTX });
|
|
338
|
+
await this.engine.insert('sys_approval_action', {
|
|
339
|
+
id: uid('aact'), request_id: id, organization_id: ctxOrg,
|
|
340
|
+
step_name: input.nodeId, step_index: 0, action: 'submit',
|
|
341
|
+
actor_id: input.submitterId ?? context.userId ?? null, comment: null, created_at: now,
|
|
342
|
+
}, { context: SYSTEM_CTX });
|
|
343
|
+
|
|
344
|
+
// Record lock (when `lockRecord !== false`) is enforced by the beforeUpdate
|
|
345
|
+
// hook keyed on the now-pending request; no extra write needed here.
|
|
346
|
+
if (input.config.approvalStatusField) {
|
|
347
|
+
await this.mirrorStatusField(input.object, input.recordId, input.config.approvalStatusField, 'pending');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return rowFromRequest(row);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Record a decision on a node-driven request. Honours the node's `unanimous`
|
|
355
|
+
* behavior (holds until every approver has approved). When the request
|
|
356
|
+
* finalizes, returns the suspended run id + node id so the caller (or
|
|
357
|
+
* {@link ApprovalService.decide}) can resume the flow down the matching
|
|
358
|
+
* branch.
|
|
359
|
+
*/
|
|
360
|
+
async decideNode(
|
|
361
|
+
requestId: string,
|
|
362
|
+
input: { decision: 'approve' | 'reject'; actorId: string; comment?: string },
|
|
363
|
+
context: SharingExecutionContext,
|
|
364
|
+
): Promise<{ request: ApprovalRequestRow; runId: string | null; nodeId: string | null; finalized: boolean; decision: 'approve' | 'reject' }> {
|
|
365
|
+
if (!requestId) throw new Error('VALIDATION_FAILED: requestId is required');
|
|
366
|
+
if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
|
|
367
|
+
if (input.decision !== 'approve' && input.decision !== 'reject') {
|
|
368
|
+
throw new Error('VALIDATION_FAILED: decision must be approve|reject');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Read the raw row to reach flow_* correlation + the node config snapshot.
|
|
372
|
+
const rawRows = await this.engine.find('sys_approval_request', {
|
|
373
|
+
where: { id: requestId }, limit: 1, context: SYSTEM_CTX,
|
|
374
|
+
});
|
|
375
|
+
const raw: any = Array.isArray(rawRows) ? rawRows[0] : null;
|
|
376
|
+
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
377
|
+
if (raw.status !== 'pending') throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
378
|
+
|
|
379
|
+
const pendingApprovers = csvSplit(raw.pending_approvers);
|
|
380
|
+
if (!context.isSystem && !pendingApprovers.includes(input.actorId)) {
|
|
381
|
+
throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
|
|
382
|
+
}
|
|
537
383
|
|
|
538
|
-
|
|
384
|
+
const config = parseJson<ApprovalNodeConfig>(raw.node_config_json, { approvers: [], behavior: 'first_response' } as any);
|
|
385
|
+
const org = raw.organization_id ?? null;
|
|
386
|
+
const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null;
|
|
387
|
+
const runId: string | null = raw.flow_run_id ?? null;
|
|
388
|
+
const now = this.clock.now().toISOString();
|
|
389
|
+
|
|
390
|
+
// Audit the decision first so the unanimous tally below sees it.
|
|
539
391
|
await this.engine.insert('sys_approval_action', {
|
|
540
|
-
id: uid('aact'),
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
step_name: step0.name,
|
|
544
|
-
step_index: 0,
|
|
545
|
-
action: 'submit',
|
|
546
|
-
actor_id: input.submitterId ?? context.userId ?? null,
|
|
547
|
-
comment: input.comment ?? null,
|
|
548
|
-
created_at: now,
|
|
392
|
+
id: uid('aact'), request_id: requestId, organization_id: org,
|
|
393
|
+
step_name: nodeId, step_index: 0, action: input.decision,
|
|
394
|
+
actor_id: input.actorId, comment: input.comment ?? null, created_at: now,
|
|
549
395
|
}, { context: SYSTEM_CTX });
|
|
550
396
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
397
|
+
// Unanimous approve: advance only once every approver has approved.
|
|
398
|
+
if (input.decision === 'approve' && config.behavior === 'unanimous') {
|
|
399
|
+
const original = await this.expandApprovers(
|
|
400
|
+
{ approvers: config.approvers }, parseJson(raw.payload_json, undefined), org,
|
|
401
|
+
);
|
|
402
|
+
const acts = await this.engine.find('sys_approval_action', {
|
|
403
|
+
where: { request_id: requestId, step_index: 0, action: 'approve' }, limit: 500, context: SYSTEM_CTX,
|
|
404
|
+
});
|
|
405
|
+
const approved = new Set<string>((acts ?? []).map((a: any) => String(a.actor_id ?? '')).filter(Boolean));
|
|
406
|
+
const stillPending = original.filter(a => !approved.has(a));
|
|
407
|
+
if (stillPending.length > 0) {
|
|
408
|
+
await this.engine.update('sys_approval_request', {
|
|
409
|
+
id: requestId, pending_approvers: stillPending.join(','), updated_at: now,
|
|
410
|
+
}, { context: SYSTEM_CTX });
|
|
411
|
+
const fresh = await this.getRequest(requestId, context);
|
|
412
|
+
return { request: fresh!, runId, nodeId, finalized: false, decision: input.decision };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const finalStatus = input.decision === 'approve' ? 'approved' : 'rejected';
|
|
417
|
+
await this.engine.update('sys_approval_request', {
|
|
418
|
+
id: requestId, status: finalStatus, pending_approvers: null, completed_at: now, updated_at: now,
|
|
419
|
+
}, { context: SYSTEM_CTX });
|
|
420
|
+
if (config.approvalStatusField) {
|
|
421
|
+
await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, finalStatus);
|
|
422
|
+
}
|
|
423
|
+
const fresh = await this.getRequest(requestId, context);
|
|
424
|
+
return { request: fresh!, runId, nodeId, finalized: true, decision: input.decision };
|
|
567
425
|
}
|
|
568
426
|
|
|
427
|
+
/**
|
|
428
|
+
* Public contract entrypoint (ADR-0019). Records a decision on a node-driven
|
|
429
|
+
* request via {@link ApprovalService.decideNode} and, when it finalizes,
|
|
430
|
+
* resumes the owning flow run down the matching `approve` / `reject` edge.
|
|
431
|
+
*/
|
|
432
|
+
async decide(
|
|
433
|
+
requestId: string,
|
|
434
|
+
input: ApprovalDecisionInput,
|
|
435
|
+
context: SharingExecutionContext,
|
|
436
|
+
): Promise<ApprovalDecisionResult> {
|
|
437
|
+
const result = await this.decideNode(requestId, input, context);
|
|
438
|
+
|
|
439
|
+
let resumed = false;
|
|
440
|
+
if (result.finalized && result.runId && typeof this.automation?.resume === 'function') {
|
|
441
|
+
const branchLabel = result.decision === 'approve'
|
|
442
|
+
? APPROVAL_BRANCH_LABELS.approve
|
|
443
|
+
: APPROVAL_BRANCH_LABELS.reject;
|
|
444
|
+
try {
|
|
445
|
+
await this.automation.resume(result.runId, {
|
|
446
|
+
branchLabel,
|
|
447
|
+
output: { decision: result.decision, requestId },
|
|
448
|
+
});
|
|
449
|
+
resumed = true;
|
|
450
|
+
} catch (err: any) {
|
|
451
|
+
this.logger?.warn?.('[approvals] resume after decision failed', {
|
|
452
|
+
request: requestId, run: result.runId, error: err?.message ?? String(err),
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
request: result.request,
|
|
459
|
+
finalized: result.finalized,
|
|
460
|
+
decision: result.decision,
|
|
461
|
+
runId: result.runId,
|
|
462
|
+
resumed,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ── Read API ─────────────────────────────────────────────────
|
|
467
|
+
|
|
569
468
|
async listRequests(
|
|
570
469
|
filter: {
|
|
571
470
|
object?: string;
|
|
@@ -616,188 +515,6 @@ export class ApprovalService implements IApprovalService {
|
|
|
616
515
|
return Array.isArray(rows) && rows[0] ? rowFromRequest(rows[0]) : null;
|
|
617
516
|
}
|
|
618
517
|
|
|
619
|
-
async approve(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise<ApprovalDecisionResult> {
|
|
620
|
-
const req = await this.getRequest(requestId, context);
|
|
621
|
-
if (!req) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
622
|
-
if (req.status !== 'pending') throw new Error(`INVALID_STATE: request is ${req.status}`);
|
|
623
|
-
if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
|
|
624
|
-
|
|
625
|
-
if (!context.isSystem && !(req.pending_approvers ?? []).includes(input.actorId)) {
|
|
626
|
-
throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
const process = await this.loadProcessForRequest(req, context);
|
|
630
|
-
if (!process) throw new Error(`PROCESS_NOT_FOUND: ${req.process_name}`);
|
|
631
|
-
const steps: any[] = process.definition?.steps ?? [];
|
|
632
|
-
const stepIndex = req.current_step_index ?? 0;
|
|
633
|
-
const step = steps[stepIndex];
|
|
634
|
-
if (!step) throw new Error(`INVALID_STATE: step index ${stepIndex} out of range`);
|
|
635
|
-
|
|
636
|
-
const now = this.clock.now().toISOString();
|
|
637
|
-
// Audit row first so unanimous tally sees it.
|
|
638
|
-
await this.engine.insert('sys_approval_action', {
|
|
639
|
-
id: uid('aact'),
|
|
640
|
-
request_id: req.id,
|
|
641
|
-
organization_id: (req as any).organization_id ?? null,
|
|
642
|
-
step_name: step.name,
|
|
643
|
-
step_index: stepIndex,
|
|
644
|
-
action: 'approve',
|
|
645
|
-
actor_id: input.actorId,
|
|
646
|
-
comment: input.comment ?? null,
|
|
647
|
-
created_at: now,
|
|
648
|
-
}, { context: SYSTEM_CTX });
|
|
649
|
-
|
|
650
|
-
// Unanimous: only advance once every original approver has approved at this step_index.
|
|
651
|
-
if (step.behavior === 'unanimous') {
|
|
652
|
-
const original = await this.expandApprovers(step, req.payload, (req as any).organization_id ?? null);
|
|
653
|
-
const acts = await this.engine.find('sys_approval_action', {
|
|
654
|
-
where: { request_id: req.id, step_index: stepIndex, action: 'approve' },
|
|
655
|
-
limit: 500, context: SYSTEM_CTX,
|
|
656
|
-
});
|
|
657
|
-
const approved = new Set<string>((acts ?? []).map((a: any) => String(a.actor_id ?? '')).filter(Boolean));
|
|
658
|
-
const stillPending = original.filter(a => !approved.has(a));
|
|
659
|
-
if (stillPending.length > 0) {
|
|
660
|
-
// Update pending_approvers to those who haven't voted yet.
|
|
661
|
-
await this.engine.update('sys_approval_request', {
|
|
662
|
-
id: req.id,
|
|
663
|
-
pending_approvers: stillPending.join(','),
|
|
664
|
-
updated_at: now,
|
|
665
|
-
}, { context: SYSTEM_CTX });
|
|
666
|
-
const fresh = await this.getRequest(req.id, context);
|
|
667
|
-
return { request: fresh!, finalized: false };
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
// Advance the request — either to next step or to finalized=approved.
|
|
672
|
-
if (stepIndex + 1 >= steps.length) {
|
|
673
|
-
await this.engine.update('sys_approval_request', {
|
|
674
|
-
id: req.id,
|
|
675
|
-
status: 'approved',
|
|
676
|
-
pending_approvers: null,
|
|
677
|
-
completed_at: now,
|
|
678
|
-
updated_at: now,
|
|
679
|
-
}, { context: SYSTEM_CTX });
|
|
680
|
-
const fresh = await this.getRequest(req.id, context);
|
|
681
|
-
// Phase B: step.onApprove + process.onFinalApprove + status mirror.
|
|
682
|
-
await this.runActions((step as any)?.onApprove, 'step_approve', process, fresh!, step, input.actorId, input.comment);
|
|
683
|
-
await this.syncStatusField(process, fresh!);
|
|
684
|
-
await this.runActions((process.definition as any)?.onFinalApprove, 'final_approve', process, fresh!, step, input.actorId, input.comment);
|
|
685
|
-
return { request: fresh!, finalized: true };
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
const nextStep = steps[stepIndex + 1];
|
|
689
|
-
const nextApprovers = await this.expandApprovers(nextStep, req.payload, (req as any).organization_id ?? null);
|
|
690
|
-
await this.engine.update('sys_approval_request', {
|
|
691
|
-
id: req.id,
|
|
692
|
-
current_step: nextStep.name,
|
|
693
|
-
current_step_index: stepIndex + 1,
|
|
694
|
-
pending_approvers: nextApprovers.join(','),
|
|
695
|
-
updated_at: now,
|
|
696
|
-
}, { context: SYSTEM_CTX });
|
|
697
|
-
const fresh = await this.getRequest(req.id, context);
|
|
698
|
-
// Phase B: step.onApprove fires when transitioning out of this step.
|
|
699
|
-
await this.runActions((step as any)?.onApprove, 'step_approve', process, fresh!, step, input.actorId, input.comment);
|
|
700
|
-
return { request: fresh!, finalized: false };
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
async reject(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise<ApprovalDecisionResult> {
|
|
704
|
-
const req = await this.getRequest(requestId, context);
|
|
705
|
-
if (!req) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
706
|
-
if (req.status !== 'pending') throw new Error(`INVALID_STATE: request is ${req.status}`);
|
|
707
|
-
if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
|
|
708
|
-
if (!context.isSystem && !(req.pending_approvers ?? []).includes(input.actorId)) {
|
|
709
|
-
throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
const process = await this.loadProcessForRequest(req, context);
|
|
713
|
-
if (!process) throw new Error(`PROCESS_NOT_FOUND: ${req.process_name}`);
|
|
714
|
-
const steps: any[] = process.definition?.steps ?? [];
|
|
715
|
-
const stepIndex = req.current_step_index ?? 0;
|
|
716
|
-
const step = steps[stepIndex];
|
|
717
|
-
|
|
718
|
-
const now = this.clock.now().toISOString();
|
|
719
|
-
await this.engine.insert('sys_approval_action', {
|
|
720
|
-
id: uid('aact'),
|
|
721
|
-
request_id: req.id,
|
|
722
|
-
organization_id: (req as any).organization_id ?? null,
|
|
723
|
-
step_name: step?.name,
|
|
724
|
-
step_index: stepIndex,
|
|
725
|
-
action: 'reject',
|
|
726
|
-
actor_id: input.actorId,
|
|
727
|
-
comment: input.comment ?? null,
|
|
728
|
-
created_at: now,
|
|
729
|
-
}, { context: SYSTEM_CTX });
|
|
730
|
-
|
|
731
|
-
if (step?.rejectionBehavior === 'back_to_previous' && stepIndex > 0) {
|
|
732
|
-
const prev = steps[stepIndex - 1];
|
|
733
|
-
const prevApprovers = await this.expandApprovers(prev, req.payload, (req as any).organization_id ?? null);
|
|
734
|
-
await this.engine.update('sys_approval_request', {
|
|
735
|
-
id: req.id,
|
|
736
|
-
current_step: prev.name,
|
|
737
|
-
current_step_index: stepIndex - 1,
|
|
738
|
-
pending_approvers: prevApprovers.join(','),
|
|
739
|
-
updated_at: now,
|
|
740
|
-
}, { context: SYSTEM_CTX });
|
|
741
|
-
const fresh = await this.getRequest(req.id, context);
|
|
742
|
-
// Phase B: step-level onReject fires on non-final rejection too.
|
|
743
|
-
await this.runActions((step as any)?.onReject, 'step_reject', process, fresh!, step, input.actorId, input.comment);
|
|
744
|
-
return { request: fresh!, finalized: false };
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
await this.engine.update('sys_approval_request', {
|
|
748
|
-
id: req.id,
|
|
749
|
-
status: 'rejected',
|
|
750
|
-
pending_approvers: null,
|
|
751
|
-
completed_at: now,
|
|
752
|
-
updated_at: now,
|
|
753
|
-
}, { context: SYSTEM_CTX });
|
|
754
|
-
const fresh = await this.getRequest(req.id, context);
|
|
755
|
-
// Phase B: step.onReject + process.onFinalReject + status mirror.
|
|
756
|
-
await this.runActions((step as any)?.onReject, 'step_reject', process, fresh!, step, input.actorId, input.comment);
|
|
757
|
-
await this.syncStatusField(process, fresh!);
|
|
758
|
-
await this.runActions((process.definition as any)?.onFinalReject, 'final_reject', process, fresh!, step, input.actorId, input.comment);
|
|
759
|
-
return { request: fresh!, finalized: true };
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
async recall(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise<ApprovalDecisionResult> {
|
|
763
|
-
const req = await this.getRequest(requestId, context);
|
|
764
|
-
if (!req) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
765
|
-
if (req.status !== 'pending') throw new Error(`INVALID_STATE: request is ${req.status}`);
|
|
766
|
-
if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
|
|
767
|
-
if (!context.isSystem && req.submitter_id && req.submitter_id !== input.actorId) {
|
|
768
|
-
throw new Error(`FORBIDDEN: only the submitter can recall this request`);
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
const now = this.clock.now().toISOString();
|
|
772
|
-
await this.engine.insert('sys_approval_action', {
|
|
773
|
-
id: uid('aact'),
|
|
774
|
-
request_id: req.id,
|
|
775
|
-
organization_id: (req as any).organization_id ?? null,
|
|
776
|
-
step_name: req.current_step,
|
|
777
|
-
step_index: req.current_step_index,
|
|
778
|
-
action: 'recall',
|
|
779
|
-
actor_id: input.actorId,
|
|
780
|
-
comment: input.comment ?? null,
|
|
781
|
-
created_at: now,
|
|
782
|
-
}, { context: SYSTEM_CTX });
|
|
783
|
-
|
|
784
|
-
await this.engine.update('sys_approval_request', {
|
|
785
|
-
id: req.id,
|
|
786
|
-
status: 'recalled',
|
|
787
|
-
pending_approvers: null,
|
|
788
|
-
completed_at: now,
|
|
789
|
-
updated_at: now,
|
|
790
|
-
}, { context: SYSTEM_CTX });
|
|
791
|
-
const fresh = await this.getRequest(req.id, context);
|
|
792
|
-
// Phase B: process.onRecall + status mirror.
|
|
793
|
-
const process = await this.loadProcessForRequest(req, context);
|
|
794
|
-
if (process) {
|
|
795
|
-
await this.syncStatusField(process, fresh!);
|
|
796
|
-
await this.runActions((process.definition as any)?.onRecall, 'recall', process, fresh!, undefined, input.actorId, input.comment);
|
|
797
|
-
}
|
|
798
|
-
return { request: fresh!, finalized: true };
|
|
799
|
-
}
|
|
800
|
-
|
|
801
518
|
async listActions(requestId: string, context: SharingExecutionContext): Promise<ApprovalActionRow[]> {
|
|
802
519
|
if (!requestId) return [];
|
|
803
520
|
// Tenant gate: ensure the caller can see the parent request before
|