@@ -41,6 +41,12 @@ interface ProjectBillingAccountAccessOptions {
4141 requireAllowedProjectRole ?: boolean ;
4242}
4343
44+ interface ProjectLookupTarget {
45+ label : string ;
46+ projectMembersTable : Prisma . Sql ;
47+ projectsTable : Prisma . Sql ;
48+ }
49+
4450const PROJECT_BILLING_ACCOUNT_DETAIL_ROLES = [
4551 "manager" ,
4652 "account_manager" ,
@@ -51,14 +57,29 @@ const PROJECT_BILLING_ACCOUNT_DETAIL_ROLES = [
5157 "copilot" ,
5258] ;
5359
60+ const PROJECT_LOOKUP_TARGETS : ProjectLookupTarget [ ] = [
61+ {
62+ label : "configured projects search path" ,
63+ projectMembersTable : Prisma . sql `project_members` ,
64+ projectsTable : Prisma . sql `projects` ,
65+ } ,
66+ {
67+ label : "projects schema" ,
68+ projectMembersTable : Prisma . sql `projects.project_members` ,
69+ projectsTable : Prisma . sql `projects.projects` ,
70+ } ,
71+ ] ;
72+
5473/**
5574 * Resolves budget-entry external metadata from service-owned persistence stores.
5675 *
5776 * The service uses raw batched lookups so billing-account detail responses do
5877 * not make one network/database call per line item. It resolves display names
5978 * for response rows and project access for role-filtered line-item visibility.
6079 * Missing DB URLs or missing referenced rows produce empty lookup mappings
61- * rather than failing the billing account response.
80+ * rather than failing the billing account response. Projects API lookups try
81+ * both the configured connection search path and the explicit `projects`
82+ * schema, because deployment URLs are not always schema-qualified.
6283 */
6384@Injectable ( )
6485export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
@@ -215,7 +236,8 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
215236 * allow any non-deleted project assigned to the account, while plain project
216237 * members require non-deleted membership. Callers without a billing-account
217238 * Topcoder role can require the membership to be a management/copilot
218- * project role.
239+ * project role. The Projects DB table lookup is tolerant of URLs with or
240+ * without a `schema=projects` search path.
219241 *
220242 * @param billingAccountId Topcoder billing-account id from the detail route.
221243 * @param userId Topcoder user id from the authenticated caller; optional
@@ -267,31 +289,44 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
267289 PROJECT_BILLING_ACCOUNT_DETAIL_ROLES ,
268290 ) } )`;
269291
270- try {
271- const rows = await client . $queryRaw < ProjectBillingAccountAccessRow [ ] > (
272- Prisma . sql `
273- SELECT true AS "hasAccess"
274- FROM projects project
275- INNER JOIN project_members project_member
276- ON project_member."projectId" = project."id"
277- WHERE project."billingAccountId" = ${ BigInt (
278- normalizedBillingAccountId ,
279- ) }
280- AND project."deletedAt" IS NULL
281- AND project_member."userId" = ${ BigInt ( membershipUserId ) }
282- ${ projectRoleFilter }
283- AND project_member."deletedAt" IS NULL
284- LIMIT 1
285- ` ,
286- ) ;
292+ const errors : string [ ] = [ ] ;
293+ let successfulLookups = 0 ;
287294
288- return rows . some ( ( row ) => row . hasAccess ) ;
289- } catch ( error ) {
295+ for ( const target of PROJECT_LOOKUP_TARGETS ) {
296+ try {
297+ const rows = await client . $queryRaw < ProjectBillingAccountAccessRow [ ] > (
298+ Prisma . sql `
299+ SELECT true AS "hasAccess"
300+ FROM ${ target . projectsTable } project
301+ INNER JOIN ${ target . projectMembersTable } project_member
302+ ON project_member."projectId" = project."id"
303+ WHERE project."billingAccountId" = ${ BigInt (
304+ normalizedBillingAccountId ,
305+ ) }
306+ AND project."deletedAt" IS NULL
307+ AND project_member."userId" = ${ BigInt ( membershipUserId ) }
308+ ${ projectRoleFilter }
309+ AND project_member."deletedAt" IS NULL
310+ LIMIT 1
311+ ` ,
312+ ) ;
313+ successfulLookups += 1 ;
314+
315+ if ( rows . some ( ( row ) => row . hasAccess ) ) {
316+ return true ;
317+ }
318+ } catch ( error ) {
319+ errors . push ( `${ target . label } : ${ this . getErrorMessage ( error ) } ` ) ;
320+ }
321+ }
322+
323+ if ( successfulLookups === 0 && errors . length > 0 ) {
290324 this . logger . warn (
291- `Failed to resolve project billing-account access: ${ this . getErrorMessage ( error ) } ` ,
325+ `Failed to resolve project billing-account access: ${ errors . join ( "; " ) } ` ,
292326 ) ;
293- return false ;
294327 }
328+
329+ return false ;
295330 }
296331
297332 /**
@@ -305,24 +340,37 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
305340 client : PrismaClient ,
306341 billingAccountId : string ,
307342 ) : Promise < boolean > {
308- try {
309- const rows = await client . $queryRaw < ProjectBillingAccountAccessRow [ ] > (
310- Prisma . sql `
311- SELECT true AS "hasAccess"
312- FROM projects project
313- WHERE project."billingAccountId" = ${ BigInt ( billingAccountId ) }
314- AND project."deletedAt" IS NULL
315- LIMIT 1
316- ` ,
317- ) ;
343+ const errors : string [ ] = [ ] ;
344+ let successfulLookups = 0 ;
318345
319- return rows . some ( ( row ) => row . hasAccess ) ;
320- } catch ( error ) {
346+ for ( const target of PROJECT_LOOKUP_TARGETS ) {
347+ try {
348+ const rows = await client . $queryRaw < ProjectBillingAccountAccessRow [ ] > (
349+ Prisma . sql `
350+ SELECT true AS "hasAccess"
351+ FROM ${ target . projectsTable } project
352+ WHERE project."billingAccountId" = ${ BigInt ( billingAccountId ) }
353+ AND project."deletedAt" IS NULL
354+ LIMIT 1
355+ ` ,
356+ ) ;
357+ successfulLookups += 1 ;
358+
359+ if ( rows . some ( ( row ) => row . hasAccess ) ) {
360+ return true ;
361+ }
362+ } catch ( error ) {
363+ errors . push ( `${ target . label } : ${ this . getErrorMessage ( error ) } ` ) ;
364+ }
365+ }
366+
367+ if ( successfulLookups === 0 && errors . length > 0 ) {
321368 this . logger . warn (
322- `Failed to resolve project billing-account assignment: ${ this . getErrorMessage ( error ) } ` ,
369+ `Failed to resolve project billing-account assignment: ${ errors . join ( "; " ) } ` ,
323370 ) ;
324- return false ;
325371 }
372+
373+ return false ;
326374 }
327375
328376 /**
@@ -552,6 +600,8 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
552600 * @param userId Normalized numeric Topcoder user id.
553601 * @param projectIds Candidate project ids from line-item references.
554602 * @returns Set of candidate project ids with an active project member row.
603+ * The lookup checks both the configured search path and the explicit
604+ * `projects` schema.
555605 */
556606 private async getAccessibleProjectIdsForUser (
557607 userId : string ,
@@ -576,29 +626,39 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
576626 return result ;
577627 }
578628
579- try {
580- const rows = await client . $queryRaw < ProjectAccessRow [ ] > (
581- Prisma . sql `
582- SELECT DISTINCT "projectId"::text AS "projectId"
583- FROM project_members
584- WHERE "userId" = ${ BigInt ( userId ) }
585- AND "projectId" IN (${ Prisma . join (
586- uniqueProjectIds . map ( ( projectId ) => BigInt ( projectId ) ) ,
587- ) } )
588- AND "deletedAt" IS NULL
589- ` ,
590- ) ;
629+ const errors : string [ ] = [ ] ;
630+ let successfulLookups = 0 ;
591631
592- for ( const row of rows ) {
593- const projectId = this . normalizeNumericTextId ( row . projectId ) ;
632+ for ( const target of PROJECT_LOOKUP_TARGETS ) {
633+ try {
634+ const rows = await client . $queryRaw < ProjectAccessRow [ ] > (
635+ Prisma . sql `
636+ SELECT DISTINCT "projectId"::text AS "projectId"
637+ FROM ${ target . projectMembersTable }
638+ WHERE "userId" = ${ BigInt ( userId ) }
639+ AND "projectId" IN (${ Prisma . join (
640+ uniqueProjectIds . map ( ( projectId ) => BigInt ( projectId ) ) ,
641+ ) } )
642+ AND "deletedAt" IS NULL
643+ ` ,
644+ ) ;
645+ successfulLookups += 1 ;
594646
595- if ( projectId ) {
596- result . add ( projectId ) ;
647+ for ( const row of rows ) {
648+ const projectId = this . normalizeNumericTextId ( row . projectId ) ;
649+
650+ if ( projectId ) {
651+ result . add ( projectId ) ;
652+ }
597653 }
654+ } catch ( error ) {
655+ errors . push ( `${ target . label } : ${ this . getErrorMessage ( error ) } ` ) ;
598656 }
599- } catch ( error ) {
657+ }
658+
659+ if ( successfulLookups === 0 && errors . length > 0 ) {
600660 this . logger . warn (
601- `Failed to resolve project access for billing-account entries: ${ this . getErrorMessage ( error ) } ` ,
661+ `Failed to resolve project access for billing-account entries: ${ errors . join ( "; " ) } ` ,
602662 ) ;
603663 }
604664
0 commit comments