@objectstack/plugin-approvals 9.0.1 → 9.2.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 +23 -0
- package/dist/index.d.mts +15 -4
- package/dist/index.d.ts +15 -4
- package/dist/index.js +135 -30
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +135 -30
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -7
- package/src/approval-service.test.ts +31 -0
- package/src/approval-service.ts +134 -29
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/plugin-approvals",
|
|
3
|
-
"version": "9.0
|
|
3
|
+
"version": "9.2.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": "9.0
|
|
17
|
-
"@objectstack/formula": "9.0
|
|
18
|
-
"@objectstack/metadata-core": "9.0
|
|
19
|
-
"@objectstack/platform-objects": "9.0
|
|
20
|
-
"@objectstack/spec": "9.0
|
|
16
|
+
"@objectstack/core": "9.2.0",
|
|
17
|
+
"@objectstack/formula": "9.2.0",
|
|
18
|
+
"@objectstack/metadata-core": "9.2.0",
|
|
19
|
+
"@objectstack/platform-objects": "9.2.0",
|
|
20
|
+
"@objectstack/spec": "9.2.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@types/node": "^25.9.2",
|
|
24
24
|
"typescript": "^6.0.3",
|
|
25
25
|
"vitest": "^4.1.8",
|
|
26
|
-
"@objectstack/service-automation": "9.0
|
|
26
|
+
"@objectstack/service-automation": "9.2.0"
|
|
27
27
|
},
|
|
28
28
|
"keywords": [
|
|
29
29
|
"objectstack",
|
|
@@ -376,6 +376,37 @@ describe('ApprovalService (node era)', () => {
|
|
|
376
376
|
expect(rows[0].record_title).toBe('Snapshot Title');
|
|
377
377
|
});
|
|
378
378
|
|
|
379
|
+
it('enrichment resolves lookup foreign keys in the payload to record titles', async () => {
|
|
380
|
+
(engine as any).getSchema = (name: string) =>
|
|
381
|
+
name === 'opportunity'
|
|
382
|
+
? { label: 'Opportunity', fields: { name: {}, account: { type: 'lookup', reference: 'account' } } }
|
|
383
|
+
: name === 'account' ? { label: 'Account', fields: { name: {} } } : undefined;
|
|
384
|
+
engine._tables['opportunity'] = [{ id: 'opp1', name: 'Acme Renewal', account: 'acc1' }];
|
|
385
|
+
engine._tables['account'] = [{ id: 'acc1', name: 'Acme Corp' }];
|
|
386
|
+
await svc.openNodeRequest(openInput(['u9'], { record: { id: 'opp1', name: 'Acme Renewal', account: 'acc1' } }), CTX);
|
|
387
|
+
const rows = await svc.listRequests({ status: 'pending' }, SYS);
|
|
388
|
+
expect(rows[0].object_label).toBe('Opportunity');
|
|
389
|
+
expect(rows[0].payload_display).toEqual({ account: 'Acme Corp' });
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('enrichment maps user-id approvers to display names', async () => {
|
|
393
|
+
engine._tables['sys_user'] = [{ id: 'u9', name: 'Grace Hopper', email: 'grace@example.com' }];
|
|
394
|
+
await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
395
|
+
const rows = await svc.listRequests({ status: 'pending' }, SYS);
|
|
396
|
+
expect(rows[0].pending_approver_names).toEqual({ u9: 'Grace Hopper' });
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('listActions resolves actor display names', async () => {
|
|
400
|
+
engine._tables['sys_user'] = [
|
|
401
|
+
{ id: 'u1', name: 'Ada Lovelace', email: 'ada@example.com' },
|
|
402
|
+
{ id: 'u9', name: 'Grace Hopper', email: 'grace@example.com' },
|
|
403
|
+
];
|
|
404
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
405
|
+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
406
|
+
const actions = await svc.listActions(req.id, SYS);
|
|
407
|
+
expect(actions.map(a => (a as any).actor_name)).toEqual(['Ada Lovelace', 'Grace Hopper']);
|
|
408
|
+
});
|
|
409
|
+
|
|
379
410
|
it('enrichment resolves an email submitter via sys_user.email', async () => {
|
|
380
411
|
engine._tables['sys_user'] = [{ id: 'u7', name: 'Grace Hopper', email: 'grace@example.com' }];
|
|
381
412
|
await svc.openNodeRequest(openInput(['u9'], { submitterId: 'grace@example.com' }), CTX);
|
package/src/approval-service.ts
CHANGED
|
@@ -593,15 +593,66 @@ export class ApprovalService implements IApprovalService {
|
|
|
593
593
|
}
|
|
594
594
|
|
|
595
595
|
/**
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
|
|
599
|
-
|
|
596
|
+
* Batch-resolve `sys_user` display names for identifiers that may be user
|
|
597
|
+
* ids or emails. Best-effort — failures leave entries unresolved.
|
|
598
|
+
*/
|
|
599
|
+
private async resolveUserNames(identifiers: Array<string | null | undefined>): Promise<Map<string, string>> {
|
|
600
|
+
const names = new Map<string, string>();
|
|
601
|
+
const targets = Array.from(new Set(identifiers.filter(Boolean))) as string[];
|
|
602
|
+
if (!targets.length) return names;
|
|
603
|
+
try {
|
|
604
|
+
const users = await this.engine.find('sys_user', {
|
|
605
|
+
where: { id: { $in: targets } }, fields: ['id', 'name', 'email'],
|
|
606
|
+
limit: targets.length, context: SYSTEM_CTX,
|
|
607
|
+
});
|
|
608
|
+
for (const u of (users ?? []) as any[]) {
|
|
609
|
+
if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
|
|
610
|
+
}
|
|
611
|
+
} catch { /* best-effort */ }
|
|
612
|
+
const unresolvedEmails = targets.filter(t => !names.has(t) && t.includes('@'));
|
|
613
|
+
if (unresolvedEmails.length) {
|
|
614
|
+
try {
|
|
615
|
+
const users = await this.engine.find('sys_user', {
|
|
616
|
+
where: { email: { $in: unresolvedEmails } }, fields: ['email', 'name'],
|
|
617
|
+
limit: unresolvedEmails.length, context: SYSTEM_CTX,
|
|
618
|
+
});
|
|
619
|
+
for (const u of (users ?? []) as any[]) {
|
|
620
|
+
if (u?.email && u.name) names.set(String(u.email), String(u.name));
|
|
621
|
+
}
|
|
622
|
+
} catch { /* best-effort */ }
|
|
623
|
+
}
|
|
624
|
+
return names;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Lookup-typed fields (key + referenced object) of an object's schema. */
|
|
628
|
+
private resolveLookupFields(object: string): Array<{ key: string; reference: string }> {
|
|
629
|
+
try {
|
|
630
|
+
const schema: any = (this.engine as any).getSchema?.(object);
|
|
631
|
+
const fields = schema?.fields ?? {};
|
|
632
|
+
const out: Array<{ key: string; reference: string }> = [];
|
|
633
|
+
for (const [key, f] of Object.entries<any>(fields)) {
|
|
634
|
+
if ((f?.type === 'lookup' || f?.type === 'master_detail') && f?.reference) {
|
|
635
|
+
out.push({ key, reference: String(f.reference) });
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return out;
|
|
639
|
+
} catch { return []; }
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Attach inbox display fields to rows so clients never render a raw
|
|
644
|
+
* identifier: `record_title`, `submitter_name`, `object_label`,
|
|
645
|
+
* `pending_approver_names` (user-id approvers), and `payload_display`
|
|
646
|
+
* (lookup foreign keys in the snapshot → referenced record titles).
|
|
647
|
+
* Batched: one query per distinct object (target + referenced) plus one
|
|
648
|
+
* `sys_user` lookup. Best-effort — a deleted record falls back to the
|
|
649
|
+
* payload snapshot, and any failure leaves the field unset rather than
|
|
650
|
+
* failing the list.
|
|
600
651
|
*/
|
|
601
652
|
private async enrichRows(rows: ApprovalRequestRow[]): Promise<void> {
|
|
602
653
|
if (!rows.length) return;
|
|
603
654
|
|
|
604
|
-
// Record titles, batched per object.
|
|
655
|
+
// Record titles + object labels, batched per object.
|
|
605
656
|
const byObject = new Map<string, Set<string>>();
|
|
606
657
|
for (const r of rows) {
|
|
607
658
|
if (!r.object_name || !r.record_id) continue;
|
|
@@ -610,7 +661,12 @@ export class ApprovalService implements IApprovalService {
|
|
|
610
661
|
set.add(r.record_id);
|
|
611
662
|
}
|
|
612
663
|
const titles = new Map<string, string>();
|
|
664
|
+
const objectLabels = new Map<string, string>();
|
|
613
665
|
for (const [object, idSet] of byObject) {
|
|
666
|
+
try {
|
|
667
|
+
const schema: any = (this.engine as any).getSchema?.(object);
|
|
668
|
+
if (schema?.label) objectLabels.set(object, String(schema.label));
|
|
669
|
+
} catch { /* label optional */ }
|
|
614
670
|
const ids = Array.from(idSet);
|
|
615
671
|
const displayField = this.resolveDisplayField(object);
|
|
616
672
|
try {
|
|
@@ -619,44 +675,83 @@ export class ApprovalService implements IApprovalService {
|
|
|
619
675
|
});
|
|
620
676
|
for (const rec of (recs ?? []) as any[]) {
|
|
621
677
|
const title = ApprovalService.pickTitle(rec, displayField);
|
|
622
|
-
if (rec?.id && title) titles.set(`${object}
|
|
678
|
+
if (rec?.id && title) titles.set(`${object} ${rec.id}`, title);
|
|
623
679
|
}
|
|
624
680
|
} catch { /* object may be unregistered — payload fallback below */ }
|
|
625
681
|
}
|
|
626
682
|
|
|
627
|
-
//
|
|
628
|
-
const
|
|
629
|
-
const
|
|
630
|
-
|
|
683
|
+
// Lookup foreign keys inside payload snapshots → referenced record titles.
|
|
684
|
+
const lookupFieldsByObject = new Map<string, Array<{ key: string; reference: string }>>();
|
|
685
|
+
for (const object of byObject.keys()) {
|
|
686
|
+
const lookups = this.resolveLookupFields(object);
|
|
687
|
+
if (lookups.length) lookupFieldsByObject.set(object, lookups);
|
|
688
|
+
}
|
|
689
|
+
const refIds = new Map<string, Set<string>>();
|
|
690
|
+
for (const r of rows) {
|
|
691
|
+
const lookups = lookupFieldsByObject.get(r.object_name);
|
|
692
|
+
const payload: any = r.payload;
|
|
693
|
+
if (!lookups || !payload || typeof payload !== 'object') continue;
|
|
694
|
+
for (const { key, reference } of lookups) {
|
|
695
|
+
const v = payload[key];
|
|
696
|
+
if (v == null || typeof v === 'object' || !String(v).trim()) continue;
|
|
697
|
+
let set = refIds.get(reference);
|
|
698
|
+
if (!set) { set = new Set(); refIds.set(reference, set); }
|
|
699
|
+
set.add(String(v));
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
const refTitles = new Map<string, string>();
|
|
703
|
+
for (const [object, idSet] of refIds) {
|
|
704
|
+
const ids = Array.from(idSet);
|
|
705
|
+
const displayField = this.resolveDisplayField(object);
|
|
631
706
|
try {
|
|
632
|
-
const
|
|
633
|
-
where: { id: { $in:
|
|
634
|
-
limit: submitters.length, context: SYSTEM_CTX,
|
|
707
|
+
const recs = await this.engine.find(object, {
|
|
708
|
+
where: { id: { $in: ids } }, limit: ids.length, context: SYSTEM_CTX,
|
|
635
709
|
});
|
|
636
|
-
for (const
|
|
637
|
-
|
|
710
|
+
for (const rec of (recs ?? []) as any[]) {
|
|
711
|
+
const title = ApprovalService.pickTitle(rec, displayField);
|
|
712
|
+
if (rec?.id && title) refTitles.set(`${object} ${rec.id}`, title);
|
|
638
713
|
}
|
|
639
|
-
} catch { /*
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
}
|
|
650
|
-
} catch { /* best-effort */ }
|
|
714
|
+
} catch { /* referenced object unreadable — leave unresolved */ }
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Display names for submitters AND user-id approvers in one lookup.
|
|
718
|
+
// `role:<r>` (and other `type:value` literals) are already readable.
|
|
719
|
+
const userIdentifiers: Array<string | null | undefined> = [];
|
|
720
|
+
for (const r of rows) {
|
|
721
|
+
userIdentifiers.push(r.submitter_id);
|
|
722
|
+
for (const a of r.pending_approvers ?? []) {
|
|
723
|
+
if (a && !a.includes(':')) userIdentifiers.push(a);
|
|
651
724
|
}
|
|
652
725
|
}
|
|
726
|
+
const names = await this.resolveUserNames(userIdentifiers);
|
|
653
727
|
|
|
654
728
|
for (const r of rows as any[]) {
|
|
655
|
-
const title = titles.get(`${r.object_name}
|
|
729
|
+
const title = titles.get(`${r.object_name} ${r.record_id}`)
|
|
656
730
|
?? ApprovalService.pickTitle(r.payload, undefined);
|
|
657
731
|
if (title) r.record_title = title;
|
|
658
732
|
const name = r.submitter_id ? names.get(String(r.submitter_id)) : undefined;
|
|
659
733
|
if (name) r.submitter_name = name;
|
|
734
|
+
const label = objectLabels.get(r.object_name);
|
|
735
|
+
if (label) r.object_label = label;
|
|
736
|
+
|
|
737
|
+
const approverNames: Record<string, string> = {};
|
|
738
|
+
for (const a of r.pending_approvers ?? []) {
|
|
739
|
+
const n = names.get(String(a));
|
|
740
|
+
if (n) approverNames[a] = n;
|
|
741
|
+
}
|
|
742
|
+
if (Object.keys(approverNames).length) r.pending_approver_names = approverNames;
|
|
743
|
+
|
|
744
|
+
const lookups = lookupFieldsByObject.get(r.object_name);
|
|
745
|
+
if (lookups && r.payload && typeof r.payload === 'object') {
|
|
746
|
+
const display: Record<string, string> = {};
|
|
747
|
+
for (const { key, reference } of lookups) {
|
|
748
|
+
const v = (r.payload as any)[key];
|
|
749
|
+
if (v == null) continue;
|
|
750
|
+
const t = refTitles.get(`${reference} ${String(v)}`);
|
|
751
|
+
if (t) display[key] = t;
|
|
752
|
+
}
|
|
753
|
+
if (Object.keys(display).length) r.payload_display = display;
|
|
754
|
+
}
|
|
660
755
|
}
|
|
661
756
|
}
|
|
662
757
|
|
|
@@ -741,6 +836,16 @@ export class ApprovalService implements IApprovalService {
|
|
|
741
836
|
orderBy: [{ field: 'created_at', direction: 'asc' }],
|
|
742
837
|
context: SYSTEM_CTX,
|
|
743
838
|
});
|
|
744
|
-
|
|
839
|
+
const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
|
|
840
|
+
// Timeline display: resolve actor ids to names so the audit trail never
|
|
841
|
+
// shows a raw identifier. Role/team literals are already readable.
|
|
842
|
+
const names = await this.resolveUserNames(
|
|
843
|
+
actions.map(a => a.actor_id).filter(id => id && !id.includes(':')),
|
|
844
|
+
);
|
|
845
|
+
for (const a of actions as any[]) {
|
|
846
|
+
const n = a.actor_id ? names.get(String(a.actor_id)) : undefined;
|
|
847
|
+
if (n) a.actor_name = n;
|
|
848
|
+
}
|
|
849
|
+
return actions;
|
|
745
850
|
}
|
|
746
851
|
}
|