@objectstack/plugin-approvals 15.1.1 → 16.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +168 -0
- package/dist/index.d.mts +3024 -2413
- package/dist/index.d.ts +3024 -2413
- package/dist/index.js +882 -97
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +883 -98
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -7
- package/scripts/i18n-extract.config.ts +2 -1
- package/src/approval-service.test.ts +464 -1
- package/src/approval-service.ts +425 -58
- package/src/approvals-plugin.ts +9 -3
- package/src/index.ts +1 -0
- package/src/lifecycle-hooks.ts +64 -0
- package/src/nav-contribution.test.ts +2 -0
- package/src/sys-approval-action.object.ts +17 -2
- package/src/sys-approval-approver.object.ts +7 -1
- package/src/sys-approval-delegation.object.ts +142 -0
- package/src/sys-approval-request.object.test.ts +103 -0
- package/src/sys-approval-request.object.ts +170 -1
- package/src/sys-approval-token.object.ts +7 -1
- package/src/translations/en.objects.generated.ts +49 -0
- package/src/translations/es-ES.objects.generated.ts +49 -0
- package/src/translations/ja-JP.objects.generated.ts +49 -0
- package/src/translations/zh-CN.objects.generated.ts +49 -0
package/src/approval-service.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { createHash, randomBytes } from 'node:crypto';
|
|
4
4
|
import {
|
|
5
5
|
APPROVAL_BRANCH_LABELS,
|
|
6
|
+
canonicalApproverType,
|
|
6
7
|
type ApprovalNodeConfig,
|
|
7
8
|
} from '@objectstack/spec/automation';
|
|
8
9
|
import type {
|
|
@@ -20,6 +21,7 @@ import type {
|
|
|
20
21
|
ApprovalStatus,
|
|
21
22
|
SharingExecutionContext,
|
|
22
23
|
} from '@objectstack/spec/contracts';
|
|
24
|
+
import { isGrantActive } from '@objectstack/core';
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* Node-era approval runtime (ADR-0019).
|
|
@@ -99,6 +101,23 @@ export type ActionTokenOutcome =
|
|
|
99
101
|
|
|
100
102
|
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
|
|
101
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Max hops when following an OOO delegation chain (#1322 M1): A out → B, B out
|
|
106
|
+
* → C, … Bounds the walk so a mis-configured chain can't loop or resolve
|
|
107
|
+
* unboundedly; a cycle or self-reference also stops it early.
|
|
108
|
+
*/
|
|
109
|
+
const OOO_MAX_CHAIN = 8;
|
|
110
|
+
|
|
111
|
+
/** One OOO delegation hop applied while resolving an approver (#1322 M1/M4). */
|
|
112
|
+
interface OooSubstitution {
|
|
113
|
+
/** The approver who was skipped (out of office). */
|
|
114
|
+
from: string;
|
|
115
|
+
/** The delegate the slot was routed to. */
|
|
116
|
+
to: string;
|
|
117
|
+
/** The delegator's declared reason, if any. */
|
|
118
|
+
reason: string | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
102
121
|
function uid(prefix: string): string {
|
|
103
122
|
const g: any = globalThis as any;
|
|
104
123
|
if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
|
|
@@ -186,6 +205,10 @@ function rowFromAction(row: any): ApprovalActionRow {
|
|
|
186
205
|
action: row.action,
|
|
187
206
|
actor_id: row.actor_id ?? undefined,
|
|
188
207
|
comment: row.comment ?? undefined,
|
|
208
|
+
// Decision attachments (#3266). The column shipped in #3268 but this
|
|
209
|
+
// contract mapping didn't — the raw engine row carried the fileIds while
|
|
210
|
+
// every consumer of listActions saw none (caught by browser verification).
|
|
211
|
+
attachments: Array.isArray(row.attachments) && row.attachments.length ? row.attachments.map(String) : undefined,
|
|
189
212
|
created_at: row.created_at ?? undefined,
|
|
190
213
|
};
|
|
191
214
|
}
|
|
@@ -249,8 +272,20 @@ export class ApprovalService implements IApprovalService {
|
|
|
249
272
|
}): Promise<number> {
|
|
250
273
|
const audience = input.audience.filter(a => a && !a.includes(':'));
|
|
251
274
|
if (!this.messaging || !audience.length) return 0;
|
|
275
|
+
// Deep-link the inbox (#2678 P1.5): a notification about one request should
|
|
276
|
+
// land on that request, not the bare inbox. Rewritten centrally so every
|
|
277
|
+
// call site — and any future one — inherits it; the query param is read by
|
|
278
|
+
// the console inbox to auto-open the drawer.
|
|
279
|
+
let payload = input.payload;
|
|
280
|
+
if (
|
|
281
|
+
payload?.actionUrl === '/system/approvals'
|
|
282
|
+
&& input.source?.object === 'sys_approval_request'
|
|
283
|
+
&& input.source.id
|
|
284
|
+
) {
|
|
285
|
+
payload = { ...payload, actionUrl: `/system/approvals?request=${encodeURIComponent(input.source.id)}` };
|
|
286
|
+
}
|
|
252
287
|
try {
|
|
253
|
-
await this.messaging.emit({ severity: 'info', ...input, audience });
|
|
288
|
+
await this.messaging.emit({ severity: 'info', ...input, payload, audience });
|
|
254
289
|
return audience.length;
|
|
255
290
|
} catch (err: any) {
|
|
256
291
|
this.logger?.warn?.('[approvals] notification failed', {
|
|
@@ -274,10 +309,10 @@ export class ApprovalService implements IApprovalService {
|
|
|
274
309
|
|
|
275
310
|
/**
|
|
276
311
|
* Expand the approvers on an Approval node into user IDs by querying the
|
|
277
|
-
* graph tables for `team:` / `department:` / `position:` /
|
|
278
|
-
* `manager:` approver types. Falls back to a
|
|
279
|
-
* (`type:value`) when graph lookups produce nothing — so
|
|
280
|
-
* and flows that rely on substring matching keep working.
|
|
312
|
+
* graph tables for `team:` / `department:` / `position:` /
|
|
313
|
+
* `org_membership_level:` / `manager:` approver types. Falls back to a
|
|
314
|
+
* prefixed literal (`type:value`) when graph lookups produce nothing — so
|
|
315
|
+
* existing fixtures and flows that rely on substring matching keep working.
|
|
281
316
|
*
|
|
282
317
|
* **Graph semantics:**
|
|
283
318
|
* - `team` → flat members of `sys_team` (better-auth; no BFS)
|
|
@@ -285,46 +320,109 @@ export class ApprovalService implements IApprovalService {
|
|
|
285
320
|
* → members of every descendant via `sys_business_unit_member`
|
|
286
321
|
* - `position` → holders via `sys_user_position` ∪ `sys_member.role`
|
|
287
322
|
* transition source (ADR-0090 D3 / ADR-0057 D4)
|
|
288
|
-
* - `
|
|
323
|
+
* - `org_membership_level`
|
|
324
|
+
* → users with `sys_member.role = value` in tenant — the
|
|
289
325
|
* better-auth MEMBERSHIP TIER (owner/admin/member), not a
|
|
290
326
|
* position; author `position` for org positions
|
|
291
327
|
* - `manager` → `sys_user.manager_id` of `record[value] ?? record.owner_id`
|
|
292
328
|
* - `field` → literal user id stored in `record[value]`
|
|
293
329
|
* - `user` → literal value
|
|
330
|
+
*
|
|
331
|
+
* `role` is accepted as the deprecated spelling of `org_membership_level`
|
|
332
|
+
* (ADR-0090 D3) for one window: it resolves identically and logs a warning.
|
|
333
|
+
*
|
|
334
|
+
* **Out-of-office (#1322 M1):** individually-routed approvers — the ones that
|
|
335
|
+
* resolve to a specific person (`user` / `field` / `manager`) — are passed
|
|
336
|
+
* through {@link ApprovalService.applyOooDelegation}, which reroutes them onto
|
|
337
|
+
* an active delegate when the resolved user has declared OOO. Group/graph
|
|
338
|
+
* approvers (`team` / `department` / `position` / `org_membership_level`) are
|
|
339
|
+
* left untouched: a group still has its other members, and position-routed
|
|
340
|
+
* leave is already covered by ADR-0091 job delegation. Pass an `opts.now` /
|
|
341
|
+
* `opts.substitutions` collector to record the hops for audit + notification.
|
|
294
342
|
*/
|
|
295
|
-
private async expandApprovers(
|
|
343
|
+
private async expandApprovers(
|
|
344
|
+
step: any,
|
|
345
|
+
record?: any,
|
|
346
|
+
organizationId?: string | null,
|
|
347
|
+
opts?: { now?: number; substitutions?: OooSubstitution[]; groups?: Record<string, string[]> },
|
|
348
|
+
): Promise<string[]> {
|
|
296
349
|
if (!step || !Array.isArray(step.approvers)) return [];
|
|
350
|
+
const now = opts?.now ?? this.clock.now().getTime();
|
|
297
351
|
const out: string[] = [];
|
|
298
|
-
|
|
352
|
+
const specs: any[] = step.approvers;
|
|
353
|
+
for (let idx = 0; idx < specs.length; idx++) {
|
|
354
|
+
const a = specs[idx];
|
|
299
355
|
if (!a) continue;
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const users = await this.expandPositionUsers(String(a.value), organizationId);
|
|
311
|
-
if (users.length) { for (const u of users) out.push(u); continue; }
|
|
312
|
-
} else if (a.type === 'role') {
|
|
313
|
-
const users = await this.expandRoleUsers(String(a.value), organizationId);
|
|
314
|
-
if (users.length) { for (const u of users) out.push(u); continue; }
|
|
315
|
-
} else if (a.type === 'manager' && record) {
|
|
316
|
-
const subject = (record as any)[a.value] ?? (record as any).owner_id;
|
|
317
|
-
if (subject) {
|
|
318
|
-
const mgr = await this.lookupManager(String(subject));
|
|
319
|
-
if (mgr) { out.push(mgr); continue; }
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
} catch { /* fall through */ }
|
|
323
|
-
out.push(`${a.type}:${a.value}`);
|
|
356
|
+
const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
|
|
357
|
+
// per_group (#3266): tag each resolved id with this spec's group. An
|
|
358
|
+
// approver without an explicit `group` forms its own group keyed by
|
|
359
|
+
// position, so a plain per-approver list still behaves predictably.
|
|
360
|
+
const groupKey = a.group != null && String(a.group) !== '' ? String(a.group) : `#${idx}`;
|
|
361
|
+
for (const u of ids) {
|
|
362
|
+
if (!u) continue;
|
|
363
|
+
out.push(u);
|
|
364
|
+
if (opts?.groups) (opts.groups[u] ??= []).push(groupKey);
|
|
365
|
+
}
|
|
324
366
|
}
|
|
325
367
|
return out.filter(Boolean);
|
|
326
368
|
}
|
|
327
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Resolve ONE approver spec to concrete approver identities, applying OOO
|
|
372
|
+
* substitution (#1322) to individually-routed types. Extracted from
|
|
373
|
+
* {@link ApprovalService.expandApprovers} so the caller can tag each spec's
|
|
374
|
+
* resolved ids with a group (#3266) without duplicating the resolution logic.
|
|
375
|
+
* Returns the `type:value` literal as a single-element fallback when a graph
|
|
376
|
+
* lookup yields nothing — same behaviour as before the extraction.
|
|
377
|
+
*/
|
|
378
|
+
private async resolveApproverSpec(
|
|
379
|
+
a: any,
|
|
380
|
+
record: any,
|
|
381
|
+
organizationId: string | null | undefined,
|
|
382
|
+
now: number,
|
|
383
|
+
substitutions?: OooSubstitution[],
|
|
384
|
+
): Promise<string[]> {
|
|
385
|
+
// ADR-0090 D3: `role` is the deprecated spelling of `org_membership_level`.
|
|
386
|
+
// Resolve on the canonical type, but keep the AUTHORED spelling in the
|
|
387
|
+
// `type:value` fallback below — stored `sys_approval_approver` rows and
|
|
388
|
+
// `pending_approvers` slots from 15.x carry the old literal.
|
|
389
|
+
const type = canonicalApproverType(String(a.type));
|
|
390
|
+
if (type !== a.type) {
|
|
391
|
+
this.logger?.warn?.(
|
|
392
|
+
`[approvals] approver type '${a.type}' is deprecated (ADR-0090 D3) — author '${type}' instead`,
|
|
393
|
+
{ deprecated: a.type, canonical: type },
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
if (type === 'user') {
|
|
397
|
+
return this.applyOooDelegation(String(a.value), now, organizationId, substitutions);
|
|
398
|
+
}
|
|
399
|
+
if (type === 'field' && record) {
|
|
400
|
+
return this.applyOooDelegation(String((record as any)[a.value] ?? ''), now, organizationId, substitutions);
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
if (type === 'team') {
|
|
404
|
+
const users = await this.expandTeamUsers(String(a.value));
|
|
405
|
+
if (users.length) return users;
|
|
406
|
+
} else if (type === 'department' || type === 'business_unit' || type === 'bu') {
|
|
407
|
+
const users = await this.expandBusinessUnitUsers(String(a.value), organizationId);
|
|
408
|
+
if (users.length) return users;
|
|
409
|
+
} else if (type === 'position') {
|
|
410
|
+
const users = await this.expandPositionUsers(String(a.value), organizationId);
|
|
411
|
+
if (users.length) return users;
|
|
412
|
+
} else if (type === 'org_membership_level') {
|
|
413
|
+
const users = await this.expandMembershipTierUsers(String(a.value), organizationId);
|
|
414
|
+
if (users.length) return users;
|
|
415
|
+
} else if (type === 'manager' && record) {
|
|
416
|
+
const subject = (record as any)[a.value] ?? (record as any).owner_id;
|
|
417
|
+
if (subject) {
|
|
418
|
+
const mgr = await this.lookupManager(String(subject));
|
|
419
|
+
if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
} catch { /* fall through */ }
|
|
423
|
+
return [`${a.type}:${a.value}`];
|
|
424
|
+
}
|
|
425
|
+
|
|
328
426
|
/** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
|
|
329
427
|
private async expandTeamUsers(teamId: string): Promise<string[]> {
|
|
330
428
|
if (!teamId) return [];
|
|
@@ -406,14 +504,23 @@ export class ApprovalService implements IApprovalService {
|
|
|
406
504
|
if (uid) users.add(uid);
|
|
407
505
|
}
|
|
408
506
|
} catch { /* table may not exist on minimal stacks — union source below still applies */ }
|
|
409
|
-
|
|
507
|
+
// ADR-0057 D4 transition source: pre-migration stacks still carry the
|
|
508
|
+
// position name in better-auth's `sys_member.role` column, so the same
|
|
509
|
+
// lookup serves a position name here and a membership tier for
|
|
510
|
+
// `org_membership_level` — the column is one, the two concepts are not.
|
|
511
|
+
for (const uid of await this.expandMembershipTierUsers(positionName, organizationId)) users.add(uid);
|
|
410
512
|
return Array.from(users);
|
|
411
513
|
}
|
|
412
514
|
|
|
413
|
-
/**
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
515
|
+
/**
|
|
516
|
+
* better-auth org-membership tier (`sys_member.role`: owner/admin/member) —
|
|
517
|
+
* NOT positions. Named for the projection (`org_membership_level`, ADR-0057
|
|
518
|
+
* D7 / ADR-0090 D3), not for better-auth's column: the column name is theirs
|
|
519
|
+
* and stays, the platform-facing word does not.
|
|
520
|
+
*/
|
|
521
|
+
private async expandMembershipTierUsers(tier: string, organizationId?: string | null): Promise<string[]> {
|
|
522
|
+
if (!tier) return [];
|
|
523
|
+
const filter: any = { role: tier };
|
|
417
524
|
if (organizationId) filter.organization_id = organizationId;
|
|
418
525
|
let rows: any[] = [];
|
|
419
526
|
try {
|
|
@@ -432,6 +539,74 @@ export class ApprovalService implements IApprovalService {
|
|
|
432
539
|
} catch { return null; }
|
|
433
540
|
}
|
|
434
541
|
|
|
542
|
+
/**
|
|
543
|
+
* Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
|
|
544
|
+
* id, follow any active `sys_approval_delegation` chain and return the id the
|
|
545
|
+
* slot should actually go to — the delegate acts under their own identity, so
|
|
546
|
+
* no impersonation is involved. Returns `[userId]` unchanged when there is no
|
|
547
|
+
* active delegation. Each hop is appended to `collector` (when supplied) so
|
|
548
|
+
* the caller can audit + notify (M4).
|
|
549
|
+
*
|
|
550
|
+
* The chain (A out → B, B out → C, …) is bounded by {@link OOO_MAX_CHAIN} and
|
|
551
|
+
* stops on a self-reference or a cycle, so a mis-declared loop degrades to the
|
|
552
|
+
* last reachable delegate rather than hanging.
|
|
553
|
+
*/
|
|
554
|
+
private async applyOooDelegation(
|
|
555
|
+
userId: string,
|
|
556
|
+
now: number,
|
|
557
|
+
organizationId?: string | null,
|
|
558
|
+
collector?: OooSubstitution[],
|
|
559
|
+
): Promise<string[]> {
|
|
560
|
+
const start = String(userId ?? '').trim();
|
|
561
|
+
if (!start) return [];
|
|
562
|
+
let current = start;
|
|
563
|
+
const visited = new Set<string>([current]);
|
|
564
|
+
for (let hop = 0; hop < OOO_MAX_CHAIN; hop++) {
|
|
565
|
+
const del = await this.lookupActiveDelegation(current, now, organizationId);
|
|
566
|
+
if (!del) break;
|
|
567
|
+
const to = String(del.delegate_id ?? '').trim();
|
|
568
|
+
if (!to || to === current || visited.has(to)) break; // no-op / self / cycle
|
|
569
|
+
collector?.push({ from: current, to, reason: del.reason != null ? String(del.reason) : null });
|
|
570
|
+
visited.add(to);
|
|
571
|
+
current = to;
|
|
572
|
+
}
|
|
573
|
+
return [current];
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* The active OOO delegation for a delegator at `now`, or null. Validity is the
|
|
578
|
+
* shared `isGrantActive` half-open window (ADR-0091 D2), enforced here at
|
|
579
|
+
* resolution time — never by a background job. When several rows are active,
|
|
580
|
+
* the one expiring soonest wins (the most specific coverage window).
|
|
581
|
+
*/
|
|
582
|
+
private async lookupActiveDelegation(
|
|
583
|
+
delegatorId: string,
|
|
584
|
+
now: number,
|
|
585
|
+
organizationId?: string | null,
|
|
586
|
+
): Promise<any | null> {
|
|
587
|
+
if (!delegatorId) return null;
|
|
588
|
+
let rows: any[] = [];
|
|
589
|
+
try {
|
|
590
|
+
rows = await this.engine.find('sys_approval_delegation', {
|
|
591
|
+
filter: { delegator_id: delegatorId },
|
|
592
|
+
fields: ['id', 'delegator_id', 'delegate_id', 'valid_from', 'valid_until', 'reason', 'organization_id'],
|
|
593
|
+
limit: 50,
|
|
594
|
+
context: SYSTEM_CTX,
|
|
595
|
+
} as any);
|
|
596
|
+
} catch { return null; } // table absent on minimal stacks — no OOO, resolve as-is
|
|
597
|
+
const active = (rows ?? []).filter((r: any) =>
|
|
598
|
+
isGrantActive(r, now)
|
|
599
|
+
// A null-org rule applies across tenants; a scoped rule only within its tenant.
|
|
600
|
+
&& (organizationId == null || r.organization_id == null || String(r.organization_id) === String(organizationId)));
|
|
601
|
+
if (!active.length) return null;
|
|
602
|
+
active.sort((a: any, b: any) => {
|
|
603
|
+
const au = a.valid_until ? Date.parse(String(a.valid_until)) : Number.POSITIVE_INFINITY;
|
|
604
|
+
const bu = b.valid_until ? Date.parse(String(b.valid_until)) : Number.POSITIVE_INFINITY;
|
|
605
|
+
return au - bu;
|
|
606
|
+
});
|
|
607
|
+
return active[0];
|
|
608
|
+
}
|
|
609
|
+
|
|
435
610
|
/** Mirror a request status onto a business-object field, if configured. */
|
|
436
611
|
private async mirrorStatusField(object: string, recordId: string, field: string, status: string): Promise<void> {
|
|
437
612
|
try {
|
|
@@ -486,9 +661,19 @@ export class ApprovalService implements IApprovalService {
|
|
|
486
661
|
}
|
|
487
662
|
|
|
488
663
|
const ctxOrg = (context as any)?.organizationId ?? (context as any)?.tenantId ?? input.organizationId ?? null;
|
|
489
|
-
const
|
|
664
|
+
const nowDate = this.clock.now();
|
|
665
|
+
// OOO auto-skip (#1322 M1): reroute individually-routed approvers who are
|
|
666
|
+
// out of office. Collected hops drive the audit + notification below (M4).
|
|
667
|
+
const substitutions: OooSubstitution[] = [];
|
|
668
|
+
// Group membership per resolved approver (#3266) — snapshotted so quorum /
|
|
669
|
+
// per_group finalization is decided against the slate resolved at OPEN time
|
|
670
|
+
// (OOO-substituted), not re-resolved live at each decision.
|
|
671
|
+
const groups: Record<string, string[]> = {};
|
|
672
|
+
const approvers = await this.expandApprovers(
|
|
673
|
+
{ approvers: input.config.approvers }, input.record, ctxOrg, { now: nowDate.getTime(), substitutions, groups },
|
|
674
|
+
);
|
|
490
675
|
|
|
491
|
-
const now =
|
|
676
|
+
const now = nowDate.toISOString();
|
|
492
677
|
const id = uid('areq');
|
|
493
678
|
const processName = `flow:${input.flowName ?? input.nodeId}`;
|
|
494
679
|
// Display labels ride the config snapshot (no schema migration needed);
|
|
@@ -496,6 +681,10 @@ export class ApprovalService implements IApprovalService {
|
|
|
496
681
|
const configSnapshot: any = { ...input.config };
|
|
497
682
|
if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
|
|
498
683
|
if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
|
|
684
|
+
// Snapshot the resolved approver→group map for quorum/per_group tallying.
|
|
685
|
+
if (input.config.behavior === 'quorum' || input.config.behavior === 'per_group') {
|
|
686
|
+
configSnapshot.__approverGroups = groups;
|
|
687
|
+
}
|
|
499
688
|
// ADR-0044 round numbering: rounds of a revise loop share the run — count
|
|
500
689
|
// this (run, node)'s prior requests; the new one is round N+1. Stamped on
|
|
501
690
|
// the snapshot (precedent: __flowLabel), so no schema migration.
|
|
@@ -532,6 +721,42 @@ export class ApprovalService implements IApprovalService {
|
|
|
532
721
|
actor_id: input.submitterId ?? context.userId ?? null, comment: null, created_at: now,
|
|
533
722
|
}, { context: SYSTEM_CTX });
|
|
534
723
|
|
|
724
|
+
// OOO substitution audit + notification (#1322 M4). Each hop that rerouted
|
|
725
|
+
// an approver away from an out-of-office user is recorded on the request's
|
|
726
|
+
// audit trail (a system action, no human actor) and notified to both the
|
|
727
|
+
// delegate — who now owns the slot — and the skipped approver.
|
|
728
|
+
for (const sub of substitutions) {
|
|
729
|
+
await this.engine.insert('sys_approval_action', {
|
|
730
|
+
id: uid('aact'), request_id: id, organization_id: ctxOrg,
|
|
731
|
+
step_name: input.nodeId, step_index: 0, action: 'ooo_substitute',
|
|
732
|
+
actor_id: null,
|
|
733
|
+
comment: `${sub.from} → ${sub.to}${sub.reason ? ` — ${sub.reason}` : ''}`,
|
|
734
|
+
created_at: now,
|
|
735
|
+
}, { context: SYSTEM_CTX });
|
|
736
|
+
await this.notify({
|
|
737
|
+
topic: 'approval.ooo_substituted',
|
|
738
|
+
audience: [sub.to],
|
|
739
|
+
source: { object: 'sys_approval_request', id },
|
|
740
|
+
dedupKey: `approval-ooo-${id}-${sub.to}`,
|
|
741
|
+
payload: {
|
|
742
|
+
title: 'Approval routed to you (out-of-office cover)',
|
|
743
|
+
message: `You are covering an approval on ${input.object}/${input.recordId} while ${sub.from} is out of office.`,
|
|
744
|
+
actionUrl: '/system/approvals',
|
|
745
|
+
},
|
|
746
|
+
});
|
|
747
|
+
await this.notify({
|
|
748
|
+
topic: 'approval.ooo_skipped',
|
|
749
|
+
audience: [sub.from],
|
|
750
|
+
source: { object: 'sys_approval_request', id },
|
|
751
|
+
dedupKey: `approval-ooo-skip-${id}-${sub.from}`,
|
|
752
|
+
payload: {
|
|
753
|
+
title: 'Approval routed to your delegate',
|
|
754
|
+
message: `An approval on ${input.object}/${input.recordId} was routed to ${sub.to} while you are out of office.`,
|
|
755
|
+
actionUrl: '/system/approvals',
|
|
756
|
+
},
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
|
|
535
760
|
// Record lock (when `lockRecord !== false`) is enforced by the beforeUpdate
|
|
536
761
|
// hook keyed on the now-pending request; no extra write needed here.
|
|
537
762
|
if (input.config.approvalStatusField) {
|
|
@@ -542,15 +767,54 @@ export class ApprovalService implements IApprovalService {
|
|
|
542
767
|
}
|
|
543
768
|
|
|
544
769
|
/**
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
770
|
+
* True when the approve tally satisfies the node's `behavior` (#3266):
|
|
771
|
+
* - `unanimous` — every resolved approver approved.
|
|
772
|
+
* - `quorum` — at least `minApprovals` distinct approvals (default = all).
|
|
773
|
+
* - `per_group` — every group reached `minApprovals` approvals (default 1).
|
|
774
|
+
* Thresholds are clamped to the resolvable count / group size, so a mis-set
|
|
775
|
+
* value can never deadlock a request.
|
|
776
|
+
*/
|
|
777
|
+
private isApprovalSatisfied(
|
|
778
|
+
behavior: string,
|
|
779
|
+
config: ApprovalNodeConfig,
|
|
780
|
+
original: string[],
|
|
781
|
+
groupMap: Record<string, string[]>,
|
|
782
|
+
approved: Set<string>,
|
|
783
|
+
): boolean {
|
|
784
|
+
if (behavior === 'unanimous') {
|
|
785
|
+
return original.length > 0 && original.every(a => approved.has(a));
|
|
786
|
+
}
|
|
787
|
+
if (behavior === 'quorum') {
|
|
788
|
+
const n = original.length || 1;
|
|
789
|
+
const need = Math.min(Math.max(1, config.minApprovals ?? n), n);
|
|
790
|
+
// Count distinct approvals (robust to OOO/reassign changing who holds a slot).
|
|
791
|
+
return approved.size >= need;
|
|
792
|
+
}
|
|
793
|
+
if (behavior === 'per_group') {
|
|
794
|
+
const perGroupNeed = Math.max(1, config.minApprovals ?? 1);
|
|
795
|
+
const size: Record<string, number> = {};
|
|
796
|
+
for (const gs of Object.values(groupMap)) for (const g of gs) size[g] = (size[g] ?? 0) + 1;
|
|
797
|
+
const groups = Object.keys(size);
|
|
798
|
+
if (!groups.length) return true; // nothing to gate
|
|
799
|
+
const got: Record<string, number> = {};
|
|
800
|
+
for (const a of approved) for (const g of (groupMap[a] ?? [])) got[g] = (got[g] ?? 0) + 1;
|
|
801
|
+
return groups.every(g => (got[g] ?? 0) >= Math.min(perGroupNeed, size[g]));
|
|
802
|
+
}
|
|
803
|
+
return true; // first_response and unknown → first approval finalizes
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Record a decision on a node-driven request. Honours the node's `behavior`
|
|
808
|
+
* (#3266): `first_response` finalizes on the first approval; `unanimous`,
|
|
809
|
+
* `quorum`, and `per_group` hold the request open until their tally is met
|
|
810
|
+
* (see {@link ApprovalService.isApprovalSatisfied}). A rejection always
|
|
811
|
+
* finalizes the node (one veto). When the request finalizes, returns the
|
|
812
|
+
* suspended run id + node id so the caller (or {@link ApprovalService.decide})
|
|
813
|
+
* can resume the flow down the matching branch.
|
|
550
814
|
*/
|
|
551
815
|
async decideNode(
|
|
552
816
|
requestId: string,
|
|
553
|
-
input: { decision: 'approve' | 'reject'; actorId: string; comment?: string },
|
|
817
|
+
input: { decision: 'approve' | 'reject'; actorId: string; comment?: string; attachments?: string[] },
|
|
554
818
|
context: SharingExecutionContext,
|
|
555
819
|
): Promise<{ request: ApprovalRequestRow; runId: string | null; nodeId: string | null; finalized: boolean; decision: 'approve' | 'reject' }> {
|
|
556
820
|
if (!requestId) throw new Error('VALIDATION_FAILED: requestId is required');
|
|
@@ -578,24 +842,43 @@ export class ApprovalService implements IApprovalService {
|
|
|
578
842
|
const runId: string | null = raw.flow_run_id ?? null;
|
|
579
843
|
const now = this.clock.now().toISOString();
|
|
580
844
|
|
|
581
|
-
// Audit the decision first so the
|
|
845
|
+
// Audit the decision first so the quorum/per_group tally below sees it.
|
|
582
846
|
await this.engine.insert('sys_approval_action', {
|
|
583
847
|
id: uid('aact'), request_id: requestId, organization_id: org,
|
|
584
848
|
step_name: nodeId, step_index: 0, action: input.decision,
|
|
585
|
-
actor_id: input.actorId, comment: input.comment ?? null,
|
|
849
|
+
actor_id: input.actorId, comment: input.comment ?? null,
|
|
850
|
+
attachments: input.attachments?.length ? input.attachments : null,
|
|
851
|
+
created_at: now,
|
|
586
852
|
}, { context: SYSTEM_CTX });
|
|
587
853
|
|
|
588
|
-
//
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
854
|
+
// Multi-approver aggregation on approve (#3266). A rejection always
|
|
855
|
+
// finalizes the node (one veto), so only the approve path can hold it open.
|
|
856
|
+
// `first_response` finalizes on the first approval (falls straight through).
|
|
857
|
+
const behavior = config.behavior ?? 'first_response';
|
|
858
|
+
if (input.decision === 'approve' && behavior !== 'first_response') {
|
|
593
859
|
const acts = await this.engine.find('sys_approval_action', {
|
|
594
|
-
where: { request_id: requestId, step_index: 0, action: 'approve' }, limit:
|
|
860
|
+
where: { request_id: requestId, step_index: 0, action: 'approve' }, limit: 1000, context: SYSTEM_CTX,
|
|
595
861
|
});
|
|
596
862
|
const approved = new Set<string>((acts ?? []).map((a: any) => String(a.actor_id ?? '')).filter(Boolean));
|
|
597
|
-
|
|
598
|
-
|
|
863
|
+
|
|
864
|
+
// quorum / per_group tally against the OPEN-time snapshot (already
|
|
865
|
+
// OOO-substituted). unanimous re-resolves for back-compat with requests
|
|
866
|
+
// opened before the snapshot existed.
|
|
867
|
+
const snapshotGroups = (config as any).__approverGroups as Record<string, string[]> | undefined;
|
|
868
|
+
let original: string[];
|
|
869
|
+
let groupMap: Record<string, string[]>;
|
|
870
|
+
if (snapshotGroups && (behavior === 'quorum' || behavior === 'per_group')) {
|
|
871
|
+
groupMap = snapshotGroups;
|
|
872
|
+
original = Object.keys(snapshotGroups);
|
|
873
|
+
} else {
|
|
874
|
+
original = await this.expandApprovers(
|
|
875
|
+
{ approvers: config.approvers }, parseJson(raw.payload_json, undefined), org,
|
|
876
|
+
);
|
|
877
|
+
groupMap = {};
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (!this.isApprovalSatisfied(behavior, config, original, groupMap, approved)) {
|
|
881
|
+
const stillPending = original.filter(a => !approved.has(a));
|
|
599
882
|
await this.engine.update('sys_approval_request', {
|
|
600
883
|
id: requestId, pending_approvers: stillPending.join(','), updated_at: now,
|
|
601
884
|
}, { context: SYSTEM_CTX });
|
|
@@ -1038,8 +1321,21 @@ export class ApprovalService implements IApprovalService {
|
|
|
1038
1321
|
step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'reassign',
|
|
1039
1322
|
actor_id: input.actorId, comment: input.comment ?? `${from} → ${to}`, created_at: now,
|
|
1040
1323
|
}, { context: SYSTEM_CTX });
|
|
1324
|
+
// per_group / quorum (#3266): carry the delegated slot's group membership to
|
|
1325
|
+
// the new approver in the snapshot, so their approval still counts for the
|
|
1326
|
+
// original group.
|
|
1327
|
+
let configPatch: Record<string, unknown> = {};
|
|
1328
|
+
try {
|
|
1329
|
+
const cfg = parseJson<any>(raw.node_config_json, null);
|
|
1330
|
+
const groups = cfg?.__approverGroups as Record<string, string[]> | undefined;
|
|
1331
|
+
if (groups && groups[from] && !groups[to]) {
|
|
1332
|
+
groups[to] = groups[from];
|
|
1333
|
+
delete groups[from];
|
|
1334
|
+
configPatch = { node_config_json: JSON.stringify(cfg) };
|
|
1335
|
+
}
|
|
1336
|
+
} catch { /* snapshot left untouched on parse failure */ }
|
|
1041
1337
|
await this.engine.update('sys_approval_request', {
|
|
1042
|
-
id: requestId, pending_approvers: next.join(','), updated_at: now,
|
|
1338
|
+
id: requestId, pending_approvers: next.join(','), updated_at: now, ...configPatch,
|
|
1043
1339
|
}, { context: SYSTEM_CTX });
|
|
1044
1340
|
await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
|
|
1045
1341
|
|
|
@@ -1285,7 +1581,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1285
1581
|
/** Free-form reply on the thread (submitter or any pending approver). */
|
|
1286
1582
|
async comment(
|
|
1287
1583
|
requestId: string,
|
|
1288
|
-
input: { actorId: string; comment: string },
|
|
1584
|
+
input: { actorId: string; comment: string; attachments?: string[] },
|
|
1289
1585
|
context: SharingExecutionContext,
|
|
1290
1586
|
): Promise<{ request: ApprovalRequestRow }> {
|
|
1291
1587
|
if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
|
|
@@ -1301,7 +1597,9 @@ export class ApprovalService implements IApprovalService {
|
|
|
1301
1597
|
await this.engine.insert('sys_approval_action', {
|
|
1302
1598
|
id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
|
|
1303
1599
|
step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'comment',
|
|
1304
|
-
actor_id: input.actorId, comment: input.comment.trim(),
|
|
1600
|
+
actor_id: input.actorId, comment: input.comment.trim(),
|
|
1601
|
+
attachments: input.attachments?.length ? input.attachments : null,
|
|
1602
|
+
created_at: now,
|
|
1305
1603
|
}, { context: SYSTEM_CTX });
|
|
1306
1604
|
|
|
1307
1605
|
// Notify the other side of the thread.
|
|
@@ -1876,6 +2174,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1876
2174
|
const rows = await this.engine.find('sys_approval_request', findOpts);
|
|
1877
2175
|
const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
|
|
1878
2176
|
await this.enrichRows(list);
|
|
2177
|
+
this.attachViewers(list, context);
|
|
1879
2178
|
return list;
|
|
1880
2179
|
}
|
|
1881
2180
|
|
|
@@ -1922,9 +2221,77 @@ export class ApprovalService implements IApprovalService {
|
|
|
1922
2221
|
const row = rowFromRequest(rows[0]);
|
|
1923
2222
|
await this.enrichRows([row]);
|
|
1924
2223
|
await this.attachFlowSteps(row);
|
|
2224
|
+
await this.attachDecisionProgress(row, rows[0]);
|
|
2225
|
+
this.attachViewers([row], context);
|
|
1925
2226
|
return row;
|
|
1926
2227
|
}
|
|
1927
2228
|
|
|
2229
|
+
/**
|
|
2230
|
+
* Server-computed decision aggregation progress (#3266 / objectui#2678 P1.5).
|
|
2231
|
+
* Single-read enrichment only (like {@link ApprovalService.attachFlowSteps}):
|
|
2232
|
+
* for a PENDING request whose behavior aggregates multiple approvals
|
|
2233
|
+
* (`unanimous` / `quorum` / `per_group`), expose
|
|
2234
|
+
* `decision_progress: { behavior, got, need, groups? }` so any client renders
|
|
2235
|
+
* "2 of 3" or per-group ticks without re-deriving the engine's tally rules.
|
|
2236
|
+
* `first_response` requests carry no progress (one approval finalizes).
|
|
2237
|
+
* Display-only and best-effort — errors leave the row untouched.
|
|
2238
|
+
*/
|
|
2239
|
+
private async attachDecisionProgress(row: ApprovalRequestRow, raw: any): Promise<void> {
|
|
2240
|
+
try {
|
|
2241
|
+
if (row.status !== 'pending') return;
|
|
2242
|
+
const cfg = parseJson<any>(raw.node_config_json, undefined);
|
|
2243
|
+
const behavior = cfg?.behavior ?? 'first_response';
|
|
2244
|
+
if (behavior !== 'unanimous' && behavior !== 'quorum' && behavior !== 'per_group') return;
|
|
2245
|
+
|
|
2246
|
+
const acts = await this.engine.find('sys_approval_action', {
|
|
2247
|
+
where: { request_id: row.id, step_index: 0, action: 'approve' }, limit: 1000, context: SYSTEM_CTX,
|
|
2248
|
+
});
|
|
2249
|
+
const approved = new Set<string>((acts ?? []).map((a: any) => String(a.actor_id ?? '')).filter(Boolean));
|
|
2250
|
+
|
|
2251
|
+
const snapshot = cfg?.__approverGroups as Record<string, string[]> | undefined;
|
|
2252
|
+
const slate = snapshot ? Object.keys(snapshot) : [...approved, ...(row.pending_approvers ?? [])];
|
|
2253
|
+
const total = slate.length || 1;
|
|
2254
|
+
|
|
2255
|
+
const progress: any = { behavior, got: approved.size, need: total };
|
|
2256
|
+
if (behavior === 'quorum') {
|
|
2257
|
+
progress.need = Math.min(Math.max(1, cfg?.minApprovals ?? total), total);
|
|
2258
|
+
} else if (behavior === 'per_group' && snapshot) {
|
|
2259
|
+
const perGroupNeed = Math.max(1, cfg?.minApprovals ?? 1);
|
|
2260
|
+
const size: Record<string, number> = {};
|
|
2261
|
+
for (const gs of Object.values(snapshot)) for (const g of gs) size[g] = (size[g] ?? 0) + 1;
|
|
2262
|
+
const got: Record<string, number> = {};
|
|
2263
|
+
for (const a of approved) for (const g of (snapshot[a] ?? [])) got[g] = (got[g] ?? 0) + 1;
|
|
2264
|
+
progress.groups = Object.keys(size).sort().map(g => {
|
|
2265
|
+
const need = Math.min(perGroupNeed, size[g]);
|
|
2266
|
+
return { group: g, got: Math.min(got[g] ?? 0, need), need, satisfied: (got[g] ?? 0) >= need };
|
|
2267
|
+
});
|
|
2268
|
+
progress.got = progress.groups.filter((g: any) => g.satisfied).length;
|
|
2269
|
+
progress.need = progress.groups.length;
|
|
2270
|
+
}
|
|
2271
|
+
(row as any).decision_progress = progress;
|
|
2272
|
+
} catch { /* display-only enrichment */ }
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
/**
|
|
2276
|
+
* Attach the per-viewer capability block (#3310) from the caller's context.
|
|
2277
|
+
* `can_act` mirrors the exact authorization the decision methods enforce — the
|
|
2278
|
+
* caller's user id is in the resolved `pending_approvers` while the request is
|
|
2279
|
+
* still `pending` (position/team/manager approvers are already resolved to
|
|
2280
|
+
* concrete user ids at open time, so a plain membership test is faithful).
|
|
2281
|
+
* `is_submitter` is a straight owner check. System/tokenless contexts get a
|
|
2282
|
+
* both-false block. Cheap + synchronous — safe on list reads.
|
|
2283
|
+
*/
|
|
2284
|
+
private attachViewers(rows: ApprovalRequestRow[], context: SharingExecutionContext): void {
|
|
2285
|
+
const uid = (context as any)?.userId != null ? String((context as any).userId) : null;
|
|
2286
|
+
for (const row of rows) {
|
|
2287
|
+
const pending = row.pending_approvers ?? [];
|
|
2288
|
+
(row as any).viewer = {
|
|
2289
|
+
can_act: row.status === 'pending' && !!uid && pending.includes(uid),
|
|
2290
|
+
is_submitter: !!uid && row.submitter_id != null && String(row.submitter_id) === uid,
|
|
2291
|
+
};
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
|
|
1928
2295
|
/**
|
|
1929
2296
|
* Derive approval-step progress from the owning flow's graph (single-read
|
|
1930
2297
|
* enrichment only — list reads skip it). Walks from the start node
|