@@ -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}
0 commit comments