11// Copyright The Linux Foundation and each contributor to LFX.
22// SPDX-License-Identifier: MIT
33
4- import { LENS_PERSONA_MAP , NAV_MAX_UPSTREAM_ITERATIONS , NAV_MIN_ITEMS_PER_RESPONSE } from '@lfx-one/shared/constants' ;
54import { ProjectFunding , ProjectStage } from '@lfx-one/shared/enums' ;
6- import {
7- EnrichedPersonaProject ,
8- GetLensItemsParams ,
9- LensItem ,
10- LensItemsQuery ,
11- LensItemsResponse ,
12- NavLens ,
13- PersonaApiResponse ,
14- PersonaType ,
15- Project ,
16- QueryServiceResponse ,
17- } from '@lfx-one/shared/interfaces' ;
5+ import { GetLensItemsParams , LensItem , LensItemsQuery , LensItemsResponse , NavLens , Project , QueryServiceResponse } from '@lfx-one/shared/interfaces' ;
186import { computeIsFoundation } from '@lfx-one/shared/utils' ;
197import { Request } from 'express' ;
208
21- import { personaDetectionService } from '../utils/persona-helper' ;
229import { logger } from './logger.service' ;
2310import { MicroserviceProxyService } from './microservice-proxy.service' ;
2411
2512/** Stages eligible for the project lens — Active plus supported pre-launch formation stages. */
2613const PROJECT_LENS_ALLOWED_STAGES = new Set < string > ( [ ProjectStage . Active , ProjectStage . FormationEngaged , ProjectStage . FormationExploratory ] ) ;
2714
28- /** Powers the foundation/project lens dropdown. Root writers bypass the persona filter . */
15+ /** Powers the foundation/project lens dropdown. Access is gated entirely by the user's bearer token via the query service . */
2916export class NavigationService {
3017 private readonly microserviceProxy : MicroserviceProxyService ;
3118
@@ -36,116 +23,47 @@ export class NavigationService {
3623 public async getLensItems ( req : Request , params : GetLensItemsParams ) : Promise < LensItemsResponse > {
3724 const { lens, pageToken, name, selectedUid } = params ;
3825
39- // Parallel: root-writer check + first upstream page. Deferring persona fetch saves the
40- // NATS roundtrip for admins (the hot path).
41- const [ bypassActive , firstPage ] = await Promise . all ( [ personaDetectionService . checkRootWriter ( req ) , this . fetchUpstreamPage ( req , lens , pageToken , name ) ] ) ;
26+ const firstPage = await this . fetchUpstreamPage ( req , lens , pageToken , name ) ;
4227
43- let persona : PersonaApiResponse | null = null ;
44- let personaFetchFailed = false ;
45- if ( ! bypassActive ) {
46- const personaResult = await this . fetchPersona ( req ) ;
47- persona = personaResult . persona ;
48- personaFetchFailed = personaResult . failed ;
49- }
50-
51- // Fail closed: a persona-fetch failure must not fall through to unfiltered lens items.
52- // The frontend surfaces the "Unable to load" toast when items is empty + persona_fetch_failed.
53- if ( ! bypassActive && personaFetchFailed ) {
54- logger . warning ( req , 'build_lens_items' , 'Persona fetch failed, returning empty lens items to fail closed' , { lens } ) ;
28+ if ( firstPage . failed || ! firstPage . response ) {
5529 return {
5630 items : [ ] ,
5731 next_page_token : null ,
58- bypass_active : false ,
59- persona_fetch_failed : true ,
60- upstream_failed : firstPage . failed ,
32+ upstream_failed : true ,
6133 lens,
6234 } ;
6335 }
6436
65- const shouldFilter = ! bypassActive && ! ! persona ;
66- const eligibleUids = shouldFilter && persona ? this . collectEligibleProjectUids ( persona . projects , LENS_PERSONA_MAP [ lens ] ) : null ;
67-
68- // targetCount=0 when filtering with no eligible projects skips the loop body entirely.
69- const targetCount = eligibleUids ? eligibleUids . size : NAV_MIN_ITEMS_PER_RESPONSE ;
70-
71- const accumulated : LensItem [ ] = [ ] ;
72- let iterations = 0 ;
73- let lastToken : string | null = null ;
74- let upstreamFailed = firstPage . failed ;
75- let currentResponse : QueryServiceResponse < Project > | null = firstPage . response ;
76-
77- while ( currentResponse && accumulated . length < targetCount && iterations < NAV_MAX_UPSTREAM_ITERATIONS ) {
78- iterations += 1 ;
79- const pageItems = this . filterPageResources ( currentResponse . resources , lens , eligibleUids ) ;
80- accumulated . push ( ...pageItems . map ( ( p ) => this . toLensItem ( p ) ) ) ;
81- lastToken = currentResponse . page_token ?? null ;
82-
83- if ( ! lastToken || accumulated . length >= targetCount ) break ;
84-
85- const next = await this . fetchUpstreamPage ( req , lens , lastToken , name ) ;
86- if ( next . failed ) {
87- upstreamFailed = true ;
88- lastToken = null ;
89- break ;
90- }
91- currentResponse = next . response ;
92- }
93-
94- // With persona filtering, only suppress next-page token when we've proven completeness.
95- // If the loop exited due to the iteration cap, keep the token so the client can continue.
96- if ( eligibleUids ) {
97- const fullyCollected = accumulated . length >= eligibleUids . size ;
98- if ( fullyCollected || upstreamFailed ) {
99- lastToken = null ;
100- }
101- }
37+ const projects = this . filterPageResources ( firstPage . response . resources , lens ) ;
38+ const items : LensItem [ ] = projects . map ( ( p ) => this . toLensItem ( p ) ) ;
39+ const nextPageToken : string | null = firstPage . response . page_token ?? null ;
10240
10341 // Ensure the selected project is in the first-page response so navigation from
10442 // other lenses (e.g., Me → Open) doesn't get overridden by the default picker.
10543 if ( selectedUid && ! pageToken ) {
106- const alreadyIncluded = accumulated . some ( ( item ) => item . uid === selectedUid ) ;
44+ const alreadyIncluded = items . some ( ( item ) => item . uid === selectedUid ) ;
10745 if ( ! alreadyIncluded ) {
10846 const selectedItem = await this . fetchSelectedItem ( req , lens , selectedUid ) ;
10947 if ( selectedItem ) {
110- accumulated . unshift ( selectedItem ) ;
48+ items . unshift ( selectedItem ) ;
11149 }
11250 }
11351 }
11452
11553 logger . debug ( req , 'build_lens_items' , 'Built lens items' , {
11654 lens,
117- item_count : accumulated . length ,
118- upstream_iterations : iterations ,
119- bypass_active : bypassActive ,
120- persona_fetch_failed : personaFetchFailed ,
121- upstream_failed : upstreamFailed ,
122- has_next_page : ! ! lastToken ,
55+ item_count : items . length ,
56+ has_next_page : ! ! nextPageToken ,
12357 } ) ;
12458
12559 return {
126- items : accumulated ,
127- next_page_token : lastToken ,
128- bypass_active : bypassActive ,
129- persona_fetch_failed : personaFetchFailed ,
130- upstream_failed : upstreamFailed ,
60+ items,
61+ next_page_token : nextPageToken ,
62+ upstream_failed : false ,
13163 lens,
13264 } ;
13365 }
13466
135- private async fetchPersona ( req : Request ) : Promise < { persona : PersonaApiResponse | null ; failed : boolean } > {
136- try {
137- const persona = await personaDetectionService . getPersonas ( req ) ;
138- if ( persona . error ) {
139- logger . warning ( req , 'fetch_persona' , 'Persona fetch returned error, falling back to lens-scoped results only' , { error : persona . error } ) ;
140- return { persona : null , failed : true } ;
141- }
142- return { persona, failed : false } ;
143- } catch ( error ) {
144- logger . warning ( req , 'fetch_persona' , 'Persona fetch failed, falling back to lens-scoped results only' , { err : error } ) ;
145- return { persona : null , failed : true } ;
146- }
147- }
148-
14967 private async fetchUpstreamPage (
15068 req : Request ,
15169 lens : NavLens ,
@@ -183,15 +101,12 @@ export class NavigationService {
183101 }
184102 }
185103
186- private filterPageResources ( resources : QueryServiceResponse < Project > [ 'resources' ] , lens : NavLens , eligibleUids : Set < string > | null ) : Project [ ] {
104+ private filterPageResources ( resources : QueryServiceResponse < Project > [ 'resources' ] , lens : NavLens ) : Project [ ] {
187105 let projects = resources . map ( ( r ) => r . data ) ;
188106 // legal_entity_type negation isn't supported by the filter grammar — re-check locally.
189107 if ( lens === 'foundation' ) {
190108 projects = projects . filter ( ( p ) => computeIsFoundation ( p ) ) ;
191109 }
192- if ( eligibleUids ) {
193- projects = projects . filter ( ( p ) => eligibleUids . has ( p . uid ) ) ;
194- }
195110 return projects ;
196111 }
197112
@@ -210,7 +125,7 @@ export class NavigationService {
210125 base . filters_or = [ `stage:${ ProjectStage . Active } ` , `stage:${ ProjectStage . FormationEngaged } ` ] ;
211126 } else {
212127 // Include active projects plus supported pre-launch formation stages so
213- // persona-eligible pre-launch projects appear in the project dropdown.
128+ // pre-launch projects appear in the project dropdown.
214129 base . filters_or = [ ...PROJECT_LENS_ALLOWED_STAGES ] . map ( ( stage ) => `stage:${ stage } ` ) ;
215130 }
216131
@@ -220,16 +135,6 @@ export class NavigationService {
220135 return base ;
221136 }
222137
223- private collectEligibleProjectUids ( projects : EnrichedPersonaProject [ ] , allowedPersonas : readonly PersonaType [ ] ) : Set < string > {
224- const uids = new Set < string > ( ) ;
225- for ( const project of projects ) {
226- if ( project . personas . some ( ( p ) => allowedPersonas . includes ( p ) ) ) {
227- uids . add ( project . projectUid ) ;
228- }
229- }
230- return uids ;
231- }
232-
233138 private toLensItem ( project : Project ) : LensItem {
234139 return {
235140 uid : project . uid ,
0 commit comments