Skip to content

Commit 1680241

Browse files
committed
Project manager tweak
1 parent 7c3e000 commit 1680241

2 files changed

Lines changed: 118 additions & 55 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@
4444
Manager callers only receive locked/consumed line items whose challenge or
4545
engagement project maps to an active `project_members` row for their user id.
4646
Line items with unresolved project access are omitted for those callers.
47+
Projects DB access checks try both the configured connection search path and
48+
the explicit `projects` schema so deployments do not need to rely on a
49+
schema-qualified `PROJECTS_DB_URL`.
4750
Role checks are case-insensitive so mixed token casing does not block access.
4851
- Configure env: `AUTH_SECRET` or `AUTH0_URL/AUDIENCE/ISSUER` as needed.
4952

src/billing-accounts/external-budget-entry-lookup.service.ts

Lines changed: 115 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
4450
const 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()
6485
export 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

Comments
 (0)