@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/plugin-approvals",
3
- "version": "13.0.0",
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": "13.0.0",
17
- "@objectstack/formula": "13.0.0",
18
- "@objectstack/metadata-core": "13.0.0",
19
- "@objectstack/platform-objects": "13.0.0",
20
- "@objectstack/spec": "13.0.0"
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": "13.0.0"
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,
@@ -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 / role /
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:` / `role:` / `manager:` approver
278
- * types. Falls back to a prefixed literal (`type:value`) when graph lookups
279
- * produce nothing — so existing fixtures and flows that rely on substring
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
- * - `role` users with `sys_member.role = value` in tenant
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' && escalateTo) {
1405
+ if (action === 'reassign' && escalatees.length) {
1360
1406
  await this.engine.update('sys_approval_request', {
1361
- id: raw.id, pending_approvers: escalateTo, updated_at: now,
1407
+ id: raw.id, pending_approvers: escalatees.join(','), updated_at: now,
1362
1408
  }, { context: SYSTEM_CTX });
1363
- await this.syncApproverIndex(raw.id, [escalateTo], raw.organization_id ?? null, now);
1409
+ await this.syncApproverIndex(raw.id, escalatees, raw.organization_id ?? null, now);
1364
1410
  await this.notify({
1365
1411
  topic: 'approval.escalated',
1366
- audience: [escalateTo],
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, ...(escalateTo ? [escalateTo] : [])],
1431
+ audience: [...pending, ...escalatees],
1386
1432
  actorId: SLA_ACTOR_ID,
1387
1433
  source: { object: 'sys_approval_request', id: raw.id },
1388
1434
  payload: {