@objectstack/plugin-approvals 9.1.0 → 9.3.0
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 +39 -0
- package/dist/index.d.mts +2343 -166
- package/dist/index.d.ts +2343 -166
- package/dist/index.js +1515 -151
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1514 -151
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -7
- package/src/action-link-pages.ts +102 -0
- package/src/approval-revise.test.ts +411 -0
- package/src/approval-service.test.ts +483 -4
- package/src/approval-service.ts +1262 -63
- package/src/approvals-plugin.ts +124 -3
- package/src/index.ts +1 -0
- package/src/nav-contribution.test.ts +3 -1
- package/src/sys-approval-action.object.ts +5 -1
- package/src/sys-approval-approver.object.ts +78 -0
- package/src/sys-approval-request.object.ts +8 -4
- package/src/sys-approval-token.object.ts +94 -0
package/src/approval-service.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
2
|
|
|
3
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
4
|
import {
|
|
4
5
|
APPROVAL_BRANCH_LABELS,
|
|
5
6
|
type ApprovalNodeConfig,
|
|
@@ -12,6 +13,10 @@ import type {
|
|
|
12
13
|
ApprovalDecisionResult,
|
|
13
14
|
ApprovalRecallInput,
|
|
14
15
|
ApprovalRecallResult,
|
|
16
|
+
ApprovalSendBackInput,
|
|
17
|
+
ApprovalSendBackResult,
|
|
18
|
+
ApprovalResubmitInput,
|
|
19
|
+
ApprovalResubmitResult,
|
|
15
20
|
ApprovalStatus,
|
|
16
21
|
SharingExecutionContext,
|
|
17
22
|
} from '@objectstack/spec/contracts';
|
|
@@ -47,8 +52,51 @@ export interface ApprovalClock { now(): Date }
|
|
|
47
52
|
*/
|
|
48
53
|
export interface ApprovalResumeSurface {
|
|
49
54
|
resume?(runId: string, signal?: { output?: Record<string, unknown>; branchLabel?: string }): Promise<unknown>;
|
|
55
|
+
/** Flow definition lookup, used to derive step-progress display data. */
|
|
56
|
+
getFlow?(name: string): Promise<any | null>;
|
|
57
|
+
/**
|
|
58
|
+
* Terminally cancel a suspended run (ADR-0044). Used when a recall lands
|
|
59
|
+
* during a revision window — the run is paused at the revise wait node,
|
|
60
|
+
* which has no reject edge to resume down.
|
|
61
|
+
*/
|
|
62
|
+
cancelRun?(runId: string, reason?: string): Promise<unknown>;
|
|
50
63
|
}
|
|
51
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Optional messaging surface (ADR-0012 `messaging` service). When attached,
|
|
67
|
+
* thread interactions (reassign / remind / request-info / comment) notify the
|
|
68
|
+
* affected users; without it they degrade to audit-only.
|
|
69
|
+
*/
|
|
70
|
+
export interface ApprovalMessagingSurface {
|
|
71
|
+
emit(input: {
|
|
72
|
+
topic: string;
|
|
73
|
+
audience: string[];
|
|
74
|
+
payload?: Record<string, unknown>;
|
|
75
|
+
severity?: string;
|
|
76
|
+
dedupKey?: string;
|
|
77
|
+
source?: { object: string; id: string };
|
|
78
|
+
actorId?: string;
|
|
79
|
+
}): Promise<unknown>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Minimum time between submitter reminders on one request. */
|
|
83
|
+
export const REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1000;
|
|
84
|
+
|
|
85
|
+
/** Named job under which the SLA escalation scan is registered (ADR-0042). */
|
|
86
|
+
export const ESCALATION_JOB_NAME = 'approvals-sla-escalation';
|
|
87
|
+
/** Default interval between SLA escalation scans. */
|
|
88
|
+
export const ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1000;
|
|
89
|
+
/** Reserved actor id for machine decisions made by the SLA scanner. */
|
|
90
|
+
export const SLA_ACTOR_ID = 'system:sla';
|
|
91
|
+
|
|
92
|
+
/** Default lifetime of an actionable-link token (ADR-0043). */
|
|
93
|
+
export const ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1000;
|
|
94
|
+
|
|
95
|
+
/** Outcome of redeeming (or peeking) an actionable-link token. */
|
|
96
|
+
export type ActionTokenOutcome =
|
|
97
|
+
| { ok: true; action: 'approve' | 'reject'; request: ApprovalRequestRow; approverId: string }
|
|
98
|
+
| { ok: false; reason: 'invalid' | 'expired' | 'consumed' | 'not_pending' | 'not_approver'; request?: ApprovalRequestRow };
|
|
99
|
+
|
|
52
100
|
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
|
|
53
101
|
|
|
54
102
|
function uid(prefix: string): string {
|
|
@@ -114,9 +162,21 @@ function rowFromRequest(row: any): ApprovalRequestRow {
|
|
|
114
162
|
submitted_at: row.created_at ?? undefined,
|
|
115
163
|
process_label: cfg?.__flowLabel ?? prettifyMachineName(row.process_name),
|
|
116
164
|
step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step),
|
|
165
|
+
sla_due_at: slaDueAt(row.created_at, cfg),
|
|
166
|
+
// ADR-0044 revision round (rides the config snapshot; absent ⇒ round 1).
|
|
167
|
+
round: typeof cfg?.__round === 'number' ? cfg.__round : undefined,
|
|
117
168
|
} as any;
|
|
118
169
|
}
|
|
119
170
|
|
|
171
|
+
/** `created_at + escalation.timeoutHours`, when the node declares an SLA. */
|
|
172
|
+
function slaDueAt(createdAt: unknown, cfg: any): string | undefined {
|
|
173
|
+
const hours = cfg?.escalation?.timeoutHours;
|
|
174
|
+
if (typeof hours !== 'number' || hours <= 0 || !createdAt) return undefined;
|
|
175
|
+
const t = Date.parse(String(createdAt));
|
|
176
|
+
if (Number.isNaN(t)) return undefined;
|
|
177
|
+
return new Date(t + hours * 3600_000).toISOString();
|
|
178
|
+
}
|
|
179
|
+
|
|
120
180
|
function rowFromAction(row: any): ApprovalActionRow {
|
|
121
181
|
return {
|
|
122
182
|
id: String(row.id),
|
|
@@ -141,6 +201,14 @@ export interface ApprovalServiceOptions {
|
|
|
141
201
|
* available.
|
|
142
202
|
*/
|
|
143
203
|
automation?: ApprovalResumeSurface;
|
|
204
|
+
/** Optional messaging service for thread notifications. */
|
|
205
|
+
messaging?: ApprovalMessagingSurface;
|
|
206
|
+
/**
|
|
207
|
+
* Absolute origin prefixed onto actionable links (ADR-0043), e.g.
|
|
208
|
+
* `https://app.example.com`. Defaults to relative URLs, which work inside
|
|
209
|
+
* the Console and IM webviews; outbound email needs the absolute form.
|
|
210
|
+
*/
|
|
211
|
+
publicBaseUrl?: string;
|
|
144
212
|
}
|
|
145
213
|
|
|
146
214
|
export class ApprovalService implements IApprovalService {
|
|
@@ -148,12 +216,16 @@ export class ApprovalService implements IApprovalService {
|
|
|
148
216
|
private readonly clock: ApprovalClock;
|
|
149
217
|
private readonly logger?: ApprovalServiceOptions['logger'];
|
|
150
218
|
private automation?: ApprovalResumeSurface;
|
|
219
|
+
private messaging?: ApprovalMessagingSurface;
|
|
220
|
+
private publicBaseUrl: string;
|
|
151
221
|
|
|
152
222
|
constructor(opts: ApprovalServiceOptions) {
|
|
153
223
|
this.engine = opts.engine;
|
|
154
224
|
this.clock = opts.clock ?? { now: () => new Date() };
|
|
155
225
|
this.logger = opts.logger;
|
|
156
226
|
this.automation = opts.automation;
|
|
227
|
+
this.messaging = opts.messaging;
|
|
228
|
+
this.publicBaseUrl = (opts.publicBaseUrl ?? '').replace(/\/$/, '');
|
|
157
229
|
}
|
|
158
230
|
|
|
159
231
|
/** Attach (or replace) the automation surface used to resume flow runs. */
|
|
@@ -161,6 +233,45 @@ export class ApprovalService implements IApprovalService {
|
|
|
161
233
|
this.automation = automation;
|
|
162
234
|
}
|
|
163
235
|
|
|
236
|
+
/** Attach (or replace) the messaging surface used for thread notifications. */
|
|
237
|
+
attachMessaging(messaging: ApprovalMessagingSurface): void {
|
|
238
|
+
this.messaging = messaging;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Best-effort notification fan-out — failures only log. */
|
|
242
|
+
private async notify(input: {
|
|
243
|
+
topic: string;
|
|
244
|
+
audience: string[];
|
|
245
|
+
payload?: Record<string, unknown>;
|
|
246
|
+
dedupKey?: string;
|
|
247
|
+
source?: { object: string; id: string };
|
|
248
|
+
actorId?: string;
|
|
249
|
+
}): Promise<number> {
|
|
250
|
+
const audience = input.audience.filter(a => a && !a.includes(':'));
|
|
251
|
+
if (!this.messaging || !audience.length) return 0;
|
|
252
|
+
try {
|
|
253
|
+
await this.messaging.emit({ severity: 'info', ...input, audience });
|
|
254
|
+
return audience.length;
|
|
255
|
+
} catch (err: any) {
|
|
256
|
+
this.logger?.warn?.('[approvals] notification failed', {
|
|
257
|
+
topic: input.topic, error: err?.message ?? String(err),
|
|
258
|
+
});
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Load a request row and assert it is still pending. */
|
|
264
|
+
private async loadPendingRow(requestId: string): Promise<any> {
|
|
265
|
+
if (!requestId) throw new Error('VALIDATION_FAILED: requestId is required');
|
|
266
|
+
const rows = await this.engine.find('sys_approval_request', {
|
|
267
|
+
where: { id: requestId }, limit: 1, context: SYSTEM_CTX,
|
|
268
|
+
});
|
|
269
|
+
const raw: any = Array.isArray(rows) ? rows[0] : null;
|
|
270
|
+
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
271
|
+
if (raw.status !== 'pending') throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
272
|
+
return raw;
|
|
273
|
+
}
|
|
274
|
+
|
|
164
275
|
/**
|
|
165
276
|
* Expand the approvers on an Approval node into user IDs by querying the
|
|
166
277
|
* graph tables for `team:` / `department:` / `role:` / `manager:` approver
|
|
@@ -351,6 +462,16 @@ export class ApprovalService implements IApprovalService {
|
|
|
351
462
|
const configSnapshot: any = { ...input.config };
|
|
352
463
|
if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
|
|
353
464
|
if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
|
|
465
|
+
// ADR-0044 round numbering: rounds of a revise loop share the run — count
|
|
466
|
+
// this (run, node)'s prior requests; the new one is round N+1. Stamped on
|
|
467
|
+
// the snapshot (precedent: __flowLabel), so no schema migration.
|
|
468
|
+
try {
|
|
469
|
+
const prior = await this.engine.find('sys_approval_request', {
|
|
470
|
+
where: { flow_run_id: input.runId, flow_node_id: input.nodeId }, limit: 500, context: SYSTEM_CTX,
|
|
471
|
+
});
|
|
472
|
+
const n = Array.isArray(prior) ? prior.length : 0;
|
|
473
|
+
if (n > 0) configSnapshot.__round = n + 1;
|
|
474
|
+
} catch { /* round display is best-effort */ }
|
|
354
475
|
const row: any = {
|
|
355
476
|
id,
|
|
356
477
|
process_name: processName,
|
|
@@ -370,6 +491,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
370
491
|
updated_at: now,
|
|
371
492
|
};
|
|
372
493
|
await this.engine.insert('sys_approval_request', row, { context: SYSTEM_CTX });
|
|
494
|
+
await this.syncApproverIndex(id, approvers, ctxOrg, now);
|
|
373
495
|
await this.engine.insert('sys_approval_action', {
|
|
374
496
|
id: uid('aact'), request_id: id, organization_id: ctxOrg,
|
|
375
497
|
step_name: input.nodeId, step_index: 0, action: 'submit',
|
|
@@ -443,6 +565,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
443
565
|
await this.engine.update('sys_approval_request', {
|
|
444
566
|
id: requestId, pending_approvers: stillPending.join(','), updated_at: now,
|
|
445
567
|
}, { context: SYSTEM_CTX });
|
|
568
|
+
await this.syncApproverIndex(requestId, stillPending, org, now);
|
|
446
569
|
const fresh = await this.getRequest(requestId, context);
|
|
447
570
|
return { request: fresh!, runId, nodeId, finalized: false, decision: input.decision };
|
|
448
571
|
}
|
|
@@ -452,6 +575,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
452
575
|
await this.engine.update('sys_approval_request', {
|
|
453
576
|
id: requestId, status: finalStatus, pending_approvers: null, completed_at: now, updated_at: now,
|
|
454
577
|
}, { context: SYSTEM_CTX });
|
|
578
|
+
await this.syncApproverIndex(requestId, [], org, now);
|
|
455
579
|
if (config.approvalStatusField) {
|
|
456
580
|
await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, finalStatus);
|
|
457
581
|
}
|
|
@@ -502,8 +626,14 @@ export class ApprovalService implements IApprovalService {
|
|
|
502
626
|
* Withdraw a pending request (submitter only). Finalises the row as
|
|
503
627
|
* `recalled`, releases the record lock (keyed on pending status), mirrors
|
|
504
628
|
* the status field when configured, and resumes the owning flow run down
|
|
505
|
-
* the `reject` branch with `output.decision = 'recall'` — the
|
|
506
|
-
*
|
|
629
|
+
* the `reject` branch with `output.decision = 'recall'` — leaving the run
|
|
630
|
+
* suspended forever would leak it.
|
|
631
|
+
*
|
|
632
|
+
* ADR-0044: also valid on the LATEST `returned` request of its run — the
|
|
633
|
+
* submitter abandons the revision window instead of resubmitting. The run
|
|
634
|
+
* is then paused at the revise wait node (no reject edge), so it is
|
|
635
|
+
* terminally cancelled via {@link ApprovalResumeSurface.cancelRun} rather
|
|
636
|
+
* than resumed.
|
|
507
637
|
*/
|
|
508
638
|
async recall(
|
|
509
639
|
requestId: string,
|
|
@@ -518,10 +648,16 @@ export class ApprovalService implements IApprovalService {
|
|
|
518
648
|
});
|
|
519
649
|
const raw: any = Array.isArray(rawRows) ? rawRows[0] : null;
|
|
520
650
|
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
521
|
-
|
|
651
|
+
const inReviseWindow = raw.status === 'returned';
|
|
652
|
+
if (raw.status !== 'pending' && !inReviseWindow) {
|
|
653
|
+
throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
654
|
+
}
|
|
522
655
|
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
|
|
523
656
|
throw new Error(`FORBIDDEN: only the submitter may recall this request`);
|
|
524
657
|
}
|
|
658
|
+
// A returned request is only recallable while it is still the run's live
|
|
659
|
+
// frontier — a resubmitted (or later-node) request supersedes it.
|
|
660
|
+
if (inReviseWindow) await this.assertLatestForRun(raw);
|
|
525
661
|
|
|
526
662
|
const config = parseJson<ApprovalNodeConfig>(raw.node_config_json, { approvers: [], behavior: 'first_response' } as any);
|
|
527
663
|
const org = raw.organization_id ?? null;
|
|
@@ -538,12 +674,25 @@ export class ApprovalService implements IApprovalService {
|
|
|
538
674
|
await this.engine.update('sys_approval_request', {
|
|
539
675
|
id: requestId, status: 'recalled', pending_approvers: null, completed_at: now, updated_at: now,
|
|
540
676
|
}, { context: SYSTEM_CTX });
|
|
677
|
+
await this.syncApproverIndex(requestId, [], org, now);
|
|
541
678
|
if (config.approvalStatusField) {
|
|
542
679
|
await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, 'recalled');
|
|
543
680
|
}
|
|
544
681
|
|
|
545
682
|
let resumed = false;
|
|
546
|
-
if (
|
|
683
|
+
if (inReviseWindow) {
|
|
684
|
+
// ADR-0044: the run is paused at the revise wait node, which has no
|
|
685
|
+
// reject out-edge to resume down — terminally cancel it instead.
|
|
686
|
+
if (runId && typeof this.automation?.cancelRun === 'function') {
|
|
687
|
+
try {
|
|
688
|
+
await this.automation.cancelRun(runId, `approval request ${requestId} recalled during revision`);
|
|
689
|
+
} catch (err: any) {
|
|
690
|
+
this.logger?.warn?.('[approvals] cancelRun after revise-window recall failed', {
|
|
691
|
+
request: requestId, run: runId, error: err?.message ?? String(err),
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
} else if (runId && typeof this.automation?.resume === 'function') {
|
|
547
696
|
try {
|
|
548
697
|
await this.automation.resume(runId, {
|
|
549
698
|
branchLabel: APPROVAL_BRANCH_LABELS.reject,
|
|
@@ -561,6 +710,704 @@ export class ApprovalService implements IApprovalService {
|
|
|
561
710
|
return { request: fresh!, runId, resumed };
|
|
562
711
|
}
|
|
563
712
|
|
|
713
|
+
// ── Send back for revision / resubmit (ADR-0044) ─────────────
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* ADR-0044 send back for revision. Finalises the pending request as
|
|
717
|
+
* `returned` (a third terminal state — approver-initiated rework, distinct
|
|
718
|
+
* from submitter-initiated `recalled`) and resumes the owning flow run down
|
|
719
|
+
* its `revise` edge to a wait point: the record lock (keyed on `pending`)
|
|
720
|
+
* releases, the submitter reworks the data, then {@link resubmit}s.
|
|
721
|
+
*
|
|
722
|
+
* Requires the approval node to declare a `revise` out-edge — validated
|
|
723
|
+
* BEFORE any mutation, because resuming with an unmatched `branchLabel`
|
|
724
|
+
* falls back to *all* out-edges. Past the node's `maxRevisions` budget the
|
|
725
|
+
* request auto-rejects instead (resumes down `reject` with
|
|
726
|
+
* `output.autoRejected = true`) so instances cannot orbit forever.
|
|
727
|
+
*/
|
|
728
|
+
async sendBack(
|
|
729
|
+
requestId: string,
|
|
730
|
+
input: ApprovalSendBackInput,
|
|
731
|
+
context: SharingExecutionContext,
|
|
732
|
+
): Promise<ApprovalSendBackResult> {
|
|
733
|
+
if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
|
|
734
|
+
const raw = await this.loadPendingRow(requestId);
|
|
735
|
+
const pending = csvSplit(raw.pending_approvers);
|
|
736
|
+
if (!context.isSystem && !pending.includes(input.actorId)) {
|
|
737
|
+
throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const config = parseJson<ApprovalNodeConfig>(raw.node_config_json, { approvers: [], behavior: 'first_response' } as any);
|
|
741
|
+
const org = raw.organization_id ?? null;
|
|
742
|
+
const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null;
|
|
743
|
+
const runId: string | null = raw.flow_run_id ?? null;
|
|
744
|
+
|
|
745
|
+
await this.assertReviseEdge(raw, nodeId);
|
|
746
|
+
|
|
747
|
+
const now = this.clock.now().toISOString();
|
|
748
|
+
const maxRevisions = typeof (config as any).maxRevisions === 'number' ? (config as any).maxRevisions : 3;
|
|
749
|
+
let priorSendBacks = 0;
|
|
750
|
+
if (runId && nodeId) {
|
|
751
|
+
const siblings = await this.engine.find('sys_approval_request', {
|
|
752
|
+
where: { flow_run_id: runId, flow_node_id: nodeId, status: 'returned' }, limit: 500, context: SYSTEM_CTX,
|
|
753
|
+
});
|
|
754
|
+
priorSendBacks = Array.isArray(siblings) ? siblings.length : 0;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Audit the revise intent first (audit-first, like decideNode) — on the
|
|
758
|
+
// auto-reject path the trail then reads `revise → reject`, preserving
|
|
759
|
+
// what the approver actually asked for.
|
|
760
|
+
await this.engine.insert('sys_approval_action', {
|
|
761
|
+
id: uid('aact'), request_id: requestId, organization_id: org,
|
|
762
|
+
step_name: nodeId, step_index: 0, action: 'revise',
|
|
763
|
+
actor_id: input.actorId, comment: input.comment ?? null, created_at: now,
|
|
764
|
+
}, { context: SYSTEM_CTX });
|
|
765
|
+
|
|
766
|
+
if (priorSendBacks >= maxRevisions) {
|
|
767
|
+
// Revision budget exhausted — auto-reject (ADR-0044 loop guard).
|
|
768
|
+
await this.engine.insert('sys_approval_action', {
|
|
769
|
+
id: uid('aact'), request_id: requestId, organization_id: org,
|
|
770
|
+
step_name: nodeId, step_index: 0, action: 'reject',
|
|
771
|
+
actor_id: input.actorId,
|
|
772
|
+
comment: `Auto-rejected: revision limit (${maxRevisions}) exceeded`, created_at: now,
|
|
773
|
+
}, { context: SYSTEM_CTX });
|
|
774
|
+
await this.engine.update('sys_approval_request', {
|
|
775
|
+
id: requestId, status: 'rejected', pending_approvers: null, completed_at: now, updated_at: now,
|
|
776
|
+
}, { context: SYSTEM_CTX });
|
|
777
|
+
await this.syncApproverIndex(requestId, [], org, now);
|
|
778
|
+
if (config.approvalStatusField) {
|
|
779
|
+
await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, 'rejected');
|
|
780
|
+
}
|
|
781
|
+
let resumed = false;
|
|
782
|
+
if (runId && typeof this.automation?.resume === 'function') {
|
|
783
|
+
try {
|
|
784
|
+
await this.automation.resume(runId, {
|
|
785
|
+
branchLabel: APPROVAL_BRANCH_LABELS.reject,
|
|
786
|
+
output: { decision: 'reject', autoRejected: true, requestId },
|
|
787
|
+
});
|
|
788
|
+
resumed = true;
|
|
789
|
+
} catch (err: any) {
|
|
790
|
+
this.logger?.warn?.('[approvals] resume after auto-reject failed', {
|
|
791
|
+
request: requestId, run: runId, error: err?.message ?? String(err),
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if (raw.submitter_id) {
|
|
796
|
+
await this.notify({
|
|
797
|
+
topic: 'approval.returned',
|
|
798
|
+
audience: [String(raw.submitter_id)],
|
|
799
|
+
actorId: input.actorId,
|
|
800
|
+
source: { object: 'sys_approval_request', id: requestId },
|
|
801
|
+
payload: {
|
|
802
|
+
title: 'Approval auto-rejected',
|
|
803
|
+
message: `Your ${raw.object_name}/${raw.record_id} exceeded the revision limit (${maxRevisions}) and was rejected.`,
|
|
804
|
+
actionUrl: '/system/approvals',
|
|
805
|
+
},
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
const fresh = await this.getRequest(requestId, context);
|
|
809
|
+
return { request: fresh!, runId, resumed, autoRejected: true };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
await this.engine.update('sys_approval_request', {
|
|
813
|
+
id: requestId, status: 'returned', pending_approvers: null, completed_at: now, updated_at: now,
|
|
814
|
+
}, { context: SYSTEM_CTX });
|
|
815
|
+
await this.syncApproverIndex(requestId, [], org, now);
|
|
816
|
+
if (config.approvalStatusField) {
|
|
817
|
+
await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, 'returned');
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
let resumed = false;
|
|
821
|
+
if (runId && typeof this.automation?.resume === 'function') {
|
|
822
|
+
try {
|
|
823
|
+
await this.automation.resume(runId, {
|
|
824
|
+
branchLabel: APPROVAL_BRANCH_LABELS.revise,
|
|
825
|
+
output: { decision: 'revise', requestId },
|
|
826
|
+
});
|
|
827
|
+
resumed = true;
|
|
828
|
+
} catch (err: any) {
|
|
829
|
+
this.logger?.warn?.('[approvals] resume after send-back failed', {
|
|
830
|
+
request: requestId, run: runId, error: err?.message ?? String(err),
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (raw.submitter_id) {
|
|
836
|
+
await this.notify({
|
|
837
|
+
topic: 'approval.returned',
|
|
838
|
+
audience: [String(raw.submitter_id)],
|
|
839
|
+
actorId: input.actorId,
|
|
840
|
+
source: { object: 'sys_approval_request', id: requestId },
|
|
841
|
+
payload: {
|
|
842
|
+
title: 'Sent back for revision',
|
|
843
|
+
message: input.comment?.trim() || `Your ${raw.object_name}/${raw.record_id} needs rework before it can be approved.`,
|
|
844
|
+
actionUrl: '/system/approvals',
|
|
845
|
+
},
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const fresh = await this.getRequest(requestId, context);
|
|
850
|
+
return { request: fresh!, runId, resumed };
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* ADR-0044 resubmit after rework. Valid on the LATEST `returned` request of
|
|
855
|
+
* its run, submitter-only. Audits `resubmit` on the returned (round-N)
|
|
856
|
+
* request and resumes the run from the revise wait node; traversal walks
|
|
857
|
+
* the declared back-edge into the approval node, whose executor opens the
|
|
858
|
+
* round-N+1 request — fresh approver slate, record re-locks.
|
|
859
|
+
*/
|
|
860
|
+
async resubmit(
|
|
861
|
+
requestId: string,
|
|
862
|
+
input: ApprovalResubmitInput,
|
|
863
|
+
context: SharingExecutionContext,
|
|
864
|
+
): Promise<ApprovalResubmitResult> {
|
|
865
|
+
if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
|
|
866
|
+
const rawRows = await this.engine.find('sys_approval_request', {
|
|
867
|
+
where: { id: requestId }, limit: 1, context: SYSTEM_CTX,
|
|
868
|
+
});
|
|
869
|
+
const raw: any = Array.isArray(rawRows) ? rawRows[0] : null;
|
|
870
|
+
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
871
|
+
if (raw.status !== 'returned') {
|
|
872
|
+
throw new Error(`INVALID_STATE: request is ${raw.status} (resubmit applies to returned requests)`);
|
|
873
|
+
}
|
|
874
|
+
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
|
|
875
|
+
throw new Error('FORBIDDEN: only the submitter may resubmit');
|
|
876
|
+
}
|
|
877
|
+
await this.assertLatestForRun(raw);
|
|
878
|
+
|
|
879
|
+
// A colliding pending request on the same record (e.g. a record-change
|
|
880
|
+
// trigger re-fired off an edit made inside the revise window) would make
|
|
881
|
+
// the approval node's re-entry fail AFTER the engine consumed the
|
|
882
|
+
// suspension — permanently killing the run. Refuse up front instead; the
|
|
883
|
+
// submitter resolves the collision (recall the other request) first.
|
|
884
|
+
const colliding = await this.engine.find('sys_approval_request', {
|
|
885
|
+
where: { object_name: raw.object_name, record_id: raw.record_id, status: 'pending' },
|
|
886
|
+
limit: 1, context: SYSTEM_CTX,
|
|
887
|
+
});
|
|
888
|
+
if (Array.isArray(colliding) && colliding[0]) {
|
|
889
|
+
throw new Error(
|
|
890
|
+
`DUPLICATE_REQUEST: another approval request is already pending on ${raw.object_name}/${raw.record_id} — resolve it before resubmitting`,
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const org = raw.organization_id ?? null;
|
|
895
|
+
const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null;
|
|
896
|
+
const runId: string | null = raw.flow_run_id ?? null;
|
|
897
|
+
const now = this.clock.now().toISOString();
|
|
898
|
+
|
|
899
|
+
await this.engine.insert('sys_approval_action', {
|
|
900
|
+
id: uid('aact'), request_id: requestId, organization_id: org,
|
|
901
|
+
step_name: nodeId, step_index: 0, action: 'resubmit',
|
|
902
|
+
actor_id: input.actorId, comment: input.comment ?? null, created_at: now,
|
|
903
|
+
}, { context: SYSTEM_CTX });
|
|
904
|
+
|
|
905
|
+
// The next round only exists if this resume lands — surface `resumed`
|
|
906
|
+
// honestly so a stuck run is visible instead of silently swallowed.
|
|
907
|
+
let resumed = false;
|
|
908
|
+
if (runId && typeof this.automation?.resume === 'function') {
|
|
909
|
+
try {
|
|
910
|
+
await this.automation.resume(runId, {
|
|
911
|
+
branchLabel: APPROVAL_BRANCH_LABELS.resubmit,
|
|
912
|
+
output: { resubmitted: true, requestId },
|
|
913
|
+
});
|
|
914
|
+
resumed = true;
|
|
915
|
+
} catch (err: any) {
|
|
916
|
+
this.logger?.warn?.('[approvals] resume after resubmit failed', {
|
|
917
|
+
request: requestId, run: runId, error: err?.message ?? String(err),
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const fresh = await this.getRequest(requestId, context);
|
|
923
|
+
return { request: fresh!, runId, resumed };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* ADR-0044 guard: the flow's approval node must declare a `revise`
|
|
928
|
+
* out-edge before send-back is allowed — the engine's branch-label fallback
|
|
929
|
+
* (no matching label ⇒ ALL out-edges) must never be reachable from a user
|
|
930
|
+
* action.
|
|
931
|
+
*/
|
|
932
|
+
private async assertReviseEdge(raw: any, nodeId: string | null): Promise<void> {
|
|
933
|
+
const processName = String(raw.process_name ?? '');
|
|
934
|
+
const flowName = processName.startsWith('flow:') ? processName.slice('flow:'.length) : undefined;
|
|
935
|
+
if (!flowName || !nodeId || typeof this.automation?.getFlow !== 'function') {
|
|
936
|
+
throw new Error('VALIDATION_FAILED: send-back requires the owning flow definition (automation engine unavailable)');
|
|
937
|
+
}
|
|
938
|
+
const flow: any = await this.automation.getFlow(flowName);
|
|
939
|
+
const hasRevise = Array.isArray(flow?.edges)
|
|
940
|
+
&& flow.edges.some((e: any) => e?.source === nodeId && e?.label === APPROVAL_BRANCH_LABELS.revise);
|
|
941
|
+
if (!hasRevise) {
|
|
942
|
+
throw new Error(
|
|
943
|
+
`VALIDATION_FAILED: approval node '${nodeId}' has no '${APPROVAL_BRANCH_LABELS.revise}' out-edge — ` +
|
|
944
|
+
'the flow does not support send-back for revision',
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* ADR-0044 guard: a `returned` request is only actionable (resubmit /
|
|
951
|
+
* recall) while it is still the newest request on its run — a later round
|
|
952
|
+
* or a later node's request supersedes it.
|
|
953
|
+
*/
|
|
954
|
+
private async assertLatestForRun(raw: any): Promise<void> {
|
|
955
|
+
const runId = raw.flow_run_id;
|
|
956
|
+
if (!runId) return;
|
|
957
|
+
// SortNode's key is `order` (spec/data/query.zod.ts) — `direction` would
|
|
958
|
+
// silently default to ascending and return the OLDEST row.
|
|
959
|
+
const rows = await this.engine.find('sys_approval_request', {
|
|
960
|
+
where: { flow_run_id: runId },
|
|
961
|
+
orderBy: [{ field: 'created_at', order: 'desc' }], limit: 1, context: SYSTEM_CTX,
|
|
962
|
+
});
|
|
963
|
+
const latest: any = Array.isArray(rows) ? rows[0] : null;
|
|
964
|
+
if (latest && String(latest.id) !== String(raw.id)) {
|
|
965
|
+
throw new Error('INVALID_STATE: a newer approval request supersedes this one');
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// ── Thread interactions (no flow movement) ───────────────────
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Hand a pending-approver slot to someone else. `from` defaults to the
|
|
973
|
+
* actor itself; the actor must hold the slot being handed over (or be a
|
|
974
|
+
* system caller). Audits `reassign` and notifies the new approver.
|
|
975
|
+
*/
|
|
976
|
+
async reassign(
|
|
977
|
+
requestId: string,
|
|
978
|
+
input: { actorId: string; to: string; from?: string; comment?: string },
|
|
979
|
+
context: SharingExecutionContext,
|
|
980
|
+
): Promise<{ request: ApprovalRequestRow }> {
|
|
981
|
+
if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
|
|
982
|
+
const to = String(input?.to ?? '').trim();
|
|
983
|
+
if (!to) throw new Error('VALIDATION_FAILED: `to` (new approver) is required');
|
|
984
|
+
const raw = await this.loadPendingRow(requestId);
|
|
985
|
+
|
|
986
|
+
const pending = csvSplit(raw.pending_approvers);
|
|
987
|
+
const from = String(input.from ?? input.actorId).trim();
|
|
988
|
+
if (!pending.includes(from)) {
|
|
989
|
+
throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
|
|
990
|
+
}
|
|
991
|
+
if (!context.isSystem && input.actorId !== from && !pending.includes(input.actorId)) {
|
|
992
|
+
throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
|
|
993
|
+
}
|
|
994
|
+
if (pending.includes(to)) {
|
|
995
|
+
throw new Error(`VALIDATION_FAILED: '${to}' is already a pending approver`);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const next = pending.map(a => (a === from ? to : a));
|
|
999
|
+
const now = this.clock.now().toISOString();
|
|
1000
|
+
// Audit first, then mutate — mirrors decideNode(), so a failed audit
|
|
1001
|
+
// write can never leave a moved slot without a trail.
|
|
1002
|
+
await this.engine.insert('sys_approval_action', {
|
|
1003
|
+
id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
|
|
1004
|
+
step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'reassign',
|
|
1005
|
+
actor_id: input.actorId, comment: input.comment ?? `${from} → ${to}`, created_at: now,
|
|
1006
|
+
}, { context: SYSTEM_CTX });
|
|
1007
|
+
await this.engine.update('sys_approval_request', {
|
|
1008
|
+
id: requestId, pending_approvers: next.join(','), updated_at: now,
|
|
1009
|
+
}, { context: SYSTEM_CTX });
|
|
1010
|
+
await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
|
|
1011
|
+
|
|
1012
|
+
await this.notify({
|
|
1013
|
+
topic: 'approval.reassigned',
|
|
1014
|
+
audience: [to],
|
|
1015
|
+
actorId: input.actorId,
|
|
1016
|
+
source: { object: 'sys_approval_request', id: requestId },
|
|
1017
|
+
dedupKey: `approval-reassign-${requestId}-${to}`,
|
|
1018
|
+
payload: {
|
|
1019
|
+
title: 'Approval handed to you',
|
|
1020
|
+
message: `You are now an approver on ${raw.object_name}/${raw.record_id}.`,
|
|
1021
|
+
actionUrl: '/system/approvals',
|
|
1022
|
+
},
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
const fresh = await this.getRequest(requestId, context);
|
|
1026
|
+
return { request: fresh! };
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Submitter nudge — notify every pending approver. Throttled to one
|
|
1031
|
+
* reminder per {@link REMIND_COOLDOWN_MS} per request.
|
|
1032
|
+
*/
|
|
1033
|
+
async remind(
|
|
1034
|
+
requestId: string,
|
|
1035
|
+
input: { actorId: string; comment?: string },
|
|
1036
|
+
context: SharingExecutionContext,
|
|
1037
|
+
): Promise<{ request: ApprovalRequestRow; notified: number }> {
|
|
1038
|
+
if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
|
|
1039
|
+
const raw = await this.loadPendingRow(requestId);
|
|
1040
|
+
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
|
|
1041
|
+
throw new Error('FORBIDDEN: only the submitter may send reminders');
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const acts = await this.engine.find('sys_approval_action', {
|
|
1045
|
+
where: { request_id: requestId, action: 'remind' },
|
|
1046
|
+
orderBy: [{ field: 'created_at', order: 'desc' }], limit: 1, context: SYSTEM_CTX,
|
|
1047
|
+
});
|
|
1048
|
+
const last: any = Array.isArray(acts) ? acts[0] : null;
|
|
1049
|
+
const now = this.clock.now();
|
|
1050
|
+
if (last?.created_at && now.getTime() - Date.parse(last.created_at) < REMIND_COOLDOWN_MS) {
|
|
1051
|
+
throw new Error('THROTTLED: a reminder was already sent recently');
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const pending = csvSplit(raw.pending_approvers);
|
|
1055
|
+
const nowIso = now.toISOString();
|
|
1056
|
+
await this.engine.insert('sys_approval_action', {
|
|
1057
|
+
id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
|
|
1058
|
+
step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'remind',
|
|
1059
|
+
actor_id: input.actorId, comment: input.comment ?? null, created_at: nowIso,
|
|
1060
|
+
}, { context: SYSTEM_CTX });
|
|
1061
|
+
|
|
1062
|
+
// Per-approver fan-out: concrete identities (user ids / emails) each get
|
|
1063
|
+
// their OWN one-tap approve/reject links (ADR-0043); `role:*`-style
|
|
1064
|
+
// literals can't carry a personal token and fall back to a plain nudge.
|
|
1065
|
+
let notified = 0;
|
|
1066
|
+
const concrete = pending.filter(a => a && !a.includes(':'));
|
|
1067
|
+
const literals = pending.filter(a => a && a.includes(':'));
|
|
1068
|
+
for (const approver of concrete) {
|
|
1069
|
+
try {
|
|
1070
|
+
const tokens = await this.issueActionTokens(requestId, approver);
|
|
1071
|
+
notified += await this.notify({
|
|
1072
|
+
topic: 'approval.reminder',
|
|
1073
|
+
audience: [approver],
|
|
1074
|
+
actorId: input.actorId,
|
|
1075
|
+
source: { object: 'sys_approval_request', id: requestId },
|
|
1076
|
+
dedupKey: `approval-remind-${requestId}-${nowIso}-${approver}`,
|
|
1077
|
+
payload: {
|
|
1078
|
+
title: 'Approval reminder',
|
|
1079
|
+
message: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`,
|
|
1080
|
+
actionUrl: '/system/approvals',
|
|
1081
|
+
actions: [
|
|
1082
|
+
{ label: 'Approve', url: this.actionLinkUrl(tokens.approve) },
|
|
1083
|
+
{ label: 'Reject', url: this.actionLinkUrl(tokens.reject) },
|
|
1084
|
+
],
|
|
1085
|
+
},
|
|
1086
|
+
});
|
|
1087
|
+
} catch (err: any) {
|
|
1088
|
+
this.logger?.warn?.('[approvals] reminder with action links failed', {
|
|
1089
|
+
request: requestId, approver, error: err?.message ?? String(err),
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
if (literals.length) {
|
|
1094
|
+
notified += await this.notify({
|
|
1095
|
+
topic: 'approval.reminder',
|
|
1096
|
+
audience: literals,
|
|
1097
|
+
actorId: input.actorId,
|
|
1098
|
+
source: { object: 'sys_approval_request', id: requestId },
|
|
1099
|
+
dedupKey: `approval-remind-${requestId}-${nowIso}`,
|
|
1100
|
+
payload: {
|
|
1101
|
+
title: 'Approval reminder',
|
|
1102
|
+
message: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`,
|
|
1103
|
+
actionUrl: '/system/approvals',
|
|
1104
|
+
},
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const fresh = await this.getRequest(requestId, context);
|
|
1109
|
+
return { request: fresh!, notified };
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// ── Actionable links (ADR-0043) ──────────────────────────────
|
|
1113
|
+
|
|
1114
|
+
/** Build the session-less confirm-page URL for a raw token. */
|
|
1115
|
+
actionLinkUrl(rawToken: string): string {
|
|
1116
|
+
return `${this.publicBaseUrl}/api/v1/approvals/act?token=${encodeURIComponent(rawToken)}`;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* Issue one-tap approve/reject tokens for one approver on one pending
|
|
1121
|
+
* request. Raw tokens are returned ONCE; only SHA-256 hashes are stored
|
|
1122
|
+
* (`sys_approval_token`), so a DB leak yields no usable links.
|
|
1123
|
+
*/
|
|
1124
|
+
async issueActionTokens(
|
|
1125
|
+
requestId: string,
|
|
1126
|
+
approverId: string,
|
|
1127
|
+
opts?: { ttlMs?: number },
|
|
1128
|
+
): Promise<{ approve: string; reject: string }> {
|
|
1129
|
+
if (!approverId?.trim()) throw new Error('VALIDATION_FAILED: approverId is required');
|
|
1130
|
+
const raw = await this.loadPendingRow(requestId);
|
|
1131
|
+
const pending = csvSplit(raw.pending_approvers);
|
|
1132
|
+
if (!pending.includes(approverId)) {
|
|
1133
|
+
throw new Error(`FORBIDDEN: '${approverId}' is not a pending approver on this request`);
|
|
1134
|
+
}
|
|
1135
|
+
const now = this.clock.now();
|
|
1136
|
+
const expires = new Date(now.getTime() + (opts?.ttlMs ?? ACTION_TOKEN_TTL_MS)).toISOString();
|
|
1137
|
+
const out = { approve: '', reject: '' };
|
|
1138
|
+
for (const action of ['approve', 'reject'] as const) {
|
|
1139
|
+
const rawToken = randomBytes(32).toString('base64url');
|
|
1140
|
+
await this.engine.insert('sys_approval_token', {
|
|
1141
|
+
id: uid('atok'),
|
|
1142
|
+
organization_id: raw.organization_id ?? null,
|
|
1143
|
+
token_hash: createHash('sha256').update(rawToken).digest('hex'),
|
|
1144
|
+
request_id: requestId,
|
|
1145
|
+
action,
|
|
1146
|
+
approver_id: approverId,
|
|
1147
|
+
expires_at: expires,
|
|
1148
|
+
consumed_at: null,
|
|
1149
|
+
created_at: now.toISOString(),
|
|
1150
|
+
}, { context: SYSTEM_CTX });
|
|
1151
|
+
out[action] = rawToken;
|
|
1152
|
+
}
|
|
1153
|
+
return out;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/** Shared validation chain for peek/redeem. Returns the token row when live. */
|
|
1157
|
+
private async resolveActionToken(rawToken: string): Promise<
|
|
1158
|
+
{ ok: true; token: any; request: ApprovalRequestRow } | Extract<ActionTokenOutcome, { ok: false }>
|
|
1159
|
+
> {
|
|
1160
|
+
const trimmed = rawToken?.trim();
|
|
1161
|
+
if (!trimmed) return { ok: false, reason: 'invalid' };
|
|
1162
|
+
const hash = createHash('sha256').update(trimmed).digest('hex');
|
|
1163
|
+
const rows = await this.engine.find('sys_approval_token', {
|
|
1164
|
+
where: { token_hash: hash }, limit: 1, context: SYSTEM_CTX,
|
|
1165
|
+
});
|
|
1166
|
+
const token: any = Array.isArray(rows) ? rows[0] : null;
|
|
1167
|
+
if (!token) return { ok: false, reason: 'invalid' };
|
|
1168
|
+
if (token.consumed_at) return { ok: false, reason: 'consumed' };
|
|
1169
|
+
if (Date.parse(token.expires_at) < this.clock.now().getTime()) {
|
|
1170
|
+
return { ok: false, reason: 'expired' };
|
|
1171
|
+
}
|
|
1172
|
+
const request = await this.getRequest(token.request_id, SYSTEM_CTX as unknown as SharingExecutionContext);
|
|
1173
|
+
if (!request || request.status !== 'pending') {
|
|
1174
|
+
return { ok: false, reason: 'not_pending', request: request ?? undefined };
|
|
1175
|
+
}
|
|
1176
|
+
if (!(request.pending_approvers ?? []).includes(token.approver_id)) {
|
|
1177
|
+
// Reassigned away / slot consumed by a unanimous round — the link died
|
|
1178
|
+
// with the slot (ADR-0043 invalidation row).
|
|
1179
|
+
return { ok: false, reason: 'not_approver', request };
|
|
1180
|
+
}
|
|
1181
|
+
return { ok: true, token, request };
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/** GET confirm page: validate WITHOUT consuming — never mutates. */
|
|
1185
|
+
async peekActionToken(rawToken: string): Promise<ActionTokenOutcome> {
|
|
1186
|
+
const res = await this.resolveActionToken(rawToken);
|
|
1187
|
+
if (!res.ok) return res;
|
|
1188
|
+
return { ok: true, action: res.token.action, request: res.request, approverId: res.token.approver_id };
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* POST redemption: consume the token FIRST (a failed decide still burns
|
|
1193
|
+
* it — replay-safe), then decide as the bound approver.
|
|
1194
|
+
*/
|
|
1195
|
+
async redeemActionToken(rawToken: string): Promise<ActionTokenOutcome> {
|
|
1196
|
+
const res = await this.resolveActionToken(rawToken);
|
|
1197
|
+
if (!res.ok) return res;
|
|
1198
|
+
await this.engine.update('sys_approval_token', {
|
|
1199
|
+
id: res.token.id, consumed_at: this.clock.now().toISOString(),
|
|
1200
|
+
}, { context: SYSTEM_CTX });
|
|
1201
|
+
const out = await this.decide(res.token.request_id, {
|
|
1202
|
+
decision: res.token.action,
|
|
1203
|
+
actorId: res.token.approver_id,
|
|
1204
|
+
comment: 'Via action link',
|
|
1205
|
+
}, SYSTEM_CTX as unknown as SharingExecutionContext);
|
|
1206
|
+
return { ok: true, action: res.token.action, request: out.request, approverId: res.token.approver_id };
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Approver asks the submitter for more information. The request stays
|
|
1211
|
+
* pending — a thread interaction, not a flow decision.
|
|
1212
|
+
*/
|
|
1213
|
+
async requestInfo(
|
|
1214
|
+
requestId: string,
|
|
1215
|
+
input: { actorId: string; comment: string },
|
|
1216
|
+
context: SharingExecutionContext,
|
|
1217
|
+
): Promise<{ request: ApprovalRequestRow }> {
|
|
1218
|
+
if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
|
|
1219
|
+
if (!input?.comment?.trim()) throw new Error('VALIDATION_FAILED: comment is required');
|
|
1220
|
+
const raw = await this.loadPendingRow(requestId);
|
|
1221
|
+
const pending = csvSplit(raw.pending_approvers);
|
|
1222
|
+
if (!context.isSystem && !pending.includes(input.actorId)) {
|
|
1223
|
+
throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const now = this.clock.now().toISOString();
|
|
1227
|
+
await this.engine.insert('sys_approval_action', {
|
|
1228
|
+
id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
|
|
1229
|
+
step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'request_info',
|
|
1230
|
+
actor_id: input.actorId, comment: input.comment.trim(), created_at: now,
|
|
1231
|
+
}, { context: SYSTEM_CTX });
|
|
1232
|
+
|
|
1233
|
+
if (raw.submitter_id) {
|
|
1234
|
+
await this.notify({
|
|
1235
|
+
topic: 'approval.request_info',
|
|
1236
|
+
audience: [String(raw.submitter_id)],
|
|
1237
|
+
actorId: input.actorId,
|
|
1238
|
+
source: { object: 'sys_approval_request', id: requestId },
|
|
1239
|
+
payload: {
|
|
1240
|
+
title: 'More information requested',
|
|
1241
|
+
message: input.comment.trim(),
|
|
1242
|
+
actionUrl: '/system/approvals',
|
|
1243
|
+
},
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const fresh = await this.getRequest(requestId, context);
|
|
1248
|
+
return { request: fresh! };
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/** Free-form reply on the thread (submitter or any pending approver). */
|
|
1252
|
+
async comment(
|
|
1253
|
+
requestId: string,
|
|
1254
|
+
input: { actorId: string; comment: string },
|
|
1255
|
+
context: SharingExecutionContext,
|
|
1256
|
+
): Promise<{ request: ApprovalRequestRow }> {
|
|
1257
|
+
if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
|
|
1258
|
+
if (!input?.comment?.trim()) throw new Error('VALIDATION_FAILED: comment is required');
|
|
1259
|
+
const raw = await this.loadPendingRow(requestId);
|
|
1260
|
+
const pending = csvSplit(raw.pending_approvers);
|
|
1261
|
+
const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(input.actorId);
|
|
1262
|
+
if (!context.isSystem && !isSubmitter && !pending.includes(input.actorId)) {
|
|
1263
|
+
throw new Error(`FORBIDDEN: actor '${input.actorId}' is not on this request`);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const now = this.clock.now().toISOString();
|
|
1267
|
+
await this.engine.insert('sys_approval_action', {
|
|
1268
|
+
id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
|
|
1269
|
+
step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'comment',
|
|
1270
|
+
actor_id: input.actorId, comment: input.comment.trim(), created_at: now,
|
|
1271
|
+
}, { context: SYSTEM_CTX });
|
|
1272
|
+
|
|
1273
|
+
// Notify the other side of the thread.
|
|
1274
|
+
const audience = isSubmitter ? pending : [String(raw.submitter_id ?? '')].filter(Boolean);
|
|
1275
|
+
await this.notify({
|
|
1276
|
+
topic: 'approval.comment',
|
|
1277
|
+
audience,
|
|
1278
|
+
actorId: input.actorId,
|
|
1279
|
+
source: { object: 'sys_approval_request', id: requestId },
|
|
1280
|
+
payload: {
|
|
1281
|
+
title: 'New comment on an approval',
|
|
1282
|
+
message: input.comment.trim(),
|
|
1283
|
+
actionUrl: '/system/approvals',
|
|
1284
|
+
},
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
const fresh = await this.getRequest(requestId, context);
|
|
1288
|
+
return { request: fresh! };
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// ── SLA escalation (ADR-0042) ─────────────────────────────────
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* One escalation sweep: every *pending* request whose node config declares
|
|
1295
|
+
* `escalation.timeoutHours` and whose deadline has passed is escalated
|
|
1296
|
+
* **at most once, ever** — the `escalate` audit row is the idempotency
|
|
1297
|
+
* marker, written before any mutation (audit-first, like reassign). One
|
|
1298
|
+
* bad row never stops the sweep.
|
|
1299
|
+
*/
|
|
1300
|
+
async runEscalations(): Promise<{ scanned: number; escalated: number }> {
|
|
1301
|
+
let rows: any[] = [];
|
|
1302
|
+
try {
|
|
1303
|
+
rows = await this.engine.find('sys_approval_request', {
|
|
1304
|
+
where: { status: 'pending' }, limit: 500, context: SYSTEM_CTX,
|
|
1305
|
+
}) ?? [];
|
|
1306
|
+
} catch (err: any) {
|
|
1307
|
+
this.logger?.warn?.('[approvals] escalation scan failed to list requests', {
|
|
1308
|
+
error: err?.message ?? String(err),
|
|
1309
|
+
});
|
|
1310
|
+
return { scanned: 0, escalated: 0 };
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
let escalated = 0;
|
|
1314
|
+
for (const raw of rows) {
|
|
1315
|
+
try {
|
|
1316
|
+
const cfg = parseJson<any>(raw.node_config_json, undefined);
|
|
1317
|
+
const esc = cfg?.escalation;
|
|
1318
|
+
if (!esc || typeof esc.timeoutHours !== 'number' || esc.timeoutHours <= 0) continue;
|
|
1319
|
+
const due = slaDueAt(raw.created_at, cfg);
|
|
1320
|
+
if (!due || Date.parse(due) > this.clock.now().getTime()) continue;
|
|
1321
|
+
|
|
1322
|
+
// Single-shot: a prior 'escalate' action means this request is done.
|
|
1323
|
+
const prior = await this.engine.find('sys_approval_action', {
|
|
1324
|
+
where: { request_id: raw.id, action: 'escalate' }, limit: 1, context: SYSTEM_CTX,
|
|
1325
|
+
});
|
|
1326
|
+
if (Array.isArray(prior) && prior[0]) continue;
|
|
1327
|
+
|
|
1328
|
+
await this.escalateRequest(raw, esc);
|
|
1329
|
+
escalated++;
|
|
1330
|
+
} catch (err: any) {
|
|
1331
|
+
this.logger?.warn?.('[approvals] escalation failed for request', {
|
|
1332
|
+
request: raw?.id, error: err?.message ?? String(err),
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
if (escalated > 0) {
|
|
1337
|
+
this.logger?.info?.('[approvals] SLA escalation sweep', { scanned: rows.length, escalated });
|
|
1338
|
+
}
|
|
1339
|
+
return { scanned: rows.length, escalated };
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/** Execute the configured escalation action for one overdue request. */
|
|
1343
|
+
private async escalateRequest(raw: any, esc: any): Promise<void> {
|
|
1344
|
+
const action: string = esc.action ?? 'notify';
|
|
1345
|
+
const escalateTo: string | undefined =
|
|
1346
|
+
typeof esc.escalateTo === 'string' && esc.escalateTo.trim() ? esc.escalateTo.trim() : undefined;
|
|
1347
|
+
const now = this.clock.now().toISOString();
|
|
1348
|
+
const pending = csvSplit(raw.pending_approvers);
|
|
1349
|
+
|
|
1350
|
+
// Audit first — this row IS the idempotency marker (ADR-0042 §1).
|
|
1351
|
+
await this.engine.insert('sys_approval_action', {
|
|
1352
|
+
id: uid('aact'), request_id: raw.id, organization_id: raw.organization_id ?? null,
|
|
1353
|
+
step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'escalate',
|
|
1354
|
+
actor_id: SLA_ACTOR_ID,
|
|
1355
|
+
comment: `${action}${escalateTo ? ` → ${escalateTo}` : ''}`,
|
|
1356
|
+
created_at: now,
|
|
1357
|
+
}, { context: SYSTEM_CTX });
|
|
1358
|
+
|
|
1359
|
+
if (action === 'reassign' && escalateTo) {
|
|
1360
|
+
await this.engine.update('sys_approval_request', {
|
|
1361
|
+
id: raw.id, pending_approvers: escalateTo, updated_at: now,
|
|
1362
|
+
}, { context: SYSTEM_CTX });
|
|
1363
|
+
await this.syncApproverIndex(raw.id, [escalateTo], raw.organization_id ?? null, now);
|
|
1364
|
+
await this.notify({
|
|
1365
|
+
topic: 'approval.escalated',
|
|
1366
|
+
audience: [escalateTo],
|
|
1367
|
+
actorId: SLA_ACTOR_ID,
|
|
1368
|
+
source: { object: 'sys_approval_request', id: raw.id },
|
|
1369
|
+
payload: {
|
|
1370
|
+
title: 'Approval escalated to you',
|
|
1371
|
+
message: `An overdue approval on ${raw.object_name}/${raw.record_id} was escalated to you.`,
|
|
1372
|
+
actionUrl: '/system/approvals',
|
|
1373
|
+
},
|
|
1374
|
+
});
|
|
1375
|
+
} else if (action === 'auto_approve' || action === 'auto_reject') {
|
|
1376
|
+
await this.decide(raw.id, {
|
|
1377
|
+
decision: action === 'auto_approve' ? 'approve' : 'reject',
|
|
1378
|
+
actorId: SLA_ACTOR_ID,
|
|
1379
|
+
comment: 'SLA escalation',
|
|
1380
|
+
}, SYSTEM_CTX as unknown as SharingExecutionContext);
|
|
1381
|
+
} else {
|
|
1382
|
+
// 'notify' (and the reassign-without-target fallback)
|
|
1383
|
+
await this.notify({
|
|
1384
|
+
topic: 'approval.sla_breached',
|
|
1385
|
+
audience: [...pending, ...(escalateTo ? [escalateTo] : [])],
|
|
1386
|
+
actorId: SLA_ACTOR_ID,
|
|
1387
|
+
source: { object: 'sys_approval_request', id: raw.id },
|
|
1388
|
+
payload: {
|
|
1389
|
+
title: 'Approval SLA breached',
|
|
1390
|
+
message: `A decision on ${raw.object_name}/${raw.record_id} is overdue.`,
|
|
1391
|
+
actionUrl: '/system/approvals',
|
|
1392
|
+
},
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
if (esc.notifySubmitter !== false && raw.submitter_id) {
|
|
1397
|
+
await this.notify({
|
|
1398
|
+
topic: 'approval.sla_breached',
|
|
1399
|
+
audience: [String(raw.submitter_id)],
|
|
1400
|
+
actorId: SLA_ACTOR_ID,
|
|
1401
|
+
source: { object: 'sys_approval_request', id: raw.id },
|
|
1402
|
+
payload: {
|
|
1403
|
+
title: 'Your approval request breached its SLA',
|
|
1404
|
+
message: `${raw.object_name}/${raw.record_id}: escalation action '${action}' was taken.`,
|
|
1405
|
+
actionUrl: '/system/approvals',
|
|
1406
|
+
},
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
564
1411
|
// ── Display enrichment ───────────────────────────────────────
|
|
565
1412
|
|
|
566
1413
|
/**
|
|
@@ -593,15 +1440,66 @@ export class ApprovalService implements IApprovalService {
|
|
|
593
1440
|
}
|
|
594
1441
|
|
|
595
1442
|
/**
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
|
|
599
|
-
|
|
1443
|
+
* Batch-resolve `sys_user` display names for identifiers that may be user
|
|
1444
|
+
* ids or emails. Best-effort — failures leave entries unresolved.
|
|
1445
|
+
*/
|
|
1446
|
+
private async resolveUserNames(identifiers: Array<string | null | undefined>): Promise<Map<string, string>> {
|
|
1447
|
+
const names = new Map<string, string>();
|
|
1448
|
+
const targets = Array.from(new Set(identifiers.filter(Boolean))) as string[];
|
|
1449
|
+
if (!targets.length) return names;
|
|
1450
|
+
try {
|
|
1451
|
+
const users = await this.engine.find('sys_user', {
|
|
1452
|
+
where: { id: { $in: targets } }, fields: ['id', 'name', 'email'],
|
|
1453
|
+
limit: targets.length, context: SYSTEM_CTX,
|
|
1454
|
+
});
|
|
1455
|
+
for (const u of (users ?? []) as any[]) {
|
|
1456
|
+
if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
|
|
1457
|
+
}
|
|
1458
|
+
} catch { /* best-effort */ }
|
|
1459
|
+
const unresolvedEmails = targets.filter(t => !names.has(t) && t.includes('@'));
|
|
1460
|
+
if (unresolvedEmails.length) {
|
|
1461
|
+
try {
|
|
1462
|
+
const users = await this.engine.find('sys_user', {
|
|
1463
|
+
where: { email: { $in: unresolvedEmails } }, fields: ['email', 'name'],
|
|
1464
|
+
limit: unresolvedEmails.length, context: SYSTEM_CTX,
|
|
1465
|
+
});
|
|
1466
|
+
for (const u of (users ?? []) as any[]) {
|
|
1467
|
+
if (u?.email && u.name) names.set(String(u.email), String(u.name));
|
|
1468
|
+
}
|
|
1469
|
+
} catch { /* best-effort */ }
|
|
1470
|
+
}
|
|
1471
|
+
return names;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/** Lookup-typed fields (key + referenced object) of an object's schema. */
|
|
1475
|
+
private resolveLookupFields(object: string): Array<{ key: string; reference: string }> {
|
|
1476
|
+
try {
|
|
1477
|
+
const schema: any = (this.engine as any).getSchema?.(object);
|
|
1478
|
+
const fields = schema?.fields ?? {};
|
|
1479
|
+
const out: Array<{ key: string; reference: string }> = [];
|
|
1480
|
+
for (const [key, f] of Object.entries<any>(fields)) {
|
|
1481
|
+
if ((f?.type === 'lookup' || f?.type === 'master_detail') && f?.reference) {
|
|
1482
|
+
out.push({ key, reference: String(f.reference) });
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
return out;
|
|
1486
|
+
} catch { return []; }
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* Attach inbox display fields to rows so clients never render a raw
|
|
1491
|
+
* identifier: `record_title`, `submitter_name`, `object_label`,
|
|
1492
|
+
* `pending_approver_names` (user-id approvers), and `payload_display`
|
|
1493
|
+
* (lookup foreign keys in the snapshot → referenced record titles).
|
|
1494
|
+
* Batched: one query per distinct object (target + referenced) plus one
|
|
1495
|
+
* `sys_user` lookup. Best-effort — a deleted record falls back to the
|
|
1496
|
+
* payload snapshot, and any failure leaves the field unset rather than
|
|
1497
|
+
* failing the list.
|
|
600
1498
|
*/
|
|
601
1499
|
private async enrichRows(rows: ApprovalRequestRow[]): Promise<void> {
|
|
602
1500
|
if (!rows.length) return;
|
|
603
1501
|
|
|
604
|
-
// Record titles, batched per object.
|
|
1502
|
+
// Record titles + object labels, batched per object.
|
|
605
1503
|
const byObject = new Map<string, Set<string>>();
|
|
606
1504
|
for (const r of rows) {
|
|
607
1505
|
if (!r.object_name || !r.record_id) continue;
|
|
@@ -610,7 +1508,12 @@ export class ApprovalService implements IApprovalService {
|
|
|
610
1508
|
set.add(r.record_id);
|
|
611
1509
|
}
|
|
612
1510
|
const titles = new Map<string, string>();
|
|
1511
|
+
const objectLabels = new Map<string, string>();
|
|
613
1512
|
for (const [object, idSet] of byObject) {
|
|
1513
|
+
try {
|
|
1514
|
+
const schema: any = (this.engine as any).getSchema?.(object);
|
|
1515
|
+
if (schema?.label) objectLabels.set(object, String(schema.label));
|
|
1516
|
+
} catch { /* label optional */ }
|
|
614
1517
|
const ids = Array.from(idSet);
|
|
615
1518
|
const displayField = this.resolveDisplayField(object);
|
|
616
1519
|
try {
|
|
@@ -619,59 +1522,199 @@ export class ApprovalService implements IApprovalService {
|
|
|
619
1522
|
});
|
|
620
1523
|
for (const rec of (recs ?? []) as any[]) {
|
|
621
1524
|
const title = ApprovalService.pickTitle(rec, displayField);
|
|
622
|
-
if (rec?.id && title) titles.set(`${object}
|
|
1525
|
+
if (rec?.id && title) titles.set(`${object} ${rec.id}`, title);
|
|
623
1526
|
}
|
|
624
1527
|
} catch { /* object may be unregistered — payload fallback below */ }
|
|
625
1528
|
}
|
|
626
1529
|
|
|
627
|
-
//
|
|
628
|
-
const
|
|
629
|
-
const
|
|
630
|
-
|
|
1530
|
+
// Lookup foreign keys inside payload snapshots → referenced record titles.
|
|
1531
|
+
const lookupFieldsByObject = new Map<string, Array<{ key: string; reference: string }>>();
|
|
1532
|
+
for (const object of byObject.keys()) {
|
|
1533
|
+
const lookups = this.resolveLookupFields(object);
|
|
1534
|
+
if (lookups.length) lookupFieldsByObject.set(object, lookups);
|
|
1535
|
+
}
|
|
1536
|
+
const refIds = new Map<string, Set<string>>();
|
|
1537
|
+
for (const r of rows) {
|
|
1538
|
+
const lookups = lookupFieldsByObject.get(r.object_name);
|
|
1539
|
+
const payload: any = r.payload;
|
|
1540
|
+
if (!lookups || !payload || typeof payload !== 'object') continue;
|
|
1541
|
+
for (const { key, reference } of lookups) {
|
|
1542
|
+
const v = payload[key];
|
|
1543
|
+
if (v == null || typeof v === 'object' || !String(v).trim()) continue;
|
|
1544
|
+
let set = refIds.get(reference);
|
|
1545
|
+
if (!set) { set = new Set(); refIds.set(reference, set); }
|
|
1546
|
+
set.add(String(v));
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
const refTitles = new Map<string, string>();
|
|
1550
|
+
for (const [object, idSet] of refIds) {
|
|
1551
|
+
const ids = Array.from(idSet);
|
|
1552
|
+
const displayField = this.resolveDisplayField(object);
|
|
631
1553
|
try {
|
|
632
|
-
const
|
|
633
|
-
where: { id: { $in:
|
|
634
|
-
limit: submitters.length, context: SYSTEM_CTX,
|
|
1554
|
+
const recs = await this.engine.find(object, {
|
|
1555
|
+
where: { id: { $in: ids } }, limit: ids.length, context: SYSTEM_CTX,
|
|
635
1556
|
});
|
|
636
|
-
for (const
|
|
637
|
-
|
|
1557
|
+
for (const rec of (recs ?? []) as any[]) {
|
|
1558
|
+
const title = ApprovalService.pickTitle(rec, displayField);
|
|
1559
|
+
if (rec?.id && title) refTitles.set(`${object} ${rec.id}`, title);
|
|
638
1560
|
}
|
|
639
|
-
} catch { /*
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
}
|
|
650
|
-
} catch { /* best-effort */ }
|
|
1561
|
+
} catch { /* referenced object unreadable — leave unresolved */ }
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// Display names for submitters AND user-id approvers in one lookup.
|
|
1565
|
+
// `role:<r>` (and other `type:value` literals) are already readable.
|
|
1566
|
+
const userIdentifiers: Array<string | null | undefined> = [];
|
|
1567
|
+
for (const r of rows) {
|
|
1568
|
+
userIdentifiers.push(r.submitter_id);
|
|
1569
|
+
for (const a of r.pending_approvers ?? []) {
|
|
1570
|
+
if (a && !a.includes(':')) userIdentifiers.push(a);
|
|
651
1571
|
}
|
|
652
1572
|
}
|
|
1573
|
+
const names = await this.resolveUserNames(userIdentifiers);
|
|
653
1574
|
|
|
654
1575
|
for (const r of rows as any[]) {
|
|
655
|
-
const title = titles.get(`${r.object_name}
|
|
1576
|
+
const title = titles.get(`${r.object_name} ${r.record_id}`)
|
|
656
1577
|
?? ApprovalService.pickTitle(r.payload, undefined);
|
|
657
1578
|
if (title) r.record_title = title;
|
|
658
1579
|
const name = r.submitter_id ? names.get(String(r.submitter_id)) : undefined;
|
|
659
1580
|
if (name) r.submitter_name = name;
|
|
1581
|
+
const label = objectLabels.get(r.object_name);
|
|
1582
|
+
if (label) r.object_label = label;
|
|
1583
|
+
|
|
1584
|
+
const approverNames: Record<string, string> = {};
|
|
1585
|
+
for (const a of r.pending_approvers ?? []) {
|
|
1586
|
+
const n = names.get(String(a));
|
|
1587
|
+
if (n) approverNames[a] = n;
|
|
1588
|
+
}
|
|
1589
|
+
if (Object.keys(approverNames).length) r.pending_approver_names = approverNames;
|
|
1590
|
+
|
|
1591
|
+
const lookups = lookupFieldsByObject.get(r.object_name);
|
|
1592
|
+
if (lookups && r.payload && typeof r.payload === 'object') {
|
|
1593
|
+
const display: Record<string, string> = {};
|
|
1594
|
+
for (const { key, reference } of lookups) {
|
|
1595
|
+
const v = (r.payload as any)[key];
|
|
1596
|
+
if (v == null) continue;
|
|
1597
|
+
const t = refTitles.get(`${reference} ${String(v)}`);
|
|
1598
|
+
if (t) display[key] = t;
|
|
1599
|
+
}
|
|
1600
|
+
if (Object.keys(display).length) r.payload_display = display;
|
|
1601
|
+
}
|
|
660
1602
|
}
|
|
661
1603
|
}
|
|
662
1604
|
|
|
1605
|
+
// ── Pending-approver index (issue #1745) ─────────────────────
|
|
1606
|
+
|
|
1607
|
+
/**
|
|
1608
|
+
* Mirror one request's `pending_approvers` CSV into the normalized
|
|
1609
|
+
* `sys_approval_approver` index. Called by every write path that changes
|
|
1610
|
+
* the approver set; an empty `approvers` clears the request's rows (the
|
|
1611
|
+
* request left `pending`). Diff-based so reassign/unanimous churn doesn't
|
|
1612
|
+
* rewrite untouched rows.
|
|
1613
|
+
*/
|
|
1614
|
+
private async syncApproverIndex(
|
|
1615
|
+
requestId: string,
|
|
1616
|
+
approvers: string[],
|
|
1617
|
+
org: string | null,
|
|
1618
|
+
now: string,
|
|
1619
|
+
): Promise<void> {
|
|
1620
|
+
const desired = new Set(approvers.map(a => String(a).trim()).filter(Boolean));
|
|
1621
|
+
const existing = await this.engine.find('sys_approval_approver', {
|
|
1622
|
+
where: { request_id: requestId }, limit: 500, context: SYSTEM_CTX,
|
|
1623
|
+
});
|
|
1624
|
+
const rows: any[] = Array.isArray(existing) ? existing : [];
|
|
1625
|
+
for (const row of rows) {
|
|
1626
|
+
if (desired.has(String(row.approver))) desired.delete(String(row.approver));
|
|
1627
|
+
else await this.engine.delete('sys_approval_approver', { where: { id: row.id }, context: SYSTEM_CTX });
|
|
1628
|
+
}
|
|
1629
|
+
for (const approver of desired) {
|
|
1630
|
+
await this.engine.insert('sys_approval_approver', {
|
|
1631
|
+
id: uid('aapr'), request_id: requestId, approver,
|
|
1632
|
+
organization_id: org, created_at: now,
|
|
1633
|
+
}, { context: SYSTEM_CTX });
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/**
|
|
1638
|
+
* Rebuild the whole `sys_approval_approver` index from the CSV source of
|
|
1639
|
+
* truth. Idempotent; run at plugin start so rows written before the index
|
|
1640
|
+
* existed (or drifted past a crashed sync) become queryable. Cost tracks
|
|
1641
|
+
* the number of *pending* requests, not the request history.
|
|
1642
|
+
*/
|
|
1643
|
+
async rebuildApproverIndex(): Promise<{ requests: number; inserted: number; deleted: number }> {
|
|
1644
|
+
// Desired state: every pending request's CSV entries.
|
|
1645
|
+
const desired = new Map<string, { approvers: Set<string>; org: string | null }>();
|
|
1646
|
+
const PAGE = 500;
|
|
1647
|
+
for (let offset = 0; ; offset += PAGE) {
|
|
1648
|
+
const batch = await this.engine.find('sys_approval_request', {
|
|
1649
|
+
where: { status: 'pending' },
|
|
1650
|
+
fields: ['id', 'pending_approvers', 'organization_id'],
|
|
1651
|
+
limit: PAGE, offset, context: SYSTEM_CTX,
|
|
1652
|
+
});
|
|
1653
|
+
const rows: any[] = Array.isArray(batch) ? batch : [];
|
|
1654
|
+
for (const r of rows) {
|
|
1655
|
+
desired.set(String(r.id), {
|
|
1656
|
+
approvers: new Set(csvSplit(r.pending_approvers)),
|
|
1657
|
+
org: r.organization_id ?? null,
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
if (rows.length < PAGE) break;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// Current state: read the whole index first (bounded by the live work
|
|
1664
|
+
// queue), THEN mutate — deleting while paginating would shift the cursor.
|
|
1665
|
+
const indexRows: any[] = [];
|
|
1666
|
+
for (let offset = 0; ; offset += PAGE) {
|
|
1667
|
+
const batch = await this.engine.find('sys_approval_approver', {
|
|
1668
|
+
orderBy: [{ field: 'created_at', order: 'asc' }],
|
|
1669
|
+
limit: PAGE, offset, context: SYSTEM_CTX,
|
|
1670
|
+
});
|
|
1671
|
+
const rows: any[] = Array.isArray(batch) ? batch : [];
|
|
1672
|
+
indexRows.push(...rows);
|
|
1673
|
+
if (rows.length < PAGE) break;
|
|
1674
|
+
}
|
|
1675
|
+
let inserted = 0; let deleted = 0;
|
|
1676
|
+
const seen = new Map<string, Set<string>>();
|
|
1677
|
+
for (const row of indexRows) {
|
|
1678
|
+
const reqId = String(row.request_id);
|
|
1679
|
+
const want = desired.get(reqId);
|
|
1680
|
+
const have = seen.get(reqId) ?? seen.set(reqId, new Set()).get(reqId)!;
|
|
1681
|
+
// Orphan (request no longer pending), stale entry, or duplicate → drop.
|
|
1682
|
+
if (!want || !want.approvers.has(String(row.approver)) || have.has(String(row.approver))) {
|
|
1683
|
+
await this.engine.delete('sys_approval_approver', { where: { id: row.id }, context: SYSTEM_CTX });
|
|
1684
|
+
deleted++;
|
|
1685
|
+
continue;
|
|
1686
|
+
}
|
|
1687
|
+
have.add(String(row.approver));
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
const now = this.clock.now().toISOString();
|
|
1691
|
+
for (const [reqId, want] of desired) {
|
|
1692
|
+
const have = seen.get(reqId);
|
|
1693
|
+
for (const approver of want.approvers) {
|
|
1694
|
+
if (have?.has(approver)) continue;
|
|
1695
|
+
await this.engine.insert('sys_approval_approver', {
|
|
1696
|
+
id: uid('aapr'), request_id: reqId, approver,
|
|
1697
|
+
organization_id: want.org, created_at: now,
|
|
1698
|
+
}, { context: SYSTEM_CTX });
|
|
1699
|
+
inserted++;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
return { requests: desired.size, inserted, deleted };
|
|
1703
|
+
}
|
|
1704
|
+
|
|
663
1705
|
// ── Read API ─────────────────────────────────────────────────
|
|
664
1706
|
|
|
665
|
-
|
|
1707
|
+
/** Filter type accepted by {@link listRequests} / {@link countRequests}. */
|
|
1708
|
+
private buildRequestWhere(
|
|
666
1709
|
filter: {
|
|
667
1710
|
object?: string;
|
|
668
1711
|
recordId?: string;
|
|
669
1712
|
status?: ApprovalStatus | ApprovalStatus[];
|
|
670
|
-
approverId?: string | string[];
|
|
671
1713
|
submitterId?: string;
|
|
1714
|
+
q?: string;
|
|
672
1715
|
} | undefined,
|
|
673
1716
|
context: SharingExecutionContext,
|
|
674
|
-
):
|
|
1717
|
+
): { where: any; tenantOrg: string | null } {
|
|
675
1718
|
const f: any = {};
|
|
676
1719
|
if (filter?.object) f.object_name = filter.object;
|
|
677
1720
|
if (filter?.recordId) f.record_id = filter.recordId;
|
|
@@ -680,40 +1723,145 @@ export class ApprovalService implements IApprovalService {
|
|
|
680
1723
|
// (organizationId / tenantId), scope the query to that tenant. SYSTEM
|
|
681
1724
|
// callers (no tenant) see all rows. This prevents the bespoke endpoint
|
|
682
1725
|
// from leaking other-tenant rows since we deliberately query with
|
|
683
|
-
// SYSTEM_CTX to bypass RLS on the engine (
|
|
684
|
-
//
|
|
685
|
-
const tenantOrg = (context as any)?.organizationId ?? (context as any)?.tenantId;
|
|
1726
|
+
// SYSTEM_CTX to bypass RLS on the engine (the approver-visibility rule
|
|
1727
|
+
// spans three identity forms, which RLS can't model cleanly).
|
|
1728
|
+
const tenantOrg = (context as any)?.organizationId ?? (context as any)?.tenantId ?? null;
|
|
686
1729
|
if (tenantOrg) f.organization_id = tenantOrg;
|
|
687
|
-
//
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
1730
|
+
// Free-text search, pushed down: `payload_json` carries the record
|
|
1731
|
+
// snapshot, so record titles match without any join. `$contains` is the
|
|
1732
|
+
// driver's escaped-LIKE operator.
|
|
1733
|
+
const q = filter?.q?.trim();
|
|
1734
|
+
if (q) {
|
|
1735
|
+
f.$or = [
|
|
1736
|
+
{ process_name: { $contains: q } },
|
|
1737
|
+
{ object_name: { $contains: q } },
|
|
1738
|
+
{ record_id: { $contains: q } },
|
|
1739
|
+
{ submitter_id: { $contains: q } },
|
|
1740
|
+
{ payload_json: { $contains: q } },
|
|
1741
|
+
];
|
|
1742
|
+
}
|
|
1743
|
+
// Status pushes down whole: `$in` for arrays (all bundled drivers
|
|
1744
|
+
// support it), equality for a single value.
|
|
1745
|
+
if (Array.isArray(filter?.status)) {
|
|
1746
|
+
const statuses = (filter!.status as ApprovalStatus[]).filter(Boolean);
|
|
1747
|
+
if (statuses.length === 1) f.status = statuses[0];
|
|
1748
|
+
else if (statuses.length > 1) f.status = { $in: statuses };
|
|
1749
|
+
} else if (filter?.status) {
|
|
1750
|
+
f.status = filter.status;
|
|
1751
|
+
}
|
|
1752
|
+
return { where: f, tenantOrg };
|
|
1753
|
+
}
|
|
691
1754
|
|
|
692
|
-
|
|
693
|
-
|
|
1755
|
+
/** Window the approver-index probe — pending queues live far below this. */
|
|
1756
|
+
private static readonly APPROVER_INDEX_CAP = 10_000;
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* Resolve an approver filter to matching request ids via the normalized
|
|
1760
|
+
* `sys_approval_approver` index — the indexed replacement for the old
|
|
1761
|
+
* in-memory CSV scan, and what makes approver-filtered pagination correct
|
|
1762
|
+
* past any scan window (issue #1745). A request matches when ANY of the
|
|
1763
|
+
* caller's identities (user id / email / role:<r>) holds a pending slot.
|
|
1764
|
+
* Returns null when the filter is absent (callers skip the id constraint).
|
|
1765
|
+
*/
|
|
1766
|
+
private async approverRequestIds(
|
|
1767
|
+
targets: string[],
|
|
1768
|
+
tenantOrg: string | null,
|
|
1769
|
+
): Promise<string[] | null> {
|
|
1770
|
+
if (!targets.length) return null;
|
|
1771
|
+
const where: any = targets.length === 1
|
|
1772
|
+
? { approver: targets[0] }
|
|
1773
|
+
: { approver: { $in: targets } };
|
|
1774
|
+
if (tenantOrg) where.organization_id = tenantOrg;
|
|
1775
|
+
const rows = await this.engine.find('sys_approval_approver', {
|
|
1776
|
+
where, fields: ['request_id'],
|
|
1777
|
+
limit: ApprovalService.APPROVER_INDEX_CAP, context: SYSTEM_CTX,
|
|
694
1778
|
});
|
|
695
|
-
|
|
696
|
-
if (
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
1779
|
+
const list: any[] = Array.isArray(rows) ? rows : [];
|
|
1780
|
+
if (list.length >= ApprovalService.APPROVER_INDEX_CAP) {
|
|
1781
|
+
this.logger?.warn?.('[approvals] approver index probe hit its window — results may be truncated', {
|
|
1782
|
+
cap: ApprovalService.APPROVER_INDEX_CAP, targets: targets.length,
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
return [...new Set<string>(list.map(r => String(r.request_id)))];
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
async listRequests(
|
|
1789
|
+
filter: {
|
|
1790
|
+
object?: string;
|
|
1791
|
+
recordId?: string;
|
|
1792
|
+
status?: ApprovalStatus | ApprovalStatus[];
|
|
1793
|
+
approverId?: string | string[];
|
|
1794
|
+
submitterId?: string;
|
|
1795
|
+
q?: string;
|
|
1796
|
+
limit?: number;
|
|
1797
|
+
offset?: number;
|
|
1798
|
+
} | undefined,
|
|
1799
|
+
context: SharingExecutionContext,
|
|
1800
|
+
): Promise<ApprovalRequestRow[]> {
|
|
1801
|
+
const { where, tenantOrg } = this.buildRequestWhere(filter, context);
|
|
1802
|
+
const approverTargets = (Array.isArray(filter?.approverId) ? filter!.approverId : filter?.approverId ? [filter.approverId] : [])
|
|
1803
|
+
.map(t => String(t).trim())
|
|
1804
|
+
.filter(Boolean);
|
|
1805
|
+
|
|
1806
|
+
// Every filter now pushes into the engine (issue #1745): approver via
|
|
1807
|
+
// the normalized index, status arrays via $in — so the page window is
|
|
1808
|
+
// always engine-side and correct at any table size.
|
|
1809
|
+
const ids = await this.approverRequestIds(approverTargets, tenantOrg);
|
|
1810
|
+
if (ids) {
|
|
1811
|
+
if (ids.length === 0) return [];
|
|
1812
|
+
where.id = ids.length === 1 ? ids[0] : { $in: ids };
|
|
712
1813
|
}
|
|
1814
|
+
|
|
1815
|
+
const findOpts: any = {
|
|
1816
|
+
where,
|
|
1817
|
+
orderBy: [{ field: 'created_at', order: 'desc' }],
|
|
1818
|
+
context: SYSTEM_CTX,
|
|
1819
|
+
};
|
|
1820
|
+
if (filter?.limit != null || filter?.offset != null) {
|
|
1821
|
+
findOpts.limit = Math.min(Math.max(filter?.limit ?? 50, 1), 200);
|
|
1822
|
+
if (filter?.offset) findOpts.offset = Math.max(filter.offset, 0);
|
|
1823
|
+
} else {
|
|
1824
|
+
// Unpaginated callers keep the legacy bounded window.
|
|
1825
|
+
findOpts.limit = 500;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
const rows = await this.engine.find('sys_approval_request', findOpts);
|
|
1829
|
+
const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
|
|
713
1830
|
await this.enrichRows(list);
|
|
714
1831
|
return list;
|
|
715
1832
|
}
|
|
716
1833
|
|
|
1834
|
+
async countRequests(
|
|
1835
|
+
filter: Parameters<IApprovalService['listRequests']>[0],
|
|
1836
|
+
context: SharingExecutionContext,
|
|
1837
|
+
): Promise<number> {
|
|
1838
|
+
const { where, tenantOrg } = this.buildRequestWhere(filter, context);
|
|
1839
|
+
const approverTargets = (Array.isArray(filter?.approverId) ? filter!.approverId : filter?.approverId ? [filter.approverId] : [])
|
|
1840
|
+
.map(t => String(t).trim())
|
|
1841
|
+
.filter(Boolean);
|
|
1842
|
+
|
|
1843
|
+
const ids = await this.approverRequestIds(approverTargets, tenantOrg);
|
|
1844
|
+
if (ids) {
|
|
1845
|
+
if (ids.length === 0) return 0;
|
|
1846
|
+
where.id = ids.length === 1 ? ids[0] : { $in: ids };
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
const countFn = (this.engine as any).count;
|
|
1850
|
+
if (typeof countFn === 'function') {
|
|
1851
|
+
try {
|
|
1852
|
+
const n = await countFn.call(this.engine, 'sys_approval_request', { where, context: SYSTEM_CTX });
|
|
1853
|
+
if (typeof n === 'number') return n;
|
|
1854
|
+
} catch { /* fall through to scan */ }
|
|
1855
|
+
}
|
|
1856
|
+
// Engine without count(): bounded scan. The approver-filtered case is
|
|
1857
|
+
// exact (the id set bounds it); the unfiltered case keeps the legacy
|
|
1858
|
+
// 500 window.
|
|
1859
|
+
const rows = await this.engine.find('sys_approval_request', {
|
|
1860
|
+
where, fields: ['id'], limit: ids ? Math.max(500, ids.length) : 500, context: SYSTEM_CTX,
|
|
1861
|
+
});
|
|
1862
|
+
return Array.isArray(rows) ? rows.length : 0;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
717
1865
|
async getRequest(requestId: string, context: SharingExecutionContext): Promise<ApprovalRequestRow | null> {
|
|
718
1866
|
if (!requestId) return null;
|
|
719
1867
|
const where: any = { id: requestId };
|
|
@@ -725,9 +1873,50 @@ export class ApprovalService implements IApprovalService {
|
|
|
725
1873
|
if (!Array.isArray(rows) || !rows[0]) return null;
|
|
726
1874
|
const row = rowFromRequest(rows[0]);
|
|
727
1875
|
await this.enrichRows([row]);
|
|
1876
|
+
await this.attachFlowSteps(row);
|
|
728
1877
|
return row;
|
|
729
1878
|
}
|
|
730
1879
|
|
|
1880
|
+
/**
|
|
1881
|
+
* Derive approval-step progress from the owning flow's graph (single-read
|
|
1882
|
+
* enrichment only — list reads skip it). Walks from the start node
|
|
1883
|
+
* preferring `approve`/`true` edges, so the result is the flow's main
|
|
1884
|
+
* approval trunk; conditional side-steps show as part of the potential
|
|
1885
|
+
* path. Display-only and best-effort.
|
|
1886
|
+
*/
|
|
1887
|
+
private async attachFlowSteps(row: ApprovalRequestRow): Promise<void> {
|
|
1888
|
+
try {
|
|
1889
|
+
const flowName = row.process_name?.startsWith('flow:') ? row.process_name.slice(5) : undefined;
|
|
1890
|
+
if (!flowName || typeof this.automation?.getFlow !== 'function') return;
|
|
1891
|
+
const flow: any = await this.automation.getFlow(flowName);
|
|
1892
|
+
if (!flow?.nodes?.length) return;
|
|
1893
|
+
const nodesById = new Map<string, any>(flow.nodes.map((n: any) => [n.id, n]));
|
|
1894
|
+
const steps: Array<{ id: string; label: string }> = [];
|
|
1895
|
+
const seen = new Set<string>();
|
|
1896
|
+
let cur: any = flow.nodes.find((n: any) => n.type === 'start');
|
|
1897
|
+
while (cur && !seen.has(cur.id)) {
|
|
1898
|
+
seen.add(cur.id);
|
|
1899
|
+
if (cur.type === 'approval') steps.push({ id: cur.id, label: cur.label || cur.id });
|
|
1900
|
+
const out = (flow.edges ?? []).filter((e: any) => e.source === cur.id);
|
|
1901
|
+
if (!out.length) break;
|
|
1902
|
+
const pick = out.find((e: any) => e.label === 'approve')
|
|
1903
|
+
?? out.find((e: any) => e.label === 'true')
|
|
1904
|
+
?? out[0];
|
|
1905
|
+
cur = nodesById.get(pick.target);
|
|
1906
|
+
}
|
|
1907
|
+
if (steps.length === 0) return;
|
|
1908
|
+
const currentId = row.flow_node_id ?? row.current_step;
|
|
1909
|
+
const currentIdx = steps.findIndex(s => s.id === currentId);
|
|
1910
|
+
(row as any).flow_steps = steps.map((s, i) => ({
|
|
1911
|
+
...s,
|
|
1912
|
+
state: currentIdx < 0 ? 'upcoming'
|
|
1913
|
+
: i < currentIdx ? 'done'
|
|
1914
|
+
: i === currentIdx ? (row.status === 'approved' ? 'done' : 'current')
|
|
1915
|
+
: 'upcoming',
|
|
1916
|
+
}));
|
|
1917
|
+
} catch { /* display-only — never fail the read */ }
|
|
1918
|
+
}
|
|
1919
|
+
|
|
731
1920
|
async listActions(requestId: string, context: SharingExecutionContext): Promise<ApprovalActionRow[]> {
|
|
732
1921
|
if (!requestId) return [];
|
|
733
1922
|
// Tenant gate: ensure the caller can see the parent request before
|
|
@@ -738,9 +1927,19 @@ export class ApprovalService implements IApprovalService {
|
|
|
738
1927
|
const rows = await this.engine.find('sys_approval_action', {
|
|
739
1928
|
where: { request_id: requestId },
|
|
740
1929
|
limit: 500,
|
|
741
|
-
orderBy: [{ field: 'created_at',
|
|
1930
|
+
orderBy: [{ field: 'created_at', order: 'asc' }],
|
|
742
1931
|
context: SYSTEM_CTX,
|
|
743
1932
|
});
|
|
744
|
-
|
|
1933
|
+
const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
|
|
1934
|
+
// Timeline display: resolve actor ids to names so the audit trail never
|
|
1935
|
+
// shows a raw identifier. Role/team literals are already readable.
|
|
1936
|
+
const names = await this.resolveUserNames(
|
|
1937
|
+
actions.map(a => a.actor_id).filter(id => id && !id.includes(':')),
|
|
1938
|
+
);
|
|
1939
|
+
for (const a of actions as any[]) {
|
|
1940
|
+
const n = a.actor_id ? names.get(String(a.actor_id)) : undefined;
|
|
1941
|
+
if (n) a.actor_name = n;
|
|
1942
|
+
}
|
|
1943
|
+
return actions;
|
|
745
1944
|
}
|
|
746
1945
|
}
|