@@ -205,6 +205,10 @@ function rowFromAction(row: any): ApprovalActionRow {
205205 action : row . action ,
206206 actor_id : row . actor_id ?? undefined ,
207207 comment : row . comment ?? undefined ,
208+ // Decision attachments (#3266). The column shipped in #3268 but this
209+ // contract mapping didn't — the raw engine row carried the fileIds while
210+ // every consumer of listActions saw none (caught by browser verification).
211+ attachments : Array . isArray ( row . attachments ) && row . attachments . length ? row . attachments . map ( String ) : undefined ,
208212 created_at : row . created_at ?? undefined ,
209213 } ;
210214}
@@ -268,8 +272,20 @@ export class ApprovalService implements IApprovalService {
268272 } ) : Promise < number > {
269273 const audience = input . audience . filter ( a => a && ! a . includes ( ':' ) ) ;
270274 if ( ! this . messaging || ! audience . length ) return 0 ;
275+ // Deep-link the inbox (#2678 P1.5): a notification about one request should
276+ // land on that request, not the bare inbox. Rewritten centrally so every
277+ // call site — and any future one — inherits it; the query param is read by
278+ // the console inbox to auto-open the drawer.
279+ let payload = input . payload ;
280+ if (
281+ payload ?. actionUrl === '/system/approvals'
282+ && input . source ?. object === 'sys_approval_request'
283+ && input . source . id
284+ ) {
285+ payload = { ...payload , actionUrl : `/system/approvals?request=${ encodeURIComponent ( input . source . id ) } ` } ;
286+ }
271287 try {
272- await this . messaging . emit ( { severity : 'info' , ...input , audience } ) ;
288+ await this . messaging . emit ( { severity : 'info' , ...input , payload , audience } ) ;
273289 return audience . length ;
274290 } catch ( err : any ) {
275291 this . logger ?. warn ?.( '[approvals] notification failed' , {
@@ -2204,9 +2220,56 @@ export class ApprovalService implements IApprovalService {
22042220 const row = rowFromRequest ( rows [ 0 ] ) ;
22052221 await this . enrichRows ( [ row ] ) ;
22062222 await this . attachFlowSteps ( row ) ;
2223+ await this . attachDecisionProgress ( row , rows [ 0 ] ) ;
22072224 return row ;
22082225 }
22092226
2227+ /**
2228+ * Server-computed decision aggregation progress (#3266 / objectui#2678 P1.5).
2229+ * Single-read enrichment only (like {@link ApprovalService.attachFlowSteps}):
2230+ * for a PENDING request whose behavior aggregates multiple approvals
2231+ * (`unanimous` / `quorum` / `per_group`), expose
2232+ * `decision_progress: { behavior, got, need, groups? }` so any client renders
2233+ * "2 of 3" or per-group ticks without re-deriving the engine's tally rules.
2234+ * `first_response` requests carry no progress (one approval finalizes).
2235+ * Display-only and best-effort — errors leave the row untouched.
2236+ */
2237+ private async attachDecisionProgress ( row : ApprovalRequestRow , raw : any ) : Promise < void > {
2238+ try {
2239+ if ( row . status !== 'pending' ) return ;
2240+ const cfg = parseJson < any > ( raw . node_config_json , undefined ) ;
2241+ const behavior = cfg ?. behavior ?? 'first_response' ;
2242+ if ( behavior !== 'unanimous' && behavior !== 'quorum' && behavior !== 'per_group' ) return ;
2243+
2244+ const acts = await this . engine . find ( 'sys_approval_action' , {
2245+ where : { request_id : row . id , step_index : 0 , action : 'approve' } , limit : 1000 , context : SYSTEM_CTX ,
2246+ } ) ;
2247+ const approved = new Set < string > ( ( acts ?? [ ] ) . map ( ( a : any ) => String ( a . actor_id ?? '' ) ) . filter ( Boolean ) ) ;
2248+
2249+ const snapshot = cfg ?. __approverGroups as Record < string , string [ ] > | undefined ;
2250+ const slate = snapshot ? Object . keys ( snapshot ) : [ ...approved , ...( row . pending_approvers ?? [ ] ) ] ;
2251+ const total = slate . length || 1 ;
2252+
2253+ const progress : any = { behavior, got : approved . size , need : total } ;
2254+ if ( behavior === 'quorum' ) {
2255+ progress . need = Math . min ( Math . max ( 1 , cfg ?. minApprovals ?? total ) , total ) ;
2256+ } else if ( behavior === 'per_group' && snapshot ) {
2257+ const perGroupNeed = Math . max ( 1 , cfg ?. minApprovals ?? 1 ) ;
2258+ const size : Record < string , number > = { } ;
2259+ for ( const gs of Object . values ( snapshot ) ) for ( const g of gs ) size [ g ] = ( size [ g ] ?? 0 ) + 1 ;
2260+ const got : Record < string , number > = { } ;
2261+ for ( const a of approved ) for ( const g of ( snapshot [ a ] ?? [ ] ) ) got [ g ] = ( got [ g ] ?? 0 ) + 1 ;
2262+ progress . groups = Object . keys ( size ) . sort ( ) . map ( g => {
2263+ const need = Math . min ( perGroupNeed , size [ g ] ) ;
2264+ return { group : g , got : Math . min ( got [ g ] ?? 0 , need ) , need, satisfied : ( got [ g ] ?? 0 ) >= need } ;
2265+ } ) ;
2266+ progress . got = progress . groups . filter ( ( g : any ) => g . satisfied ) . length ;
2267+ progress . need = progress . groups . length ;
2268+ }
2269+ ( row as any ) . decision_progress = progress ;
2270+ } catch { /* display-only enrichment */ }
2271+ }
2272+
22102273 /**
22112274 * Derive approval-step progress from the owning flow's graph (single-read
22122275 * enrichment only — list reads skip it). Walks from the start node
0 commit comments