@objectstack/plugin-approvals 15.1.1 → 16.0.0-rc.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/plugin-approvals",
3
- "version": "15.1.1",
3
+ "version": "16.0.0-rc.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Multi-step approval engine for ObjectStack — sys_approval_process + sys_approval_request + sys_approval_action + IApprovalService.",
6
6
  "main": "dist/index.js",
@@ -13,17 +13,17 @@
13
13
  }
14
14
  },
15
15
  "dependencies": {
16
- "@objectstack/core": "15.1.1",
17
- "@objectstack/formula": "15.1.1",
18
- "@objectstack/metadata-core": "15.1.1",
19
- "@objectstack/spec": "15.1.1",
20
- "@objectstack/platform-objects": "15.1.1"
16
+ "@objectstack/core": "16.0.0-rc.0",
17
+ "@objectstack/formula": "16.0.0-rc.0",
18
+ "@objectstack/metadata-core": "16.0.0-rc.0",
19
+ "@objectstack/platform-objects": "16.0.0-rc.0",
20
+ "@objectstack/spec": "16.0.0-rc.0"
21
21
  },
22
22
  "devDependencies": {
23
23
  "@types/node": "^26.1.1",
24
24
  "typescript": "^6.0.3",
25
25
  "vitest": "^4.1.10",
26
- "@objectstack/service-automation": "15.1.1"
26
+ "@objectstack/service-automation": "16.0.0-rc.0"
27
27
  },
