@objectstack/plugin-approvals 13.0.0 → 14.5.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 +130 -0
- package/dist/index.d.mts +96 -10
- package/dist/index.d.ts +96 -10
- package/dist/index.js +60 -11
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +60 -11
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -7
- package/src/approval-service.test.ts +93 -0
- package/src/approval-service.ts +59 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/plugin-approvals",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "14.5.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.5.0",
|
|
17
|
+
"@objectstack/formula": "14.5.0",
|
|
18
|
+
"@objectstack/metadata-core": "14.5.0",
|
|
19
|
+
"@objectstack/platform-objects": "14.5.0",
|
|
20
|
+
"@objectstack/spec": "14.5.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.5.0"
|
|
27
27
|
},
|
|
28
28
|
"keywords": [
|
|
29
29
|
"objectstack",
|
|
@@ -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 () => {
|
|
@@ -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,
|
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
|
*/
|
|
@@ -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: {
|