@objectstack/plugin-approvals 12.6.0 → 14.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +121 -4
- package/dist/index.d.mts +48 -16
- package/dist/index.d.ts +48 -16
- package/dist/index.js +62 -13
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +62 -13
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -7
- package/src/approval-node.test.ts +2 -2
- package/src/approval-node.ts +1 -1
- package/src/approval-revise.test.ts +3 -3
- package/src/approval-service.test.ts +103 -10
- package/src/approval-service.ts +60 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/plugin-approvals",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "14.3.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": "
|
|
17
|
-
"@objectstack/formula": "
|
|
18
|
-
"@objectstack/metadata-core": "
|
|
19
|
-
"@objectstack/platform-objects": "
|
|
20
|
-
"@objectstack/spec": "
|
|
16
|
+
"@objectstack/core": "14.3.0",
|
|
17
|
+
"@objectstack/formula": "14.3.0",
|
|
18
|
+
"@objectstack/metadata-core": "14.3.0",
|
|
19
|
+
"@objectstack/platform-objects": "14.3.0",
|
|
20
|
+
"@objectstack/spec": "14.3.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@types/node": "^26.1.0",
|
|
24
24
|
"typescript": "^6.0.3",
|
|
25
25
|
"vitest": "^4.1.10",
|
|
26
|
-
"@objectstack/service-automation": "
|
|
26
|
+
"@objectstack/service-automation": "14.3.0"
|
|
27
27
|
},
|
|
28
28
|
"keywords": [
|
|
29
29
|
"objectstack",
|
|
@@ -5,7 +5,7 @@ import { AutomationEngine } from '@objectstack/service-automation';
|
|
|
5
5
|
import { ApprovalService } from './approval-service.js';
|
|
6
6
|
import { registerApprovalNode } from './approval-node.js';
|
|
7
7
|
|
|
8
|
-
const SYSTEM_CTX = { isSystem: true,
|
|
8
|
+
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any;
|
|
9
9
|
|
|
10
10
|
const noopLogger = {
|
|
11
11
|
info() {}, warn() {}, error() {}, debug() {},
|
|
@@ -190,7 +190,7 @@ describe('Approval node bridge (ADR-0019)', () => {
|
|
|
190
190
|
const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
|
|
191
191
|
|
|
192
192
|
await expect(
|
|
193
|
-
service.decideNode(request.id, { decision: 'approve', actorId: 'intruder' }, { isSystem: false,
|
|
193
|
+
service.decideNode(request.id, { decision: 'approve', actorId: 'intruder' }, { isSystem: false, positions: [], permissions: [] } as any),
|
|
194
194
|
).rejects.toThrow(/FORBIDDEN/);
|
|
195
195
|
});
|
|
196
196
|
});
|
package/src/approval-node.ts
CHANGED
|
@@ -48,7 +48,7 @@ interface MinimalLogger {
|
|
|
48
48
|
warn?: (msg: any, ...rest: any[]) => void;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
const SYSTEM_CTX = { isSystem: true,
|
|
51
|
+
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* Register the `approval` node executor on the automation engine. Idempotent at
|
|
@@ -19,8 +19,8 @@ import { ApprovalService } from './approval-service.js';
|
|
|
19
19
|
import { registerApprovalNode } from './approval-node.js';
|
|
20
20
|
import { bindApprovalLockHook, APPROVALS_HOOK_PACKAGE } from './lifecycle-hooks.js';
|
|
21
21
|
|
|
22
|
-
const SYSTEM_CTX = { isSystem: true,
|
|
23
|
-
const USER_CTX = { isSystem: false,
|
|
22
|
+
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any;
|
|
23
|
+
const USER_CTX = { isSystem: false, positions: [], permissions: [] } as any;
|
|
24
24
|
|
|
25
25
|
const noopLogger = { info() {}, warn() {}, error() {}, debug() {} };
|
|
26
26
|
|
|
@@ -288,7 +288,7 @@ describe('Send back for revision (ADR-0044)', () => {
|
|
|
288
288
|
const editAttempt = () => hook!({
|
|
289
289
|
object: 'fin_expense',
|
|
290
290
|
input: { id: 'x1', data: { amount: 1200 } },
|
|
291
|
-
session: { isSystem: false,
|
|
291
|
+
session: { isSystem: false, positions: [] },
|
|
292
292
|
});
|
|
293
293
|
|
|
294
294
|
await expect(editAttempt()).rejects.toThrow(/RECORD_LOCKED/); // pending → locked
|
|
@@ -110,8 +110,8 @@ function makeFakeEngine() {
|
|
|
110
110
|
};
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
const CTX = { userId: 'u1', tenantId: 't1',
|
|
114
|
-
const SYS = { isSystem: true,
|
|
113
|
+
const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] } as any;
|
|
114
|
+
const SYS = { isSystem: true, positions: [], permissions: [] } as any;
|
|
115
115
|
|
|
116
116
|
function nodeConfig(approvers: string[], extra: Record<string, any> = {}) {
|
|
117
117
|
return {
|
|
@@ -187,6 +187,65 @@ describe('ApprovalService (node era)', () => {
|
|
|
187
187
|
expect(engine._tables['opportunity'][0].approval_status).toBe('pending');
|
|
188
188
|
});
|
|
189
189
|
|
|
190
|
+
// ── approver expansion: position (ADR-0090 D3) ──────────────────
|
|
191
|
+
|
|
192
|
+
const positionInput = (extra: Record<string, any> = {}) => ({
|
|
193
|
+
...openInput([]),
|
|
194
|
+
config: {
|
|
195
|
+
approvers: [{ type: 'position' as const, value: 'sales_manager' }],
|
|
196
|
+
behavior: 'first_response' as const,
|
|
197
|
+
lockRecord: true,
|
|
198
|
+
},
|
|
199
|
+
...extra,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('position approver: expands via sys_user_position, org-scoped', async () => {
|
|
203
|
+
engine._tables['sys_user_position'] = [
|
|
204
|
+
{ id: 'up1', user_id: 'u5', position: 'sales_manager', organization_id: 't1' },
|
|
205
|
+
{ id: 'up2', user_id: 'u6', position: 'sales_manager', organization_id: 't1' },
|
|
206
|
+
{ id: 'up3', user_id: 'u7', position: 'sales_manager', organization_id: 't2' }, // other tenant
|
|
207
|
+
{ id: 'up4', user_id: 'u8', position: 'cfo', organization_id: 't1' }, // other position
|
|
208
|
+
];
|
|
209
|
+
const req = await svc.openNodeRequest(positionInput(), CTX);
|
|
210
|
+
expect(req.pending_approvers.sort()).toEqual(['u5', 'u6']);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('position approver: unions the sys_member.role transition source (ADR-0057 D4)', async () => {
|
|
214
|
+
engine._tables['sys_user_position'] = [
|
|
215
|
+
{ id: 'up1', user_id: 'u5', position: 'sales_manager', organization_id: 't1' },
|
|
216
|
+
];
|
|
217
|
+
engine._tables['sys_member'] = [
|
|
218
|
+
{ id: 'm1', user_id: 'u6', role: 'sales_manager', organization_id: 't1' },
|
|
219
|
+
{ id: 'm2', user_id: 'u5', role: 'sales_manager', organization_id: 't1' }, // deduped
|
|
220
|
+
];
|
|
221
|
+
const req = await svc.openNodeRequest(positionInput(), CTX);
|
|
222
|
+
expect(req.pending_approvers.sort()).toEqual(['u5', 'u6']);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('position approver: falls back to a position: literal when nobody holds it', async () => {
|
|
226
|
+
const req = await svc.openNodeRequest(positionInput(), CTX);
|
|
227
|
+
expect(req.pending_approvers).toEqual(['position:sales_manager']);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("department approver: honors the spec enum value 'department' (not just the business_unit dialect)", async () => {
|
|
231
|
+
engine._tables['sys_business_unit'] = [
|
|
232
|
+
{ id: 'bu1', organization_id: 't1', active: true },
|
|
233
|
+
{ id: 'bu2', parent_business_unit_id: 'bu1', organization_id: 't1', active: true },
|
|
234
|
+
];
|
|
235
|
+
engine._tables['sys_business_unit_member'] = [
|
|
236
|
+
{ id: 'bm1', business_unit_id: 'bu1', user_id: 'u5' },
|
|
237
|
+
{ id: 'bm2', business_unit_id: 'bu2', user_id: 'u6' },
|
|
238
|
+
];
|
|
239
|
+
const req = await svc.openNodeRequest(positionInput({
|
|
240
|
+
config: {
|
|
241
|
+
approvers: [{ type: 'department' as const, value: 'bu1' }],
|
|
242
|
+
behavior: 'first_response' as const,
|
|
243
|
+
lockRecord: true,
|
|
244
|
+
},
|
|
245
|
+
}), CTX);
|
|
246
|
+
expect(req.pending_approvers.sort()).toEqual(['u5', 'u6']);
|
|
247
|
+
});
|
|
248
|
+
|
|
190
249
|
// ── decideNode ──────────────────────────────────────────────────
|
|
191
250
|
|
|
192
251
|
it('decideNode: first_response approve finalizes immediately', async () => {
|
|
@@ -219,7 +278,7 @@ describe('ApprovalService (node era)', () => {
|
|
|
219
278
|
it('decideNode: blocks a non-approver in a non-system context', async () => {
|
|
220
279
|
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
221
280
|
await expect(
|
|
222
|
-
svc.decideNode(req.id, { decision: 'approve', actorId: 'mallory' }, { isSystem: false,
|
|
281
|
+
svc.decideNode(req.id, { decision: 'approve', actorId: 'mallory' }, { isSystem: false, positions: [], permissions: [] } as any),
|
|
223
282
|
).rejects.toThrow(/FORBIDDEN/);
|
|
224
283
|
});
|
|
225
284
|
|
|
@@ -319,7 +378,7 @@ describe('ApprovalService (node era)', () => {
|
|
|
319
378
|
|
|
320
379
|
it('recall: blocks a non-submitter in a non-system context', async () => {
|
|
321
380
|
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
322
|
-
await expect(svc.recall(req.id, { actorId: 'u9' }, {
|
|
381
|
+
await expect(svc.recall(req.id, { actorId: 'u9' }, { positions: [], permissions: [] } as any))
|
|
323
382
|
.rejects.toThrow(/FORBIDDEN/);
|
|
324
383
|
});
|
|
325
384
|
|
|
@@ -483,7 +542,7 @@ describe('ApprovalService (node era)', () => {
|
|
|
483
542
|
|
|
484
543
|
it('remind: only the submitter may nudge', async () => {
|
|
485
544
|
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
486
|
-
await expect(svc.remind(req.id, { actorId: 'u9' }, {
|
|
545
|
+
await expect(svc.remind(req.id, { actorId: 'u9' }, { positions: [], permissions: [] } as any))
|
|
487
546
|
.rejects.toThrow(/FORBIDDEN/);
|
|
488
547
|
});
|
|
489
548
|
|
|
@@ -503,7 +562,7 @@ describe('ApprovalService (node era)', () => {
|
|
|
503
562
|
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
504
563
|
await svc.comment(req.id, { actorId: 'u1', comment: 'Numbers attached.' }, CTX);
|
|
505
564
|
await svc.comment(req.id, { actorId: 'u9', comment: 'Thanks, reviewing.' }, CTX);
|
|
506
|
-
await expect(svc.comment(req.id, { actorId: 'outsider', comment: 'hi' }, {
|
|
565
|
+
await expect(svc.comment(req.id, { actorId: 'outsider', comment: 'hi' }, { positions: [], permissions: [] } as any))
|
|
507
566
|
.rejects.toThrow(/FORBIDDEN/);
|
|
508
567
|
const actions = await svc.listActions(req.id, SYS);
|
|
509
568
|
expect(actions.filter(a => a.action === 'comment')).toHaveLength(2);
|
|
@@ -804,6 +863,40 @@ describe('ApprovalService (node era)', () => {
|
|
|
804
863
|
expect(fresh?.pending_approvers).toEqual(['boss']);
|
|
805
864
|
});
|
|
806
865
|
|
|
866
|
+
it('runEscalations: reassign expands a position escalateTo to its holders (ADR-0090 D3)', async () => {
|
|
867
|
+
engine._tables['sys_user_position'] = [
|
|
868
|
+
{ id: 'up1', user_id: 'u5', position: 'approvals_supervisor', organization_id: 't1' },
|
|
869
|
+
{ id: 'up2', user_id: 'u6', position: 'approvals_supervisor', organization_id: 't1' },
|
|
870
|
+
{ id: 'up3', user_id: 'u7', position: 'approvals_supervisor', organization_id: 't2' }, // other tenant
|
|
871
|
+
];
|
|
872
|
+
const req = await svc.openNodeRequest(
|
|
873
|
+
openInput(['u9'], {}, { escalation: { timeoutHours: 1, action: 'reassign', escalateTo: 'approvals_supervisor', notifySubmitter: false } }), CTX,
|
|
874
|
+
);
|
|
875
|
+
makeOverdue(req.id);
|
|
876
|
+
await svc.runEscalations();
|
|
877
|
+
const fresh = await svc.getRequest(req.id, SYS);
|
|
878
|
+
expect(fresh?.status).toBe('pending');
|
|
879
|
+
expect(fresh?.pending_approvers?.slice().sort()).toEqual(['u5', 'u6']);
|
|
880
|
+
// The audit trail keeps the AUTHORED target, not the expansion.
|
|
881
|
+
const actions = await svc.listActions(req.id, SYS);
|
|
882
|
+
expect(actions.find(a => a.action === 'escalate')?.comment).toBe('reassign → approvals_supervisor');
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
it('runEscalations: notify expands a position escalateTo into the audience', async () => {
|
|
886
|
+
engine._tables['sys_user_position'] = [
|
|
887
|
+
{ id: 'up1', user_id: 'u5', position: 'approvals_supervisor', organization_id: 't1' },
|
|
888
|
+
];
|
|
889
|
+
const emitted: any[] = [];
|
|
890
|
+
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
|
|
891
|
+
const req = await svc.openNodeRequest(
|
|
892
|
+
openInput(['u9'], {}, { escalation: { timeoutHours: 2, action: 'notify', escalateTo: 'approvals_supervisor', notifySubmitter: false } }), CTX,
|
|
893
|
+
);
|
|
894
|
+
makeOverdue(req.id);
|
|
895
|
+
await svc.runEscalations();
|
|
896
|
+
expect(emitted).toHaveLength(1);
|
|
897
|
+
expect(emitted[0].audience).toEqual(['u9', 'u5']);
|
|
898
|
+
});
|
|
899
|
+
|
|
807
900
|
it('runEscalations: skips requests that are not yet due or have no SLA', async () => {
|
|
808
901
|
await svc.openNodeRequest(
|
|
809
902
|
openInput(['u9'], {}, { escalation: { timeoutHours: 1000, action: 'auto_approve' } }), CTX,
|
|
@@ -882,7 +975,7 @@ describe('record-lock hook (node era)', () => {
|
|
|
882
975
|
engine.fire('beforeUpdate', {
|
|
883
976
|
object: 'opportunity',
|
|
884
977
|
input: { id: 'opp1', data: { amount: 200 } },
|
|
885
|
-
session: { isSystem: false,
|
|
978
|
+
session: { isSystem: false, positions: [], userId: 'u1' },
|
|
886
979
|
}),
|
|
887
980
|
).rejects.toThrow(/RECORD_LOCKED/);
|
|
888
981
|
});
|
|
@@ -892,7 +985,7 @@ describe('record-lock hook (node era)', () => {
|
|
|
892
985
|
engine.fire('beforeUpdate', {
|
|
893
986
|
object: 'opportunity',
|
|
894
987
|
input: { id: 'opp1', data: { approval_status: 'approved' } },
|
|
895
|
-
session: { isSystem: false,
|
|
988
|
+
session: { isSystem: false, positions: [] },
|
|
896
989
|
}),
|
|
897
990
|
).resolves.toBeUndefined();
|
|
898
991
|
});
|
|
@@ -902,7 +995,7 @@ describe('record-lock hook (node era)', () => {
|
|
|
902
995
|
engine.fire('beforeUpdate', {
|
|
903
996
|
object: 'opportunity',
|
|
904
997
|
input: { id: 'opp1', data: { amount: 200 } },
|
|
905
|
-
session: { isSystem: true,
|
|
998
|
+
session: { isSystem: true, positions: [] },
|
|
906
999
|
}),
|
|
907
1000
|
).resolves.toBeUndefined();
|
|
908
1001
|
});
|
|
@@ -922,7 +1015,7 @@ describe('record-lock hook (node era)', () => {
|
|
|
922
1015
|
engine.fire('beforeUpdate', {
|
|
923
1016
|
object: 'opportunity',
|
|
924
1017
|
input: { id: 'other_record', data: { amount: 200 } },
|
|
925
|
-
session: { isSystem: false,
|
|
1018
|
+
session: { isSystem: false, positions: [] },
|
|
926
1019
|
}),
|
|
927
1020
|
).resolves.toBeUndefined();
|
|
928
1021
|
});
|
package/src/approval-service.ts
CHANGED
|
@@ -31,8 +31,8 @@ import type {
|
|
|
31
31
|
* `approve` / `reject` edge.
|
|
32
32
|
*
|
|
33
33
|
* This service owns the durable approval *state* — `sys_approval_request` /
|
|
34
|
-
* `sys_approval_action`, approver resolution (team / department /
|
|
35
|
-
* manager graph), and the optional status-field mirror — plus the decision
|
|
34
|
+
* `sys_approval_action`, approver resolution (team / department / position /
|
|
35
|
+
* role / manager graph), and the optional status-field mirror — plus the decision
|
|
36
36
|
* API. It does not author processes, submit, or walk multi-step machinery
|
|
37
37
|
* anymore; that orchestration lives on the one automation engine.
|
|
38
38
|
*/
|
|
@@ -97,7 +97,7 @@ export type ActionTokenOutcome =
|
|
|
97
97
|
| { ok: true; action: 'approve' | 'reject'; request: ApprovalRequestRow; approverId: string }
|
|
98
98
|
| { ok: false; reason: 'invalid' | 'expired' | 'consumed' | 'not_pending' | 'not_approver'; request?: ApprovalRequestRow };
|
|
99
99
|
|
|
100
|
-
const SYSTEM_CTX = { isSystem: true,
|
|
100
|
+
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
|
|
101
101
|
|
|
102
102
|
function uid(prefix: string): string {
|
|
103
103
|
const g: any = globalThis as any;
|
|
@@ -274,16 +274,20 @@ export class ApprovalService implements IApprovalService {
|
|
|
274
274
|
|
|
275
275
|
/**
|
|
276
276
|
* Expand the approvers on an Approval node into user IDs by querying the
|
|
277
|
-
* graph tables for `team:` / `department:` / `
|
|
278
|
-
* types. Falls back to a prefixed literal
|
|
279
|
-
* produce nothing — so existing fixtures
|
|
280
|
-
* matching keep working.
|
|
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.
|
|
281
281
|
*
|
|
282
282
|
* **Graph semantics:**
|
|
283
283
|
* - `team` → flat members of `sys_team` (better-auth; no BFS)
|
|
284
284
|
* - `department` → recursive BFS of `sys_business_unit.parent_business_unit_id`
|
|
285
285
|
* → members of every descendant via `sys_business_unit_member`
|
|
286
|
-
* - `
|
|
286
|
+
* - `position` → holders via `sys_user_position` ∪ `sys_member.role`
|
|
287
|
+
* transition source (ADR-0090 D3 / ADR-0057 D4)
|
|
288
|
+
* - `role` → users with `sys_member.role = value` in tenant — the
|
|
289
|
+
* better-auth MEMBERSHIP TIER (owner/admin/member), not a
|
|
290
|
+
* position; author `position` for org positions
|
|
287
291
|
* - `manager` → `sys_user.manager_id` of `record[value] ?? record.owner_id`
|
|
288
292
|
* - `field` → literal user id stored in `record[value]`
|
|
289
293
|
* - `user` → literal value
|
|
@@ -299,9 +303,12 @@ export class ApprovalService implements IApprovalService {
|
|
|
299
303
|
if (a.type === 'team') {
|
|
300
304
|
const users = await this.expandTeamUsers(String(a.value));
|
|
301
305
|
if (users.length) { for (const u of users) out.push(u); continue; }
|
|
302
|
-
} else if (a.type === 'business_unit' || a.type === 'bu') {
|
|
306
|
+
} else if (a.type === 'department' || a.type === 'business_unit' || a.type === 'bu') {
|
|
303
307
|
const users = await this.expandBusinessUnitUsers(String(a.value), organizationId);
|
|
304
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; }
|
|
305
312
|
} else if (a.type === 'role') {
|
|
306
313
|
const users = await this.expandRoleUsers(String(a.value), organizationId);
|
|
307
314
|
if (users.length) { for (const u of users) out.push(u); continue; }
|
|
@@ -377,6 +384,33 @@ export class ApprovalService implements IApprovalService {
|
|
|
377
384
|
return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
|
|
378
385
|
}
|
|
379
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Position holders (ADR-0090 D3): `sys_user_position` is the platform-owned
|
|
389
|
+
* assignment table, keyed by the position's machine name (ADR-0057 D4),
|
|
390
|
+
* unioned with the better-auth membership string (`sys_member.role`) as a
|
|
391
|
+
* transition source — the same semantics as `PositionGraphService` in
|
|
392
|
+
* `plugin-sharing`, so an approval routes to exactly the users the sharing
|
|
393
|
+
* engine would expand for the same position.
|
|
394
|
+
*/
|
|
395
|
+
private async expandPositionUsers(positionName: string, organizationId?: string | null): Promise<string[]> {
|
|
396
|
+
if (!positionName) return [];
|
|
397
|
+
const users = new Set<string>();
|
|
398
|
+
const filter: any = { position: positionName };
|
|
399
|
+
if (organizationId) filter.organization_id = organizationId;
|
|
400
|
+
try {
|
|
401
|
+
const rows = await this.engine.find('sys_user_position', {
|
|
402
|
+
filter, fields: ['user_id'], limit: 10000, context: SYSTEM_CTX,
|
|
403
|
+
} as any);
|
|
404
|
+
for (const r of (rows ?? []) as any[]) {
|
|
405
|
+
const uid = String(r.user_id ?? '');
|
|
406
|
+
if (uid) users.add(uid);
|
|
407
|
+
}
|
|
408
|
+
} 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);
|
|
410
|
+
return Array.from(users);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** better-auth org-membership tier (`sys_member.role`) — NOT positions. */
|
|
380
414
|
private async expandRoleUsers(roleName: string, organizationId?: string | null): Promise<string[]> {
|
|
381
415
|
if (!roleName) return [];
|
|
382
416
|
const filter: any = { role: roleName };
|
|
@@ -1347,6 +1381,18 @@ export class ApprovalService implements IApprovalService {
|
|
|
1347
1381
|
const now = this.clock.now().toISOString();
|
|
1348
1382
|
const pending = csvSplit(raw.pending_approvers);
|
|
1349
1383
|
|
|
1384
|
+
// `escalateTo` is a position machine name or a user id (same contract as
|
|
1385
|
+
// the `position` ApproverType, ADR-0090 D3). Position holders win; an
|
|
1386
|
+
// empty expansion falls back to the literal, so a config naming a
|
|
1387
|
+
// specific user id keeps working unchanged.
|
|
1388
|
+
let escalatees: string[] = [];
|
|
1389
|
+
if (escalateTo) {
|
|
1390
|
+
try {
|
|
1391
|
+
escalatees = await this.expandPositionUsers(escalateTo, raw.organization_id ?? null);
|
|
1392
|
+
} catch { escalatees = []; }
|
|
1393
|
+
if (!escalatees.length) escalatees = [escalateTo];
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1350
1396
|
// Audit first — this row IS the idempotency marker (ADR-0042 §1).
|
|
1351
1397
|
await this.engine.insert('sys_approval_action', {
|
|
1352
1398
|
id: uid('aact'), request_id: raw.id, organization_id: raw.organization_id ?? null,
|
|
@@ -1356,14 +1402,14 @@ export class ApprovalService implements IApprovalService {
|
|
|
1356
1402
|
created_at: now,
|
|
1357
1403
|
}, { context: SYSTEM_CTX });
|
|
1358
1404
|
|
|
1359
|
-
if (action === 'reassign' &&
|
|
1405
|
+
if (action === 'reassign' && escalatees.length) {
|
|
1360
1406
|
await this.engine.update('sys_approval_request', {
|
|
1361
|
-
id: raw.id, pending_approvers:
|
|
1407
|
+
id: raw.id, pending_approvers: escalatees.join(','), updated_at: now,
|
|
1362
1408
|
}, { context: SYSTEM_CTX });
|
|
1363
|
-
await this.syncApproverIndex(raw.id,
|
|
1409
|
+
await this.syncApproverIndex(raw.id, escalatees, raw.organization_id ?? null, now);
|
|
1364
1410
|
await this.notify({
|
|
1365
1411
|
topic: 'approval.escalated',
|
|
1366
|
-
audience:
|
|
1412
|
+
audience: escalatees,
|
|
1367
1413
|
actorId: SLA_ACTOR_ID,
|
|
1368
1414
|
source: { object: 'sys_approval_request', id: raw.id },
|
|
1369
1415
|
payload: {
|
|
@@ -1382,7 +1428,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1382
1428
|
// 'notify' (and the reassign-without-target fallback)
|
|
1383
1429
|
await this.notify({
|
|
1384
1430
|
topic: 'approval.sla_breached',
|
|
1385
|
-
audience: [...pending, ...
|
|
1431
|
+
audience: [...pending, ...escalatees],
|
|
1386
1432
|
actorId: SLA_ACTOR_ID,
|
|
1387
1433
|
source: { object: 'sys_approval_request', id: raw.id },
|
|
1388
1434
|
payload: {
|