@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.
@@ -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:` / `role:` /
278
- * `manager:` approver types. Falls back to a prefixed literal
279
- * (`type:value`) when graph lookups produce nothing — so existing fixtures
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
- * - `role` → users with `sys_member.role = value` in tenant — the
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(step: any, record?: any, organizationId?: string | null): Promise<string[]> {
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
- for (const a of step.approvers) {
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
- if (a.type === 'user') { out.push(String(a.value)); continue; }
301
- if (a.type === 'field' && record) { out.push(String((record as any)[a.value] ?? '')); continue; }
302
- try {
303
- if (a.type === 'team') {
304
- const users = await this.expandTeamUsers(String(a.value));
305
- if (users.length) { for (const u of users) out.push(u); continue; }
306
- } else if (a.type === 'department' || a.type === 'business_unit' || a.type === 'bu') {
307
- const users = await this.expandBusinessUnitUsers(String(a.value), organizationId);
308
- if (users.length) { for (const u of users) out.push(u); continue; }
309
- } else if (a.type === 'position') {
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
- for (const uid of await this.expandRoleUsers(positionName, organizationId)) users.add(uid);
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
- /** better-auth org-membership tier (`sys_member.role`) — NOT positions. */
414
- private async expandRoleUsers(roleName: string, organizationId?: string | null): Promise<string[]> {
415
- if (!roleName) return [];
416
- const filter: any = { role: roleName };
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 approvers = await this.expandApprovers({ approvers: input.config.approvers }, input.record, ctxOrg);
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 = this.clock.now().toISOString();
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
- * Record a decision on a node-driven request. Honours the node's `unanimous`
546
- * behavior (holds until every approver has approved). When the request
547
- * finalizes, returns the suspended run id + node id so the caller (or
548
- * {@link ApprovalService.decide}) can resume the flow down the matching
549
- * branch.
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 unanimous tally below sees it.
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, created_at: now,
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
- // Unanimous approve: advance only once every approver has approved.
589
- if (input.decision === 'approve' && config.behavior === 'unanimous') {
590
- const original = await this.expandApprovers(
591
- { approvers: config.approvers }, parseJson(raw.payload_json, undefined), org,
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: 500, context: SYSTEM_CTX,
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
- const stillPending = original.filter(a => !approved.has(a));
598
- if (stillPending.length > 0) {
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(), created_at: now,
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