@objectstack/plugin-approvals 9.1.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/plugin-approvals",
3
- "version": "9.1.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.1.0",
17
- "@objectstack/formula": "9.1.0",
18
- "@objectstack/metadata-core": "9.1.0",
19
- "@objectstack/platform-objects": "9.1.0",
20
- "@objectstack/spec": "9.1.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.1.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);
@@ -593,15 +593,66 @@ export class ApprovalService implements IApprovalService {
593
593
  }
594
594
 
595
595
  /**
596
- * Attach inbox display fields (`record_title`, `submitter_name`) to rows.
597
- * Batched: one query per distinct target object plus one `sys_user` lookup.
598
- * Best-effort — a deleted record falls back to the payload snapshot, and a
599
- * lookup failure leaves the field unset rather than failing the list.
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}${rec.id}`, title);
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
- // Submitter display names submitter_id may be a user id or an email.
628
- const submitters = Array.from(new Set(rows.map(r => r.submitter_id).filter(Boolean))) as string[];
629
- const names = new Map<string, string>();
630
- if (submitters.length) {
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 users = await this.engine.find('sys_user', {
633
- where: { id: { $in: submitters } }, fields: ['id', 'name', 'email'],
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 u of (users ?? []) as any[]) {
637
- if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
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 { /* best-effort */ }
640
- const unresolvedEmails = submitters.filter(s => !names.has(s) && s.includes('@'));
641
- if (unresolvedEmails.length) {
642
- try {
643
- const users = await this.engine.find('sys_user', {
644
- where: { email: { $in: unresolvedEmails } }, fields: ['email', 'name'],
645
- limit: unresolvedEmails.length, context: SYSTEM_CTX,
646
- });
647
- for (const u of (users ?? []) as any[]) {
648
- if (u?.email && u.name) names.set(String(u.email), String(u.name));
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}${r.record_id}`)
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
- return Array.isArray(rows) ? rows.map(rowFromAction) : [];
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
  }