28
28
  "keywords": [
29
29
  "objectstack",
@@ -15,6 +15,7 @@
15
15
  import { defineStack } from '@objectstack/spec';
16
16
  import { SysApprovalRequest } from '../src/sys-approval-request.object.js';
17
17
  import { SysApprovalAction } from '../src/sys-approval-action.object.js';
18
+ import { SysApprovalDelegation } from '../src/sys-approval-delegation.object.js';
18
19
  import { enObjects } from '../src/translations/en.objects.generated.js';
19
20
  import { zhCNObjects } from '../src/translations/zh-CN.objects.generated.js';
20
21
  import { jaJPObjects } from '../src/translations/ja-JP.objects.generated.js';
@@ -22,7 +23,7 @@ import { esESObjects } from '../src/translations/es-ES.objects.generated.js';
22
23
 
23
24
  export default defineStack({
24
25
  name: 'plugin-approvals-i18n-extract',
25
- objects: [SysApprovalRequest, SysApprovalAction] as any,
26
+ objects: [SysApprovalRequest, SysApprovalAction, SysApprovalDelegation] as any,
26
27
  translations: [
27
28
  { en: { objects: enObjects } },
28
29
  { 'zh-CN': { objects: zhCNObjects } },
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { describe, it, expect, beforeEach } from 'vitest';
13
13
  import { ApprovalService, REMIND_COOLDOWN_MS } from './approval-service.js';
14
- import { bindApprovalLockHook, unbindAllHooks } from './lifecycle-hooks.js';
14
+ import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js';
15
15
 
16
16
  interface FakeRow { [k: string]: any }
17
17
 
@@ -227,6 +227,57 @@ describe('ApprovalService (node era)', () => {
227
227
  expect(req.pending_approvers).toEqual(['position:sales_manager']);
228
228
  });
229
229
 
230
+ // ── approver expansion: org_membership_level + its deprecated `role` alias
231
+ // (ADR-0090 D3) ────────────────────────────────────────────────────────
232
+
233
+ // `recordId` is parameterised: the service rejects a second pending request
234
+ // on the same record, and the alias test deliberately opens two.
235
+ const tierInput = (type: 'org_membership_level' | 'role', recordId = 'opp1') => ({
236
+ ...openInput([]),
237
+ recordId,
238
+ record: { id: recordId, amount: 100 },
239
+ config: {
240
+ approvers: [{ type: type as any, value: 'admin' }],
241
+ behavior: 'first_response' as const,
242
+ lockRecord: true,
243
+ },
244
+ });
245
+
246
+ it('org_membership_level approver: expands the better-auth tier, org-scoped', async () => {
247
+ engine._tables['sys_member'] = [
248
+ { id: 'm1', user_id: 'u1', role: 'admin', organization_id: 't1' },
249
+ { id: 'm2', user_id: 'u2', role: 'admin', organization_id: 't1' },
250
+ { id: 'm3', user_id: 'u3', role: 'admin', organization_id: 't2' }, // other tenant
251
+ { id: 'm4', user_id: 'u4', role: 'member', organization_id: 't1' }, // other tier
252
+ ];
253
+ const req = await svc.openNodeRequest(tierInput('org_membership_level'), CTX);
254
+ expect(req.pending_approvers.sort()).toEqual(['u1', 'u2']);
255
+ });
256
+
257
+ it('deprecated `role` alias resolves IDENTICALLY to org_membership_level', async () => {
258
+ engine._tables['sys_member'] = [
259
+ { id: 'm1', user_id: 'u1', role: 'admin', organization_id: 't1' },
260
+ { id: 'm2', user_id: 'u4', role: 'member', organization_id: 't1' },
261
+ ];
262
+ const canonical = await svc.openNodeRequest(tierInput('org_membership_level', 'opp_canon'), CTX);
263
+ const deprecated = await svc.openNodeRequest(tierInput('role', 'opp_depr'), CTX);
264
+ expect(deprecated.pending_approvers).toEqual(canonical.pending_approvers);
265
+ expect(deprecated.pending_approvers).toEqual(['u1']);
266
+ });
267
+
268
+ // The fallback literal keeps the AUTHORED spelling: `sys_approval_approver`
269
+ // rows and `pending_approvers` slots written by 15.x carry `role:<v>`, and
270
+ // canonicalising the literal here would orphan every one of them.
271
+ it('deprecated `role` alias keeps its legacy literal on fallback (no orphaned slots)', async () => {
272
+ const req = await svc.openNodeRequest(tierInput('role'), CTX);
273
+ expect(req.pending_approvers).toEqual(['role:admin']);
274
+ });
275
+
276
+ it('org_membership_level falls back to its own canonical literal', async () => {
277
+ const req = await svc.openNodeRequest(tierInput('org_membership_level'), CTX);
278
+ expect(req.pending_approvers).toEqual(['org_membership_level:admin']);
279
+ });
280
+
230
281
  it("department approver: honors the spec enum value 'department' (not just the business_unit dialect)", async () => {
231
282
  engine._tables['sys_business_unit'] = [
232
283
  { id: 'bu1', organization_id: 't1', active: true },
@@ -1025,3 +1076,388 @@ describe('record-lock hook (node era)', () => {
1025
1076
  expect(engine._hooks['beforeUpdate']).toHaveLength(0);
1026
1077
  });
1027
1078
  });
1079
+
1080
+ // ── Out-of-office auto-skip (#1322 M1/M4) ─────────────────────────────
1081
+ //
1082
+ // When a resolved individual approver has declared an active OOO delegation,
1083
+ // the slot is rerouted to the delegate at resolution time (never a background
1084
+ // job), audited as `ooo_substitute`, and both parties are notified. Group /
1085
+ // graph approvers (position/team/department/tier) are left untouched.
1086
+ describe('ApprovalService — out-of-office delegation (#1322)', () => {
1087
+ // Mid-window instant for the issue's own example (leave 5/26–5/30).
1088
+ const OOO_NOW = new Date('2026-05-27T10:00:00Z').getTime();
1089
+ let engine: ReturnType<typeof makeFakeEngine>;
1090
+ let svc: ApprovalService;
1091
+ let emitted: any[];
1092
+
1093
+ function seedDelegation(rows: Array<Record<string, any>>) {
1094
+ engine._tables['sys_approval_delegation'] = rows.map((r, i) => ({
1095
+ id: `del${i}`,
1096
+ organization_id: 't1',
1097
+ valid_from: '2026-05-26T00:00:00Z',
1098
+ valid_until: '2026-05-30T00:00:00Z',
1099
+ reason: 'Annual leave',
1100
+ ...r,
1101
+ }));
1102
+ }
1103
+
1104
+ beforeEach(() => {
1105
+ engine = makeFakeEngine();
1106
+ emitted = [];
1107
+ svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(OOO_NOW) } });
1108
+ svc.attachMessaging({ emit: async (m: any) => { emitted.push(m); } });
1109
+ });
1110
+
1111
+ it('type:user — reroutes an out-of-office approver to the delegate', async () => {
1112
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
1113
+ const req = await svc.openNodeRequest(openInput(['alice']), CTX);
1114
+ expect(req.pending_approvers).toEqual(['bob']);
1115
+ });
1116
+
1117
+ it('records an ooo_substitute audit action with "A → B — reason"', async () => {
1118
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
1119
+ await svc.openNodeRequest(openInput(['alice']), CTX);
1120
+ const sub = engine._tables['sys_approval_action'].find((a: any) => a.action === 'ooo_substitute');
1121
+ expect(sub).toBeTruthy();
1122
+ expect(sub.comment).toBe('alice → bob — Annual leave');
1123
+ expect(sub.actor_id).toBeNull(); // system-recorded reroute, no human actor
1124
+ });
1125
+
1126
+ it('notifies both the delegate and the skipped approver', async () => {
1127
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
1128
+ await svc.openNodeRequest(openInput(['alice']), CTX);
1129
+ const to = emitted.find(e => e.topic === 'approval.ooo_substituted');
1130
+ const from = emitted.find(e => e.topic === 'approval.ooo_skipped');
1131
+ expect(to?.audience).toEqual(['bob']);
1132
+ expect(from?.audience).toEqual(['alice']);
1133
+ });
1134
+
1135
+ it('does not reroute before valid_from (window not yet open)', async () => {
1136
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', valid_from: '2026-05-28T00:00:00Z' }]);
1137
+ const req = await svc.openNodeRequest(openInput(['alice']), CTX);
1138
+ expect(req.pending_approvers).toEqual(['alice']);
1139
+ expect(engine._tables['sys_approval_action'].some((a: any) => a.action === 'ooo_substitute')).toBe(false);
1140
+ });
1141
+
1142
+ it('does not reroute at/after valid_until (half-open window)', async () => {
1143
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', valid_until: '2026-05-27T10:00:00Z' }]);
1144
+ const req = await svc.openNodeRequest(openInput(['alice']), CTX);
1145
+ expect(req.pending_approvers).toEqual(['alice']);
1146
+ });
1147
+
1148
+ it('type:field — reroutes the user stored in the record field', async () => {
1149
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
1150
+ const input = {
1151
+ ...openInput([]),
1152
+ record: { id: 'opp1', reviewer: 'alice' },
1153
+ config: { approvers: [{ type: 'field', value: 'reviewer' }], behavior: 'first_response', lockRecord: true },
1154
+ };
1155
+ const req = await svc.openNodeRequest(input as any, CTX);
1156
+ expect(req.pending_approvers).toEqual(['bob']);
1157
+ });
1158
+
1159
+ it('type:manager — reroutes when the resolved manager is out of office', async () => {
1160
+ engine._tables['sys_user'] = [{ id: 'carol', manager_id: 'alice' }];
1161
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
1162
+ const input = {
1163
+ ...openInput([]),
1164
+ record: { id: 'opp1', owner_id: 'carol' },
1165
+ config: { approvers: [{ type: 'manager', value: 'owner_id' }], behavior: 'first_response', lockRecord: true },
1166
+ };
1167
+ const req = await svc.openNodeRequest(input as any, CTX);
1168
+ expect(req.pending_approvers).toEqual(['bob']);
1169
+ });
1170
+
1171
+ it('follows a delegation chain A → B → C', async () => {
1172
+ seedDelegation([
1173
+ { delegator_id: 'alice', delegate_id: 'bob' },
1174
+ { delegator_id: 'bob', delegate_id: 'carol' },
1175
+ ]);
1176
+ const req = await svc.openNodeRequest(openInput(['alice']), CTX);
1177
+ expect(req.pending_approvers).toEqual(['carol']);
1178
+ expect(engine._tables['sys_approval_action'].filter((a: any) => a.action === 'ooo_substitute')).toHaveLength(2);
1179
+ });
1180
+
1181
+ it('stops on a cycle A → B → A without looping', async () => {
1182
+ seedDelegation([
1183
+ { delegator_id: 'alice', delegate_id: 'bob' },
1184
+ { delegator_id: 'bob', delegate_id: 'alice' },
1185
+ ]);
1186
+ const req = await svc.openNodeRequest(openInput(['alice']), CTX);
1187
+ expect(req.pending_approvers).toEqual(['bob']);
1188
+ });
1189
+
1190
+ it('ignores a self-delegation (A → A)', async () => {
1191
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'alice' }]);
1192
+ const req = await svc.openNodeRequest(openInput(['alice']), CTX);
1193
+ expect(req.pending_approvers).toEqual(['alice']);
1194
+ expect(engine._tables['sys_approval_action'].some((a: any) => a.action === 'ooo_substitute')).toBe(false);
1195
+ });
1196
+
1197
+ it('leaves approvers unchanged when there is no active delegation', async () => {
1198
+ const req = await svc.openNodeRequest(openInput(['alice']), CTX);
1199
+ expect(req.pending_approvers).toEqual(['alice']);
1200
+ });
1201
+
1202
+ it('does not OOO-substitute group-routed (position) approvers', async () => {
1203
+ engine._tables['sys_user_position'] = [{ id: 'up1', user_id: 'alice', position: 'sales_manager', organization_id: 't1' }];
1204
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
1205
+ const input = {
1206
+ ...openInput([]),
1207
+ config: { approvers: [{ type: 'position', value: 'sales_manager' }], behavior: 'first_response', lockRecord: true },
1208
+ };
1209
+ const req = await svc.openNodeRequest(input as any, CTX);
1210
+ // Position-routed leave is ADR-0091's job, not this path: the holder stays.
1211
+ expect(req.pending_approvers).toEqual(['alice']);
1212
+ });
1213
+
1214
+ it('respects tenant scope: a rule scoped to another org does not apply', async () => {
1215
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', organization_id: 't2' }]);
1216
+ const req = await svc.openNodeRequest(openInput(['alice']), CTX);
1217
+ expect(req.pending_approvers).toEqual(['alice']);
1218
+ });
1219
+
1220
+ it('applies a cross-tenant (null org) rule regardless of request tenant', async () => {
1221
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', organization_id: null }]);
1222
+ const req = await svc.openNodeRequest(openInput(['alice']), CTX);
1223
+ expect(req.pending_approvers).toEqual(['bob']);
1224
+ });
1225
+ });
1226
+
1227
+ // ── Delegation self-service write guard (#1322 follow-up) ─────────────
1228
+ //
1229
+ // sys_approval_delegation is apiEnabled CRUD; a member must not be able to
1230
+ // forge a delegation for someone else (delegator_id = victim) and reroute the
1231
+ // victim's approvals. The guard forces delegator_id == acting user for normal
1232
+ // writes; system/admin contexts bypass. Row-ownership on update/delete is the
1233
+ // platform's created_by RLS (not exercised here).
1234
+ describe('sys_approval_delegation write guard (#1322)', () => {
1235
+ const DEL = 'sys_approval_delegation';
1236
+ let engine: ReturnType<typeof makeFakeEngine>;
1237
+
1238
+ beforeEach(() => {
1239
+ engine = makeFakeEngine();
1240
+ bindDelegationWriteGuard(engine as any);
1241
+ });
1242
+
1243
+ const fireInsert = (data: any, session: any) =>
1244
+ (engine as any).fire('beforeInsert', { object: DEL, input: { data }, session });
1245
+ const fireUpdate = (data: any, session: any) =>
1246
+ (engine as any).fire('beforeUpdate', { object: DEL, input: { id: data?.id ?? 'd1', data }, session });
1247
+ const member = (userId?: string) => ({ isSystem: false, roles: [], ...(userId ? { userId } : {}) });
1248
+
1249
+ it('allows a member to create their own delegation', async () => {
1250
+ await expect(fireInsert({ delegator_id: 'u1', delegate_id: 'u2' }, member('u1'))).resolves.toBeUndefined();
1251
+ });
1252
+
1253
+ it('rejects a member forging a delegation for someone else', async () => {
1254
+ await expect(fireInsert({ delegator_id: 'victim', delegate_id: 'u1' }, member('u1'))).rejects.toThrow(/FORBIDDEN/);
1255
+ });
1256
+
1257
+ it('stamps the caller as delegator when omitted on insert', async () => {
1258
+ const data: any = { delegate_id: 'u2' };
1259
+ await fireInsert(data, member('u1'));
1260
+ expect(data.delegator_id).toBe('u1');
1261
+ });
1262
+
1263
+ it('rejects an unauthenticated non-system insert', async () => {
1264
+ await expect(fireInsert({ delegate_id: 'u2' }, member())).rejects.toThrow(/FORBIDDEN/);
1265
+ });
1266
+
1267
+ it('bypasses the guard for system context', async () => {
1268
+ await expect(fireInsert({ delegator_id: 'victim', delegate_id: 'u1' }, { isSystem: true })).resolves.toBeUndefined();
1269
+ });
1270
+
1271
+ it('lets an admin set the delegator to anyone', async () => {
1272
+ await expect(fireInsert({ delegator_id: 'victim', delegate_id: 'u2' }, { isSystem: false, roles: ['admin'], userId: 'admin1' })).resolves.toBeUndefined();
1273
+ });
1274
+
1275
+ it('rejects a member relabelling delegator on update', async () => {
1276
+ await expect(fireUpdate({ id: 'd1', delegator_id: 'victim' }, member('u1'))).rejects.toThrow(/FORBIDDEN/);
1277
+ });
1278
+
1279
+ it('allows a member update that does not touch delegator_id', async () => {
1280
+ await expect(fireUpdate({ id: 'd1', valid_until: '2026-06-01T00:00:00Z' }, member('u1'))).resolves.toBeUndefined();
1281
+ });
1282
+
1283
+ it('rejects a batch insert if any row names a foreign delegator', async () => {
1284
+ await expect(fireInsert(
1285
+ [{ delegator_id: 'u1', delegate_id: 'u2' }, { delegator_id: 'victim', delegate_id: 'u3' }],
1286
+ member('u1'),
1287
+ )).rejects.toThrow(/FORBIDDEN/);
1288
+ });
1289
+ });
1290
+
1291
+ // ── Quorum & per-group sign-off (#3266) ───────────────────────────────
1292
+ //
1293
+ // quorum = M-of-N collective sign-off; per_group = one (or minApprovals) from
1294
+ // EACH group (会签). A single rejection is always a veto. Group membership is
1295
+ // snapshotted at open, so OOO-substituted approvers count for their group.
1296
+ describe('ApprovalService — quorum & per_group (#3266)', () => {
1297
+ let engine: ReturnType<typeof makeFakeEngine>;
1298
+ let svc: ApprovalService;
1299
+ const base = new Date('2026-08-01T10:00:00Z').getTime();
1300
+
1301
+ beforeEach(() => {
1302
+ engine = makeFakeEngine();
1303
+ let n = 0;
1304
+ svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(base + (n++) * 1000) } });
1305
+ });
1306
+
1307
+ // Build an openNodeRequest input with explicit approver specs + behavior.
1308
+ const cfg = (approvers: any[], behavior: string, extra: Record<string, any> = {}) => ({
1309
+ ...openInput([]),
1310
+ config: { approvers, behavior, lockRecord: true, ...extra },
1311
+ });
1312
+ const U = (v: string, group?: string) => (group ? { type: 'user', value: v, group } : { type: 'user', value: v });
1313
+
1314
+ it('quorum: holds until minApprovals reached, then finalizes', async () => {
1315
+ const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
1316
+ const a = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
1317
+ expect(a.finalized).toBe(false);
1318
+ expect(a.request.status).toBe('pending');
1319
+ const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u2' }, SYS);
1320
+ expect(b.finalized).toBe(true);
1321
+ expect(b.request.status).toBe('approved');
1322
+ });
1323
+
1324
+ it('quorum: minApprovals clamps to the approver count (no deadlock)', async () => {
1325
+ const req = await svc.openNodeRequest(cfg([U('u1'), U('u2')], 'quorum', { minApprovals: 5 }), CTX);
1326
+ await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
1327
+ const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u2' }, SYS);
1328
+ expect(b.finalized).toBe(true);
1329
+ });
1330
+
1331
+ it('quorum: any reject is a veto', async () => {
1332
+ const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
1333
+ const r = await svc.decideNode(req.id, { decision: 'reject', actorId: 'u1' }, SYS);
1334
+ expect(r.finalized).toBe(true);
1335
+ expect(r.request.status).toBe('rejected');
1336
+ });
1337
+
1338
+ it('per_group: advances only when EACH group approves', async () => {
1339
+ const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
1340
+ const a = await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
1341
+ expect(a.finalized).toBe(false); // finance still pending
1342
+ const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'f1' }, SYS);
1343
+ expect(b.finalized).toBe(true);
1344
+ expect(b.request.status).toBe('approved');
1345
+ });
1346
+
1347
+ it('per_group: two approvals in ONE group do not satisfy another group', async () => {
1348
+ const req = await svc.openNodeRequest(
1349
+ cfg([U('l1', 'legal'), U('l2', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
1350
+ await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
1351
+ const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'l2' }, SYS);
1352
+ expect(b.finalized).toBe(false); // finance still missing
1353
+ });
1354
+
1355
+ it('per_group: minApprovals=2 needs two from each group', async () => {
1356
+ const req = await svc.openNodeRequest(cfg(
1357
+ [U('l1', 'legal'), U('l2', 'legal'), U('f1', 'finance'), U('f2', 'finance')],
1358
+ 'per_group', { minApprovals: 2 }), CTX);
1359
+ for (const u of ['l1', 'f1', 'l2']) {
1360
+ const r = await svc.decideNode(req.id, { decision: 'approve', actorId: u }, SYS);
1361
+ expect(r.finalized).toBe(false);
1362
+ }
1363
+ const done = await svc.decideNode(req.id, { decision: 'approve', actorId: 'f2' }, SYS);
1364
+ expect(done.finalized).toBe(true);
1365
+ });
1366
+
1367
+ it('per_group: reject is a veto', async () => {
1368
+ const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
1369
+ const r = await svc.decideNode(req.id, { decision: 'reject', actorId: 'l1' }, SYS);
1370
+ expect(r.request.status).toBe('rejected');
1371
+ });
1372
+
1373
+ it('per_group: an OOO-substituted member still counts for their group', async () => {
1374
+ engine._tables['sys_approval_delegation'] = [
1375
+ { id: 'd', delegator_id: 'l1', delegate_id: 'lb', organization_id: 't1', valid_from: null, valid_until: null, reason: 'leave' },
1376
+ ];
1377
+ const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
1378
+ expect(req.pending_approvers).toContain('lb');
1379
+ expect(req.pending_approvers).not.toContain('l1');
1380
+ const a = await svc.decideNode(req.id, { decision: 'approve', actorId: 'lb' }, SYS); // delegate covers legal
1381
+ expect(a.finalized).toBe(false);
1382
+ const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'f1' }, SYS);
1383
+ expect(b.finalized).toBe(true);
1384
+ });
1385
+
1386
+ it('records decision attachments on the audit row', async () => {
1387
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
1388
+ await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9', attachments: ['file_1', 'file_2'] }, SYS);
1389
+ const act = engine._tables['sys_approval_action'].find((a: any) => a.action === 'approve');
1390
+ expect(act.attachments).toEqual(['file_1', 'file_2']);
1391
+ });
1392
+ });
1393
+
1394
+ // ── Decision progress + notification deep links (#2678 P1.5) ──────────
1395
+ describe('ApprovalService — decision_progress & deep links (#2678 P1.5)', () => {
1396
+ let engine: ReturnType<typeof makeFakeEngine>;
1397
+ let svc: ApprovalService;
1398
+
1399
+ beforeEach(() => {
1400
+ engine = makeFakeEngine();
1401
+ let n = 0;
1402
+ const base = new Date('2026-09-01T09:00:00Z').getTime();
1403
+ svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(base + (n++) * 1000) } });
1404
+ });
1405
+
1406
+ const cfg = (approvers: any[], behavior: string, extra: Record<string, any> = {}) => ({
1407
+ ...openInput([]),
1408
+ config: { approvers, behavior, lockRecord: true, ...extra },
1409
+ });
1410
+ const U = (v: string, group?: string) => (group ? { type: 'user', value: v, group } : { type: 'user', value: v });
1411
+
1412
+ it('per_group: getRequest exposes per-group progress that updates per approval', async () => {
1413
+ const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
1414
+ let row: any = await svc.getRequest(req.id, SYS);
1415
+ expect(row.decision_progress).toMatchObject({ behavior: 'per_group', got: 0, need: 2 });
1416
+ expect(row.decision_progress.groups).toEqual([
1417
+ { group: 'finance', got: 0, need: 1, satisfied: false },
1418
+ { group: 'legal', got: 0, need: 1, satisfied: false },
1419
+ ]);
1420
+ await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
1421
+ row = await svc.getRequest(req.id, SYS);
1422
+ expect(row.decision_progress.got).toBe(1);
1423
+ expect(row.decision_progress.groups.find((g: any) => g.group === 'legal')).toMatchObject({ got: 1, satisfied: true });
1424
+ expect(row.decision_progress.groups.find((g: any) => g.group === 'finance')).toMatchObject({ got: 0, satisfied: false });
1425
+ });
1426
+
1427
+ it('quorum: progress reports approvals against the clamped threshold', async () => {
1428
+ const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
1429
+ await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
1430
+ const row: any = await svc.getRequest(req.id, SYS);
1431
+ expect(row.decision_progress).toMatchObject({ behavior: 'quorum', got: 1, need: 2 });
1432
+ });
1433
+
1434
+ it('first_response: no decision_progress', async () => {
1435
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
1436
+ const row: any = await svc.getRequest(req.id, SYS);
1437
+ expect(row.decision_progress).toBeUndefined();
1438
+ });
1439
+
1440
+ it('notify: inbox actionUrl is rewritten to a request deep link', async () => {
1441
+ const emitted: any[] = [];
1442
+ svc.attachMessaging({ async emit(input) { emitted.push(input); } });
1443
+ const req = await svc.openNodeRequest(openInput(['u1', 'u2']), CTX);
1444
+ await svc.reassign(req.id, { actorId: 'u1', to: 'u7' }, SYS);
1445
+ const note = emitted.find(e => e.topic === 'approval.reassigned');
1446
+ expect(note.payload.actionUrl).toBe(`/system/approvals?request=${encodeURIComponent(req.id)}`);
1447
+ });
1448
+ });
1449
+
1450
+ // listActions must surface decision attachments through the contract mapping
1451
+ // (#3266 — the column existed but rowFromAction dropped it; caught in browser).
1452
+ describe('ApprovalService — listActions attachments mapping (#3266)', () => {
1453
+ it('returns the attachments recorded on a decision', async () => {
1454
+ const engine = makeFakeEngine();
1455
+ let n = 0;
1456
+ const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
1457
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
1458
+ await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9', attachments: ['file_a'] }, SYS);
1459
+ const acts = await svc.listActions(req.id, SYS);
1460
+ const approve = acts.find(a => a.action === 'approve');
1461
+ expect(approve?.attachments).toEqual(['file_a']);
1462
+ });
1463
+ });