Skip to content

Commit eb8cb95

Browse files
authored
Merge pull request #1737 from objectstack-ai/feat-approvals-display-contract-v2
feat(approvals): display contract v2 — no raw identifiers anywhere
2 parents 6e3cf03 + bf81cfa commit eb8cb95

3 files changed

Lines changed: 180 additions & 29 deletions

File tree

packages/plugins/plugin-approvals/src/approval-service.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,37 @@ describe('ApprovalService (node era)', () => {
376376
expect(rows[0].record_title).toBe('Snapshot Title');
377377
});
378378

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+
379410
it('enrichment resolves an email submitter via sys_user.email', async () => {
380411
engine._tables['sys_user'] = [{ id: 'u7', name: 'Grace Hopper', email: 'grace@example.com' }];
381412
await svc.openNodeRequest(openInput(['u9'], { submitterId: 'grace@example.com' }), CTX);

packages/plugins/plugin-approvals/src/approval-service.ts

Lines changed: 134 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -593,15 +593,66 @@ export class ApprovalService implements IApprovalService {
593593
}
594594

595595
/**
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.
600651
*/
601652
private async enrichRows(rows: ApprovalRequestRow[]): Promise<void> {
602653
if (!rows.length) return;
603654

604-
// Record titles, batched per object.
655+
// Record titles + object labels, batched per object.
605656
const byObject = new Map<string, Set<string>>();
606657
for (const r of rows) {
607658
if (!r.object_name || !r.record_id) continue;
@@ -610,7 +661,12 @@ export class ApprovalService implements IApprovalService {
610661
set.add(r.record_id);
611662
}
612663
const titles = new Map<string, string>();
664+
const objectLabels = new Map<string, string>();
613665
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 */ }
614670
const ids = Array.from(idSet);
615671
const displayField = this.resolveDisplayField(object);
616672
try {
@@ -619,44 +675,83 @@ export class ApprovalService implements IApprovalService {
619675
});
620676
for (const rec of (recs ?? []) as any[]) {
621677
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);
623679
}
624680
} catch { /* object may be unregistered — payload fallback below */ }
625681
}
626682

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);
631706
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,
635709
});
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);
638713
}
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);
651724
}
652725
}
726+
const names = await this.resolveUserNames(userIdentifiers);
653727

654728
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}`)
656730
?? ApprovalService.pickTitle(r.payload, undefined);
657731
if (title) r.record_title = title;
658732
const name = r.submitter_id ? names.get(String(r.submitter_id)) : undefined;
659733
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+
}
660755
}
661756
}
662757

@@ -741,6 +836,16 @@ export class ApprovalService implements IApprovalService {
741836
orderBy: [{ field: 'created_at', direction: 'asc' }],
742837
context: SYSTEM_CTX,
743838
});
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;
745850
}
746851
}

packages/spec/src/contracts/approval-service.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,19 @@ export interface ApprovalRequestRow {
5757
record_title?: string;
5858
/** Display name of the submitter (`sys_user.name`), when resolvable. */
5959
submitter_name?: string;
60+
/** Schema label of the target object (e.g. "Project" for `showcase_project`). */
61+
object_label?: string;
62+
/**
63+
* Display names for user-id entries in `pending_approvers`
64+
* (id → `sys_user.name`). Emails and `role:<r>` entries are not mapped —
65+
* they are already human-readable.
66+
*/
67+
pending_approver_names?: Record<string, string>;
68+
/**
69+
* Display values for lookup fields in `payload` (field key → referenced
70+
* record's display name), so inbox summaries never show foreign-key ids.
71+
*/
72+
payload_display?: Record<string, string>;
6073
}
6174

6275
/** Audit row. */
@@ -69,6 +82,8 @@ export interface ApprovalActionRow {
6982
actor_id?: string;
7083
comment?: string;
7184
created_at?: string;
85+
/** Display name of the actor (`sys_user.name`), when resolvable. */
86+
actor_name?: string;
7287
}
7388

7489
/** Input for a decision on an approval request. */

0 commit comments

Comments
 (0)