From cae8c1f0e5fd6fa4ac7adc940b2f5a2eb7afaa3e Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 27 Apr 2026 08:32:49 +1000 Subject: [PATCH 1/9] Don't show markup values to copilots --- README.md | 6 +- .../billing-accounts.controller.ts | 4 +- .../billing-accounts.service.ts | 104 +++++++++++++++++- 3 files changed, 108 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3ae057a..01d1b90 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - Endpoints: - `GET /billing-accounts` - `POST /billing-accounts` - - `GET /billing-accounts/:billingAccountId` (includes locked/consumed arrays + budget totals; line items expose `amount`, `date`, `externalId`, `externalType`, `externalName`, and `challengeId` only for challenge compatibility; copilot, Project Manager, and Talent Manager callers only receive line items for projects they belong to) + - `GET /billing-accounts/:billingAccountId` (includes locked/consumed arrays + budget totals; line items expose `amount`, `date`, `externalId`, `externalType`, `externalName`, and `challengeId` only for challenge compatibility; copilot, Project Manager, and Talent Manager callers only receive line items for projects they belong to; copilot-only callers receive `memberPaymentsRemaining` instead of raw `markup`) - `GET /billing-accounts/users/:userId` (list billing accounts accessible by the given Topcoder user ID — resolved via Salesforce resource object) - `PATCH /billing-accounts/:billingAccountId` - `PATCH /billing-accounts/:billingAccountId/lock-amount` (challenge-only typed reference; non-negative amount; 0 amount = unlock; rejects insufficient remaining funds) @@ -29,7 +29,9 @@ also continue to allow `copilot`, `Project Manager`, and `Topcoder Project Manager`. Project Managers are restricted to billing accounts granted to their own user id on `GET /billing-accounts` and `GET /billing-accounts/:billingAccountId`. - On billing-account detail responses, copilot, Project Manager, and Talent + Copilot-only callers receive billing-account responses with `markup` omitted + and `memberPaymentsRemaining` derived server-side. On billing-account detail + responses, copilot, Project Manager, and Talent Manager callers only receive locked/consumed line items whose challenge or engagement project maps to an active `project_members` row for their user id. Line items with unresolved project access are omitted for those callers. diff --git a/src/billing-accounts/billing-accounts.controller.ts b/src/billing-accounts/billing-accounts.controller.ts index 63d623f..20c5c93 100644 --- a/src/billing-accounts/billing-accounts.controller.ts +++ b/src/billing-accounts/billing-accounts.controller.ts @@ -83,7 +83,7 @@ export class BillingAccountsController { buildOperationDoc({ summary: "List billing accounts", description: - "Retrieve billing accounts with optional filters, sorting, and pagination. Project Managers are limited to billing accounts granted to their own user id.", + "Retrieve billing accounts with optional filters, sorting, and pagination. Project Managers are limited to billing accounts granted to their own user id. Copilot-only callers receive copilot-safe budget data without the raw markup field.", jwtRoles: BILLING_ACCOUNT_PROJECT_READ_ROLES, m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA], }), @@ -173,7 +173,7 @@ export class BillingAccountsController { buildOperationDoc({ summary: "Get a billing account", description: - "Fetch a billing account by its identifier, including budget, client data, and normalized locked/consumed line items. Line items include amount, date, externalId, externalType, externalName, and challengeId only for legacy challenge compatibility. Project Managers can read only billing accounts granted to them. Copilot, Project Manager, and Talent Manager callers only receive locked/consumed line items for projects they belong to.", + "Fetch a billing account by its identifier, including budget, client data, and normalized locked/consumed line items. Line items include amount, date, externalId, externalType, externalName, and challengeId only for legacy challenge compatibility. Project Managers can read only billing accounts granted to them. Copilot, Project Manager, and Talent Manager callers only receive locked/consumed line items for projects they belong to. Copilot-only callers receive copilot-safe budget data without the raw markup field.", jwtRoles: BILLING_ACCOUNT_PROJECT_READ_ROLES, m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA], }), diff --git a/src/billing-accounts/billing-accounts.service.ts b/src/billing-accounts/billing-accounts.service.ts index 1d16109..746901e 100644 --- a/src/billing-accounts/billing-accounts.service.ts +++ b/src/billing-accounts/billing-accounts.service.ts @@ -63,6 +63,14 @@ const PROJECT_ACCESS_FILTERED_LINE_ITEM_ROLES = [ TOPCODER_TALENT_MANAGER_ROLE, ]; +const BILLING_ACCOUNT_MARKUP_VISIBLE_ROLES = [ + ADMIN_ROLE, + PROJECT_MANAGER_ROLE, + TOPCODER_PROJECT_MANAGER_ROLE, + TALENT_MANAGER_ROLE, + TOPCODER_TALENT_MANAGER_ROLE, +]; + const BUDGET_AMOUNT_DECIMAL_PLACES = 4; interface BudgetAmountLineItem { @@ -230,6 +238,31 @@ function resolveProjectAccessFilteredLineItemUserId( return getNormalizedAuthUserId(authUser) ?? null; } +/** + * Returns whether the caller should receive copilot-safe billing-account data. + * + * Copilots must not receive the raw billing-account markup. Manager, Talent + * Manager, and administrator roles retain the existing billing-account detail + * response shape even when a token also carries the copilot role. + * + * @param authUser Authenticated caller context from `req.authUser`. + * @returns `true` when markup should be removed from billing-account responses. + */ +function shouldHideMarkupForCopilot( + authUser?: BillingAccountsAuthUser, +): boolean { + const normalizedRoles = getNormalizedAuthUserRoles(authUser); + const hasCopilotRole = normalizedRoles.includes(COPILOT_ROLE.toLowerCase()); + + if (!hasCopilotRole) { + return false; + } + + return !BILLING_ACCOUNT_MARKUP_VISIBLE_ROLES.some((role) => + normalizedRoles.includes(role.toLowerCase()), + ); +} + @Injectable() export class BillingAccountsService { constructor( @@ -392,7 +425,9 @@ export class BillingAccountsService { perPage, total, totalPages: Math.ceil(total / perPage), - data, + data: data.map((billingAccount) => + this.serializeBillingAccountForAuthUser(billingAccount, authUser), + ), }; } @@ -499,7 +534,7 @@ export class BillingAccountsService { ], ); - return { + const response = { ...ba, lockedAmounts: lockedAmounts.map((lineItem) => this.serializeBudgetLineItem(lineItem, externalNames), @@ -511,6 +546,8 @@ export class BillingAccountsService { consumedBudget: consumed, totalBudgetRemaining: remaining, }; + + return this.serializeBillingAccountForAuthUser(response, authUser); } async update(billingAccountId: number, dto: UpdateBillingAccountDto) { @@ -1121,6 +1158,69 @@ export class BillingAccountsService { : serializedLineItem; } + /** + * Calculates the copilot-safe member-payment capacity for a billing account. + * + * This mirrors the Work app's existing calculation while keeping the raw + * billing markup on the server. A zero markup means the full remaining budget + * is available for member payments. + * + * @param totalBudgetRemaining Remaining billing-account budget. + * @param markup Billing-account markup from persistence. + * @returns Rounded member-payment capacity, or `undefined` when inputs are invalid. + */ + private calculateMemberPaymentsRemaining( + totalBudgetRemaining: unknown, + markup: unknown, + ): number | undefined { + const remaining = Number(totalBudgetRemaining); + const rawMarkup = Number(markup); + + if (!Number.isFinite(remaining) || !Number.isFinite(rawMarkup)) { + return undefined; + } + + const normalizedMarkup = rawMarkup > 1 ? rawMarkup / 100 : rawMarkup; + + if (normalizedMarkup < 0) { + return undefined; + } + + if (normalizedMarkup === 0) { + return Number(remaining.toFixed(2)); + } + + return Number((remaining / (1 / normalizedMarkup)).toFixed(2)); + } + + /** + * Removes raw markup from copilot responses and adds a derived safe budget field. + * + * @param billingAccount Billing-account response object after budget totals are available. + * @param authUser Authenticated caller context from `req.authUser`. + * @returns The original response for privileged callers, or a copilot-safe copy. + */ + private serializeBillingAccountForAuthUser< + T extends { markup?: unknown; totalBudgetRemaining?: unknown }, + >(billingAccount: T, authUser?: BillingAccountsAuthUser) { + if (!shouldHideMarkupForCopilot(authUser)) { + return billingAccount; + } + + const { markup, ...sanitizedBillingAccount } = billingAccount; + const memberPaymentsRemaining = this.calculateMemberPaymentsRemaining( + billingAccount.totalBudgetRemaining, + markup, + ); + + return memberPaymentsRemaining === undefined + ? sanitizedBillingAccount + : { + ...sanitizedBillingAccount, + memberPaymentsRemaining, + }; + } + /** * List users (resources) assigned to a billing account. * Returns minimal objects including handle (as name) for UI consumption. From fced697a866110ef46021af90c1c518ab1c20354 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 27 Apr 2026 09:11:11 +1000 Subject: [PATCH 2/9] Changes for PM-4943 --- README.md | 6 +- .../billing-accounts.controller.ts | 2 +- .../billing-accounts.service.ts | 122 +++++++++++++++--- 3 files changed, 107 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 01d1b90..6c43c6f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - Endpoints: - `GET /billing-accounts` - `POST /billing-accounts` - - `GET /billing-accounts/:billingAccountId` (includes locked/consumed arrays + budget totals; line items expose `amount`, `date`, `externalId`, `externalType`, `externalName`, and `challengeId` only for challenge compatibility; copilot, Project Manager, and Talent Manager callers only receive line items for projects they belong to; copilot-only callers receive `memberPaymentsRemaining` instead of raw `markup`) + - `GET /billing-accounts/:billingAccountId` (includes locked/consumed arrays + budget totals; line items expose `amount`, `date`, `externalId`, `externalType`, `externalName`, and `challengeId` only for challenge compatibility; copilot, Project Manager, and Talent Manager callers only receive line items for projects they belong to; copilot-only callers receive `memberPaymentsRemaining` and per-line-item `memberPaymentAmount` instead of raw `markup`) - `GET /billing-accounts/users/:userId` (list billing accounts accessible by the given Topcoder user ID — resolved via Salesforce resource object) - `PATCH /billing-accounts/:billingAccountId` - `PATCH /billing-accounts/:billingAccountId/lock-amount` (challenge-only typed reference; non-negative amount; 0 amount = unlock; rejects insufficient remaining funds) @@ -30,7 +30,9 @@ Project Managers are restricted to billing accounts granted to their own user id on `GET /billing-accounts` and `GET /billing-accounts/:billingAccountId`. Copilot-only callers receive billing-account responses with `markup` omitted - and `memberPaymentsRemaining` derived server-side. On billing-account detail + and `memberPaymentsRemaining` derived server-side. Billing-account detail + responses for copilots also include per-line-item `memberPaymentAmount` values + that remove the markup fee from locked and consumed amounts. On detail responses, copilot, Project Manager, and Talent Manager callers only receive locked/consumed line items whose challenge or engagement project maps to an active `project_members` row for their user id. diff --git a/src/billing-accounts/billing-accounts.controller.ts b/src/billing-accounts/billing-accounts.controller.ts index 20c5c93..3c97974 100644 --- a/src/billing-accounts/billing-accounts.controller.ts +++ b/src/billing-accounts/billing-accounts.controller.ts @@ -173,7 +173,7 @@ export class BillingAccountsController { buildOperationDoc({ summary: "Get a billing account", description: - "Fetch a billing account by its identifier, including budget, client data, and normalized locked/consumed line items. Line items include amount, date, externalId, externalType, externalName, and challengeId only for legacy challenge compatibility. Project Managers can read only billing accounts granted to them. Copilot, Project Manager, and Talent Manager callers only receive locked/consumed line items for projects they belong to. Copilot-only callers receive copilot-safe budget data without the raw markup field.", + "Fetch a billing account by its identifier, including budget, client data, and normalized locked/consumed line items. Line items include amount, date, externalId, externalType, externalName, and challengeId only for legacy challenge compatibility. Project Managers can read only billing accounts granted to them. Copilot, Project Manager, and Talent Manager callers only receive locked/consumed line items for projects they belong to. Copilot-only callers receive copilot-safe budget data without the raw markup field, including memberPaymentsRemaining and per-line-item memberPaymentAmount values.", jwtRoles: BILLING_ACCOUNT_PROJECT_READ_ROLES, m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA], }), diff --git a/src/billing-accounts/billing-accounts.service.ts b/src/billing-accounts/billing-accounts.service.ts index 746901e..38a935f 100644 --- a/src/billing-accounts/billing-accounts.service.ts +++ b/src/billing-accounts/billing-accounts.service.ts @@ -113,6 +113,23 @@ interface ProjectAccessFilteredLineItems { consumedAmounts: BudgetAmountLineItem[]; } +export interface BudgetLineItemResponse { + amount: Prisma.Decimal; + date: Date; + externalId: string; + externalType: BudgetEntryExternalTypeValue; + externalName: string | null; + challengeId?: string; + memberPaymentAmount?: number; +} + +export interface BillingAccountAuthResponse { + markup?: unknown; + totalBudgetRemaining?: unknown; + lockedAmounts?: BudgetLineItemResponse[]; + consumedAmounts?: BudgetLineItemResponse[]; +} + /** * Normalizes authenticated caller roles for case-insensitive comparisons. * @@ -464,9 +481,11 @@ export class BillingAccountsService { * `userId`. Missing access is surfaced as not found to avoid leaking account * existence. Locked and consumed line items expose `amount`, `date`, * `externalId`, `externalType`, and `externalName`; challenge rows also expose - * the deprecated `challengeId` compatibility alias. Copilot, Project Manager, - * and Talent Manager callers only receive line items for projects they belong - * to; unresolved project access hides the line item. + * the deprecated `challengeId` compatibility alias. Copilot-only callers also + * receive `memberPaymentAmount` on each line item so the UI can show the + * payment value without exposing markup. Copilot, Project Manager, and Talent + * Manager callers only receive line items for projects they belong to; + * unresolved project access hides the line item. * * @param billingAccountId Billing-account identifier. * @param authUser Authenticated caller context from `req.authUser`. @@ -1142,7 +1161,7 @@ export class BillingAccountsService { private serializeBudgetLineItem( lineItem: BudgetAmountLineItem, externalNames: Map, - ) { + ): BudgetLineItemResponse { const reference = this.toBudgetEntryReference(lineItem); const serializedLineItem = { amount: lineItem.amount, @@ -1159,24 +1178,24 @@ export class BillingAccountsService { } /** - * Calculates the copilot-safe member-payment capacity for a billing account. + * Calculates a copilot-safe member-payment amount from a billing ledger amount. * - * This mirrors the Work app's existing calculation while keeping the raw - * billing markup on the server. A zero markup means the full remaining budget - * is available for member payments. + * Billing locked and consumed rows store the member payment plus its markup + * fee. This reverses that ledger amount while keeping the raw billing markup + * on the server. A zero markup means the full amount is a member payment. * - * @param totalBudgetRemaining Remaining billing-account budget. + * @param billingAccountAmount Billing ledger amount that includes markup. * @param markup Billing-account markup from persistence. - * @returns Rounded member-payment capacity, or `undefined` when inputs are invalid. + * @returns Rounded member-payment amount, or `undefined` when inputs are invalid. */ - private calculateMemberPaymentsRemaining( - totalBudgetRemaining: unknown, + private calculateMemberPaymentAmount( + billingAccountAmount: unknown, markup: unknown, ): number | undefined { - const remaining = Number(totalBudgetRemaining); + const amount = Number(billingAccountAmount); const rawMarkup = Number(markup); - if (!Number.isFinite(remaining) || !Number.isFinite(rawMarkup)) { + if (!Number.isFinite(amount) || !Number.isFinite(rawMarkup)) { return undefined; } @@ -1187,21 +1206,65 @@ export class BillingAccountsService { } if (normalizedMarkup === 0) { - return Number(remaining.toFixed(2)); + return Number(amount.toFixed(2)); } - return Number((remaining / (1 / normalizedMarkup)).toFixed(2)); + return Number((amount / (1 + normalizedMarkup)).toFixed(2)); } /** - * Removes raw markup from copilot responses and adds a derived safe budget field. + * Calculates the copilot-safe member-payment capacity for a billing account. + * + * @param totalBudgetRemaining Remaining billing-account budget including markup capacity. + * @param markup Billing-account markup from persistence. + * @returns Rounded member-payment capacity, or `undefined` when inputs are invalid. + */ + private calculateMemberPaymentsRemaining( + totalBudgetRemaining: unknown, + markup: unknown, + ): number | undefined { + return this.calculateMemberPaymentAmount(totalBudgetRemaining, markup); + } + + /** + * Adds copilot-safe member-payment display amounts to budget line items. + * + * @param lineItems Serialized locked or consumed line items. + * @param markup Billing-account markup from persistence. + * @returns Line items with `memberPaymentAmount` when it can be calculated. + */ + private serializeLineItemsForCopilot( + lineItems: BudgetLineItemResponse[] | undefined, + markup: unknown, + ): BudgetLineItemResponse[] | undefined { + if (!lineItems) { + return undefined; + } + + return lineItems.map((lineItem) => { + const memberPaymentAmount = this.calculateMemberPaymentAmount( + lineItem.amount, + markup, + ); + + return memberPaymentAmount === undefined + ? lineItem + : { + ...lineItem, + memberPaymentAmount, + }; + }); + } + + /** + * Removes raw markup from copilot responses and adds derived safe budget fields. * * @param billingAccount Billing-account response object after budget totals are available. * @param authUser Authenticated caller context from `req.authUser`. * @returns The original response for privileged callers, or a copilot-safe copy. */ private serializeBillingAccountForAuthUser< - T extends { markup?: unknown; totalBudgetRemaining?: unknown }, + T extends BillingAccountAuthResponse, >(billingAccount: T, authUser?: BillingAccountsAuthUser) { if (!shouldHideMarkupForCopilot(authUser)) { return billingAccount; @@ -1212,11 +1275,30 @@ export class BillingAccountsService { billingAccount.totalBudgetRemaining, markup, ); + const copilotBillingAccount = { + ...sanitizedBillingAccount, + ...(sanitizedBillingAccount.lockedAmounts + ? { + lockedAmounts: this.serializeLineItemsForCopilot( + sanitizedBillingAccount.lockedAmounts, + markup, + ), + } + : {}), + ...(sanitizedBillingAccount.consumedAmounts + ? { + consumedAmounts: this.serializeLineItemsForCopilot( + sanitizedBillingAccount.consumedAmounts, + markup, + ), + } + : {}), + }; return memberPaymentsRemaining === undefined - ? sanitizedBillingAccount + ? copilotBillingAccount : { - ...sanitizedBillingAccount, + ...copilotBillingAccount, memberPaymentsRemaining, }; } From d0e1c39f61bf05c964454269aa93982edac63630 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 27 Apr 2026 16:29:14 +1000 Subject: [PATCH 3/9] Better exposure / hiding of markup --- README.md | 3 ++- .../billing-accounts.service.ts | 22 +++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6c43c6f..c8f2b30 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ Project Managers are restricted to billing accounts granted to their own user id on `GET /billing-accounts` and `GET /billing-accounts/:billingAccountId`. Copilot-only callers receive billing-account responses with `markup` omitted - and `memberPaymentsRemaining` derived server-side. Billing-account detail + and `memberPaymentsRemaining` derived server-side as total remaining minus + the total remaining markup amount. Billing-account detail responses for copilots also include per-line-item `memberPaymentAmount` values that remove the markup fee from locked and consumed amounts. On detail responses, copilot, Project Manager, and Talent diff --git a/src/billing-accounts/billing-accounts.service.ts b/src/billing-accounts/billing-accounts.service.ts index 38a935f..0d28b90 100644 --- a/src/billing-accounts/billing-accounts.service.ts +++ b/src/billing-accounts/billing-accounts.service.ts @@ -1215,7 +1215,10 @@ export class BillingAccountsService { /** * Calculates the copilot-safe member-payment capacity for a billing account. * - * @param totalBudgetRemaining Remaining billing-account budget including markup capacity. + * Remaining capacity applies the markup as a direct reduction from the + * current remaining budget: total remaining - (total remaining * markup). + * + * @param totalBudgetRemaining Current remaining billing-account budget. * @param markup Billing-account markup from persistence. * @returns Rounded member-payment capacity, or `undefined` when inputs are invalid. */ @@ -1223,7 +1226,22 @@ export class BillingAccountsService { totalBudgetRemaining: unknown, markup: unknown, ): number | undefined { - return this.calculateMemberPaymentAmount(totalBudgetRemaining, markup); + const totalRemaining = Number(totalBudgetRemaining); + const rawMarkup = Number(markup); + + if (!Number.isFinite(totalRemaining) || !Number.isFinite(rawMarkup)) { + return undefined; + } + + const normalizedMarkup = rawMarkup > 1 ? rawMarkup / 100 : rawMarkup; + + if (normalizedMarkup < 0) { + return undefined; + } + + return Number( + (totalRemaining - totalRemaining * normalizedMarkup).toFixed(2), + ); } /** From 760120d93ade33a73d985cbaab16e456fc453809 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 28 Apr 2026 09:46:45 +1000 Subject: [PATCH 4/9] PM-4952: Allow project managers to open project billing accounts What was broken Project Managers who could access a Work project could still receive a not found response from GET /billing-accounts/:billingAccountId when the billing account was not present in BillingAccountAccess. Root cause (if identifiable) The billing-account detail endpoint only checked legacy billing-account access grants for restricted Project Manager reads. Work project pages rely on project membership and the project's billingAccountId, so missing imported legacy grants blocked project-level billing-account access. What was changed Added a Projects DB membership fallback for restricted Project Manager detail reads. When no direct billing-account grant exists, the API checks for a non-deleted project with the requested billingAccountId and a non-deleted project_members row for the caller before loading details. Updated Swagger and README authorization documentation to describe the project-membership fallback. Any added/updated tests No automated tests were added because billing-accounts-api-v6 does not define a test script. Validation was performed with pnpm lint and pnpm build; pnpm test reports that the test script is missing. --- README.md | 4 +- .../billing-accounts.controller.ts | 2 +- .../billing-accounts.service.ts | 36 ++++++++--- .../external-budget-entry-lookup.service.ts | 63 ++++++++++++++++++- 4 files changed, 92 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c8f2b30..929a0f3 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ and `Topcoder Talent Manager` JWT roles; read-only billing-account lookups also continue to allow `copilot`, `Project Manager`, and `Topcoder Project Manager`. Project Managers are restricted to billing accounts granted to their own - user id on `GET /billing-accounts` and `GET /billing-accounts/:billingAccountId`. + user id on `GET /billing-accounts`. On `GET /billing-accounts/:billingAccountId`, + they can read billing accounts granted to their own user id or assigned to + projects they belong to. Copilot-only callers receive billing-account responses with `markup` omitted and `memberPaymentsRemaining` derived server-side as total remaining minus the total remaining markup amount. Billing-account detail diff --git a/src/billing-accounts/billing-accounts.controller.ts b/src/billing-accounts/billing-accounts.controller.ts index 3c97974..2e2ca88 100644 --- a/src/billing-accounts/billing-accounts.controller.ts +++ b/src/billing-accounts/billing-accounts.controller.ts @@ -173,7 +173,7 @@ export class BillingAccountsController { buildOperationDoc({ summary: "Get a billing account", description: - "Fetch a billing account by its identifier, including budget, client data, and normalized locked/consumed line items. Line items include amount, date, externalId, externalType, externalName, and challengeId only for legacy challenge compatibility. Project Managers can read only billing accounts granted to them. Copilot, Project Manager, and Talent Manager callers only receive locked/consumed line items for projects they belong to. Copilot-only callers receive copilot-safe budget data without the raw markup field, including memberPaymentsRemaining and per-line-item memberPaymentAmount values.", + "Fetch a billing account by its identifier, including budget, client data, and normalized locked/consumed line items. Line items include amount, date, externalId, externalType, externalName, and challengeId only for legacy challenge compatibility. Project Managers can read billing accounts granted to them or assigned to projects they belong to. Copilot, Project Manager, and Talent Manager callers only receive locked/consumed line items for projects they belong to. Copilot-only callers receive copilot-safe budget data without the raw markup field, including memberPaymentsRemaining and per-line-item memberPaymentAmount values.", jwtRoles: BILLING_ACCOUNT_PROJECT_READ_ROLES, m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA], }), diff --git a/src/billing-accounts/billing-accounts.service.ts b/src/billing-accounts/billing-accounts.service.ts index 0d28b90..bf5ff95 100644 --- a/src/billing-accounts/billing-accounts.service.ts +++ b/src/billing-accounts/billing-accounts.service.ts @@ -187,8 +187,8 @@ function getNormalizedAuthUserId( } /** - * Returns the enforced access-grant user id for restricted Project Manager - * billing-account reads. + * Returns the enforced user id for restricted Project Manager billing-account + * reads. * * `undefined` means the caller keeps unrestricted read behavior. `null` * indicates a restricted Project Manager caller without a usable `userId`, @@ -477,14 +477,15 @@ export class BillingAccountsService { * Fetches a single billing account, its normalized budget line items, and * budget aggregates. * - * Project Manager callers can read only billing accounts granted to their own - * `userId`. Missing access is surfaced as not found to avoid leaking account - * existence. Locked and consumed line items expose `amount`, `date`, - * `externalId`, `externalType`, and `externalName`; challenge rows also expose - * the deprecated `challengeId` compatibility alias. Copilot-only callers also + * Project Manager callers can read billing accounts granted to their own + * `userId`, or billing accounts assigned to a project they belong to. Missing + * access is surfaced as not found to avoid leaking account existence. Locked + * and consumed line items expose `amount`, `date`, `externalId`, + * `externalType`, and `externalName`; challenge rows also expose the + * deprecated `challengeId` compatibility alias. Copilot-only callers also * receive `memberPaymentAmount` on each line item so the UI can show the - * payment value without exposing markup. Copilot, Project Manager, and Talent - * Manager callers only receive line items for projects they belong to; + * payment value without exposing markup. Copilot, Project Manager, and + * Talent Manager callers only receive line items for projects they belong to; * unresolved project access hides the line item. * * @param billingAccountId Billing-account identifier. @@ -502,7 +503,7 @@ export class BillingAccountsService { lockedAmounts: true, consumedAmounts: true, }; - const ba = + let ba = restrictedProjectManagerUserId === undefined ? await this.prisma.billingAccount.findUnique({ where: { id: billingAccountId }, @@ -520,6 +521,21 @@ export class BillingAccountsService { include, }); + if (!ba && restrictedProjectManagerUserId) { + const hasProjectBillingAccountAccess = + await this.externalBudgetEntryLookup.hasProjectBillingAccountAccess( + billingAccountId, + restrictedProjectManagerUserId, + ); + + if (hasProjectBillingAccountAccess) { + ba = await this.prisma.billingAccount.findUnique({ + where: { id: billingAccountId }, + include, + }); + } + } + if (!ba) throw new NotFoundException( `Billing account with ID ${billingAccountId} not found`, diff --git a/src/billing-accounts/external-budget-entry-lookup.service.ts b/src/billing-accounts/external-budget-entry-lookup.service.ts index 96f4c3b..9e1ba33 100644 --- a/src/billing-accounts/external-budget-entry-lookup.service.ts +++ b/src/billing-accounts/external-budget-entry-lookup.service.ts @@ -32,6 +32,10 @@ interface ProjectAccessRow { projectId: string; } +interface ProjectBillingAccountAccessRow { + hasAccess: boolean; +} + /** * Resolves budget-entry external metadata from service-owned persistence stores. * @@ -186,6 +190,63 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { return accessibleReferenceKeys; } + /** + * Checks whether a user belongs to a project using a billing account. + * + * Project Managers may open billing-account details from Work project pages + * even when the legacy billing-account resource grant was not imported for + * that account. This lookup validates access against non-deleted projects + * and non-deleted project membership in projects-api-v6. + * + * @param billingAccountId Topcoder billing-account id from the detail route. + * @param userId Topcoder user id from the authenticated caller. + * @returns `true` when the user has non-deleted project membership for a + * project assigned to the billing account; otherwise `false`. + */ + async hasProjectBillingAccountAccess( + billingAccountId: number, + userId: string, + ): Promise { + const normalizedBillingAccountId = + this.normalizeNumericTextId(billingAccountId); + const normalizedUserId = this.normalizeNumericTextId(userId); + + if (!normalizedBillingAccountId || !normalizedUserId) { + return false; + } + + const client = this.getProjectsClient(); + + if (!client) { + return false; + } + + try { + const rows = await client.$queryRaw( + Prisma.sql` + SELECT true AS "hasAccess" + FROM projects project + INNER JOIN project_members project_member + ON project_member."projectId" = project."id" + WHERE project."billingAccountId" = ${BigInt( + normalizedBillingAccountId, + )} + AND project."deletedAt" IS NULL + AND project_member."userId" = ${BigInt(normalizedUserId)} + AND project_member."deletedAt" IS NULL + LIMIT 1 + `, + ); + + return rows.some((row) => row.hasAccess); + } catch (error) { + this.logger.warn( + `Failed to resolve project billing-account access: ${this.getErrorMessage(error)}`, + ); + return false; + } + } + /** * Disconnects optional lookup clients created for external stores. * @@ -555,7 +616,7 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { this.projectsClient = this.createOptionalClient( process.env.PROJECTS_DB_URL || process.env.PROJECT_DB_URL, "PROJECTS_DB_URL or PROJECT_DB_URL", - "project-access filtering will hide line items whose access cannot be resolved.", + "project-access checks will hide line items and deny project-based billing-account access when access cannot be resolved.", ); return this.projectsClient; From 4c5882dbe926458a27265c297a794d558b28f1ce Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 4 May 2026 10:50:37 +1000 Subject: [PATCH 5/9] PM-4952: Allow PM project billing detail fallback What was broken Project Manager users in Work could see a project billing account name and id, but the detail request used for the spend amount and info modal could still fail when the user did not have a direct BillingAccountAccess grant. Root cause (if identifiable) The earlier fix added a project-membership fallback, but the route did not initially allow plain Topcoder User tokens to reach it. The follow-up then required a management or copilot project-member role for every project-scoped caller, which did not match Projects API behavior for global Project Manager tokens that actively belong to the project. What was changed Allowed Topcoder User tokens through the billing-account detail route while keeping service-level authorization constrained to a direct BillingAccountAccess grant or project fallback. Project Manager and Topcoder Project Manager tokens can now use active membership on a project assigned to the billing account, while plain Topcoder User callers still need a management or copilot project-member role. Project-scoped callers continue to receive line items filtered by active project membership. Authorization docs and Swagger text were updated for the adjusted fallback behavior. Any added/updated tests No automated tests were added because billing-accounts-api-v6 does not define a test script. Validation was performed with pnpm lint and pnpm build; pnpm test reports that the test script is missing. --- README.md | 16 ++- src/auth/constants.ts | 1 + .../billing-accounts.controller.ts | 12 +- .../billing-accounts.service.ts | 109 ++++++++++++------ .../external-budget-entry-lookup.service.ts | 34 +++++- 5 files changed, 124 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 929a0f3..d865207 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - Endpoints: - `GET /billing-accounts` - `POST /billing-accounts` - - `GET /billing-accounts/:billingAccountId` (includes locked/consumed arrays + budget totals; line items expose `amount`, `date`, `externalId`, `externalType`, `externalName`, and `challengeId` only for challenge compatibility; copilot, Project Manager, and Talent Manager callers only receive line items for projects they belong to; copilot-only callers receive `memberPaymentsRemaining` and per-line-item `memberPaymentAmount` instead of raw `markup`) + - `GET /billing-accounts/:billingAccountId` (includes locked/consumed arrays + budget totals; line items expose `amount`, `date`, `externalId`, `externalType`, `externalName`, and `challengeId` only for challenge compatibility; copilot, project-scoped, and Talent Manager callers only receive line items for projects they belong to; copilot-only callers receive `memberPaymentsRemaining` and per-line-item `memberPaymentAmount` instead of raw `markup`) - `GET /billing-accounts/users/:userId` (list billing accounts accessible by the given Topcoder user ID — resolved via Salesforce resource object) - `PATCH /billing-accounts/:billingAccountId` - `PATCH /billing-accounts/:billingAccountId/lock-amount` (challenge-only typed reference; non-negative amount; 0 amount = unlock; rejects insufficient remaining funds) @@ -27,16 +27,20 @@ - Billing-account management endpoints accept `administrator`, `Talent Manager`, and `Topcoder Talent Manager` JWT roles; read-only billing-account lookups also continue to allow `copilot`, `Project Manager`, and `Topcoder Project Manager`. - Project Managers are restricted to billing accounts granted to their own - user id on `GET /billing-accounts`. On `GET /billing-accounts/:billingAccountId`, - they can read billing accounts granted to their own user id or assigned to - projects they belong to. + The detail endpoint additionally accepts `Topcoder User` so project-member + callers from Work can be authorized by project membership. + Project-scoped billing-account readers are restricted to billing accounts + granted to their own user id on `GET /billing-accounts`. On + `GET /billing-accounts/:billingAccountId`, Project Manager callers can read + billing accounts granted to their own user id or assigned to active projects + they belong to. Plain `Topcoder User` project-member callers need a + management or copilot project role for the project fallback. Copilot-only callers receive billing-account responses with `markup` omitted and `memberPaymentsRemaining` derived server-side as total remaining minus the total remaining markup amount. Billing-account detail responses for copilots also include per-line-item `memberPaymentAmount` values that remove the markup fee from locked and consumed amounts. On detail - responses, copilot, Project Manager, and Talent + responses, copilot, project-scoped, and Talent Manager callers only receive locked/consumed line items whose challenge or engagement project maps to an active `project_members` row for their user id. Line items with unresolved project access are omitted for those callers. diff --git a/src/auth/constants.ts b/src/auth/constants.ts index 2edf9ea..9971944 100644 --- a/src/auth/constants.ts +++ b/src/auth/constants.ts @@ -15,5 +15,6 @@ export const ADMIN_ROLE = "administrator"; export const COPILOT_ROLE = "copilot"; export const PROJECT_MANAGER_ROLE = "Project Manager"; export const TOPCODER_PROJECT_MANAGER_ROLE = "Topcoder Project Manager"; +export const TOPCODER_USER_ROLE = "Topcoder User"; export const TALENT_MANAGER_ROLE = "Talent Manager"; export const TOPCODER_TALENT_MANAGER_ROLE = "Topcoder Talent Manager"; diff --git a/src/billing-accounts/billing-accounts.controller.ts b/src/billing-accounts/billing-accounts.controller.ts index 2e2ca88..adc39a2 100644 --- a/src/billing-accounts/billing-accounts.controller.ts +++ b/src/billing-accounts/billing-accounts.controller.ts @@ -30,6 +30,7 @@ import { PROJECT_MANAGER_ROLE, TALENT_MANAGER_ROLE, TOPCODER_PROJECT_MANAGER_ROLE, + TOPCODER_USER_ROLE, TOPCODER_TALENT_MANAGER_ROLE, } from "../auth/constants"; import type { Request } from "express"; @@ -58,6 +59,11 @@ const BILLING_ACCOUNT_PROJECT_READ_ROLES = [ TOPCODER_PROJECT_MANAGER_ROLE, ]; +const BILLING_ACCOUNT_DETAIL_READ_ROLES = [ + ...BILLING_ACCOUNT_PROJECT_READ_ROLES, + TOPCODER_USER_ROLE, +]; + const BILLING_ACCOUNT_MANAGE_ROLES = [ ADMIN_ROLE, TALENT_MANAGER_ROLE, @@ -167,14 +173,14 @@ export class BillingAccountsController { @Get(":billingAccountId") @UseGuards(RolesGuard, ScopesGuard) - @Roles(...BILLING_ACCOUNT_PROJECT_READ_ROLES) + @Roles(...BILLING_ACCOUNT_DETAIL_READ_ROLES) @Scopes(SCOPES.READ_BA, SCOPES.ALL_BA) @ApiOperation( buildOperationDoc({ summary: "Get a billing account", description: - "Fetch a billing account by its identifier, including budget, client data, and normalized locked/consumed line items. Line items include amount, date, externalId, externalType, externalName, and challengeId only for legacy challenge compatibility. Project Managers can read billing accounts granted to them or assigned to projects they belong to. Copilot, Project Manager, and Talent Manager callers only receive locked/consumed line items for projects they belong to. Copilot-only callers receive copilot-safe budget data without the raw markup field, including memberPaymentsRemaining and per-line-item memberPaymentAmount values.", - jwtRoles: BILLING_ACCOUNT_PROJECT_READ_ROLES, + "Fetch a billing account by its identifier, including budget, client data, and normalized locked/consumed line items. Line items include amount, date, externalId, externalType, externalName, and challengeId only for legacy challenge compatibility. Project Managers can read billing accounts granted to them or assigned to projects they actively belong to; plain Topcoder User project members need an allowed project billing-account role. Copilot, Project Manager, Topcoder User, and Talent Manager callers only receive locked/consumed line items for projects they belong to. Copilot-only callers receive copilot-safe budget data without the raw markup field, including memberPaymentsRemaining and per-line-item memberPaymentAmount values.", + jwtRoles: BILLING_ACCOUNT_DETAIL_READ_ROLES, m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA], }), ) diff --git a/src/billing-accounts/billing-accounts.service.ts b/src/billing-accounts/billing-accounts.service.ts index bf5ff95..133a4da 100644 --- a/src/billing-accounts/billing-accounts.service.ts +++ b/src/billing-accounts/billing-accounts.service.ts @@ -29,6 +29,7 @@ import { PROJECT_MANAGER_ROLE, TALENT_MANAGER_ROLE, TOPCODER_PROJECT_MANAGER_ROLE, + TOPCODER_USER_ROLE, TOPCODER_TALENT_MANAGER_ROLE, } from "../auth/constants"; @@ -50,7 +51,13 @@ const UNRESTRICTED_BILLING_ACCOUNT_READ_ROLES = [ TOPCODER_TALENT_MANAGER_ROLE, ]; -const RESTRICTED_PROJECT_MANAGER_READ_ROLES = [ +const PROJECT_SCOPED_BILLING_ACCOUNT_READ_ROLES = [ + PROJECT_MANAGER_ROLE, + TOPCODER_PROJECT_MANAGER_ROLE, + TOPCODER_USER_ROLE, +]; + +const PROJECT_BILLING_ACCOUNT_TOPCODER_DETAIL_ROLES = [ PROJECT_MANAGER_ROLE, TOPCODER_PROJECT_MANAGER_ROLE, ]; @@ -59,6 +66,7 @@ const PROJECT_ACCESS_FILTERED_LINE_ITEM_ROLES = [ COPILOT_ROLE, PROJECT_MANAGER_ROLE, TOPCODER_PROJECT_MANAGER_ROLE, + TOPCODER_USER_ROLE, TALENT_MANAGER_ROLE, TOPCODER_TALENT_MANAGER_ROLE, ]; @@ -187,26 +195,25 @@ function getNormalizedAuthUserId( } /** - * Returns the enforced user id for restricted Project Manager billing-account - * reads. + * Returns the enforced user id for project-scoped billing-account reads. * * `undefined` means the caller keeps unrestricted read behavior. `null` - * indicates a restricted Project Manager caller without a usable `userId`, + * indicates a restricted project-scoped caller without a usable `userId`, * which should be treated as no accessible accounts. * * @param authUser Authenticated caller context from `req.authUser`. * @returns Enforced user id, `null`, or `undefined`. */ -function resolveRestrictedProjectManagerUserId( +function resolveProjectScopedBillingAccountReadUserId( authUser?: BillingAccountsAuthUser, ): string | null | undefined { const normalizedRoles = getNormalizedAuthUserRoles(authUser); - const hasRestrictedProjectManagerRole = - RESTRICTED_PROJECT_MANAGER_READ_ROLES.some((role) => + const hasProjectScopedBillingAccountReadRole = + PROJECT_SCOPED_BILLING_ACCOUNT_READ_ROLES.some((role) => normalizedRoles.includes(role.toLowerCase()), ); - if (!hasRestrictedProjectManagerRole) { + if (!hasProjectScopedBillingAccountReadRole) { return undefined; } @@ -221,13 +228,36 @@ function resolveRestrictedProjectManagerUserId( return getNormalizedAuthUserId(authUser) ?? null; } +/** + * Returns whether project fallback access should enforce a management/copilot + * project member role. + * + * Global Project Manager tokens already satisfy Projects API billing-account + * detail permission. They still need active membership on a project using the + * billing account, but the specific project member role does not need to carry + * the detail permission. Plain `Topcoder User` tokens must keep the stricter + * project-role check. + * + * @param authUser Authenticated caller context from `req.authUser`. + * @returns `true` when the fallback must require an allowed project role. + */ +function shouldRequireProjectBillingAccountRoleForFallback( + authUser?: BillingAccountsAuthUser, +): boolean { + const normalizedRoles = getNormalizedAuthUserRoles(authUser); + + return !PROJECT_BILLING_ACCOUNT_TOPCODER_DETAIL_ROLES.some((role) => + normalizedRoles.includes(role.toLowerCase()), + ); +} + /** * Returns the user id to use for project-level line-item filtering. * - * Administrators keep full line-item visibility. Copilots, Project Managers, - * and Talent Managers only receive line items whose underlying challenge or - * engagement project can be resolved to an active project membership for their - * user id. + * Administrators keep full line-item visibility. Copilots, project-scoped + * billing-account readers, and Talent Managers only receive line items whose + * underlying challenge or engagement project can be resolved to an active + * project membership for their user id. * * @param authUser Authenticated caller context from `req.authUser`. * @returns User id to filter by, `null` when missing, or `undefined` when no @@ -313,7 +343,7 @@ export class BillingAccountsService { /** * Lists billing accounts with optional filtering, sorting, and pagination. * - * Project Manager callers are constrained to billing accounts granted to + * Project-scoped callers are constrained to billing accounts granted to * their own `userId`, regardless of an explicit `userId` query override. * * @param q Query filters and pagination controls. @@ -335,10 +365,10 @@ export class BillingAccountsService { sortBy, sortOrder = "asc", } = q; - const restrictedProjectManagerUserId = - resolveRestrictedProjectManagerUserId(authUser); + const projectScopedBillingAccountReadUserId = + resolveProjectScopedBillingAccountReadUserId(authUser); - if (restrictedProjectManagerUserId === null) { + if (projectScopedBillingAccountReadUserId === null) { return { page, perPage, @@ -353,8 +383,10 @@ export class BillingAccountsService { ...(status ? { status } : {}), }; - if (restrictedProjectManagerUserId) { - where.accessGrants = { some: { userId: restrictedProjectManagerUserId } }; + if (projectScopedBillingAccountReadUserId) { + where.accessGrants = { + some: { userId: projectScopedBillingAccountReadUserId }, + }; } else if (userId) { where.accessGrants = { some: { userId } }; } @@ -477,16 +509,19 @@ export class BillingAccountsService { * Fetches a single billing account, its normalized budget line items, and * budget aggregates. * - * Project Manager callers can read billing accounts granted to their own - * `userId`, or billing accounts assigned to a project they belong to. Missing - * access is surfaced as not found to avoid leaking account existence. Locked - * and consumed line items expose `amount`, `date`, `externalId`, - * `externalType`, and `externalName`; challenge rows also expose the - * deprecated `challengeId` compatibility alias. Copilot-only callers also - * receive `memberPaymentAmount` on each line item so the UI can show the - * payment value without exposing markup. Copilot, Project Manager, and - * Talent Manager callers only receive line items for projects they belong to; - * unresolved project access hides the line item. + * Project-scoped callers can read billing accounts granted to their own + * `userId`, or billing accounts assigned to an active project they belong to. + * Plain `Topcoder User` callers need an allowed management/copilot project + * role for that project fallback, while global Project Manager callers only + * need active project membership. Missing access is surfaced as not found to + * avoid leaking account existence. Locked and consumed line items expose + * `amount`, `date`, `externalId`, `externalType`, and `externalName`; + * challenge rows also expose the deprecated `challengeId` compatibility + * alias. Copilot-only callers also receive `memberPaymentAmount` on each + * line item so the UI can show the payment value without exposing markup. + * Copilot, project-scoped, and Talent Manager callers only receive line + * items for projects they belong to; unresolved project access hides the + * line item. * * @param billingAccountId Billing-account identifier. * @param authUser Authenticated caller context from `req.authUser`. @@ -496,36 +531,40 @@ export class BillingAccountsService { * caller does not have access to it. */ async get(billingAccountId: number, authUser?: BillingAccountsAuthUser) { - const restrictedProjectManagerUserId = - resolveRestrictedProjectManagerUserId(authUser); + const projectScopedBillingAccountReadUserId = + resolveProjectScopedBillingAccountReadUserId(authUser); const include = { client: true, lockedAmounts: true, consumedAmounts: true, }; let ba = - restrictedProjectManagerUserId === undefined + projectScopedBillingAccountReadUserId === undefined ? await this.prisma.billingAccount.findUnique({ where: { id: billingAccountId }, include, }) - : restrictedProjectManagerUserId === null + : projectScopedBillingAccountReadUserId === null ? null : await this.prisma.billingAccount.findFirst({ where: { id: billingAccountId, accessGrants: { - some: { userId: restrictedProjectManagerUserId }, + some: { userId: projectScopedBillingAccountReadUserId }, }, }, include, }); - if (!ba && restrictedProjectManagerUserId) { + if (!ba && projectScopedBillingAccountReadUserId) { const hasProjectBillingAccountAccess = await this.externalBudgetEntryLookup.hasProjectBillingAccountAccess( billingAccountId, - restrictedProjectManagerUserId, + projectScopedBillingAccountReadUserId, + { + requireAllowedProjectRole: + shouldRequireProjectBillingAccountRoleForFallback(authUser), + }, ); if (hasProjectBillingAccountAccess) { diff --git a/src/billing-accounts/external-budget-entry-lookup.service.ts b/src/billing-accounts/external-budget-entry-lookup.service.ts index 9e1ba33..4989835 100644 --- a/src/billing-accounts/external-budget-entry-lookup.service.ts +++ b/src/billing-accounts/external-budget-entry-lookup.service.ts @@ -36,6 +36,20 @@ interface ProjectBillingAccountAccessRow { hasAccess: boolean; } +interface ProjectBillingAccountAccessOptions { + requireAllowedProjectRole?: boolean; +} + +const PROJECT_BILLING_ACCOUNT_DETAIL_ROLES = [ + "manager", + "account_manager", + "account_executive", + "project_manager", + "program_manager", + "solution_architect", + "copilot", +]; + /** * Resolves budget-entry external metadata from service-owned persistence stores. * @@ -193,19 +207,23 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { /** * Checks whether a user belongs to a project using a billing account. * - * Project Managers may open billing-account details from Work project pages - * even when the legacy billing-account resource grant was not imported for - * that account. This lookup validates access against non-deleted projects - * and non-deleted project membership in projects-api-v6. + * Project-scoped Work users may open billing-account details from project + * pages even when the legacy billing-account resource grant was not imported + * for that account. This lookup validates access against non-deleted + * projects and non-deleted project membership in projects-api-v6. Callers + * without a billing-account Topcoder role can require the membership to be a + * management/copilot project role. * * @param billingAccountId Topcoder billing-account id from the detail route. * @param userId Topcoder user id from the authenticated caller. + * @param options Access options for project-role enforcement. * @returns `true` when the user has non-deleted project membership for a * project assigned to the billing account; otherwise `false`. */ async hasProjectBillingAccountAccess( billingAccountId: number, userId: string, + options: ProjectBillingAccountAccessOptions = {}, ): Promise { const normalizedBillingAccountId = this.normalizeNumericTextId(billingAccountId); @@ -221,6 +239,13 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { return false; } + const projectRoleFilter = + options.requireAllowedProjectRole === false + ? Prisma.empty + : Prisma.sql`AND project_member."role"::text IN (${Prisma.join( + PROJECT_BILLING_ACCOUNT_DETAIL_ROLES, + )})`; + try { const rows = await client.$queryRaw( Prisma.sql` @@ -233,6 +258,7 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { )} AND project."deletedAt" IS NULL AND project_member."userId" = ${BigInt(normalizedUserId)} + ${projectRoleFilter} AND project_member."deletedAt" IS NULL LIMIT 1 `, From b0fd840bb52648e8a2c964942cd26b7a960f93a1 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 4 May 2026 14:07:05 +1000 Subject: [PATCH 6/9] PM-4952: Allow PM billing detail by project assignment What was broken A Project Manager token could still get 404 from GET /billing-accounts/:billingAccountId for a billing account that exists and is assigned to a Work project. Administrators could read the same billing account. Root cause (if identifiable) The previous billing-account fallback still required project membership for global Project Manager callers. Projects API grants billing-account detail access to the global Project Manager role by project assignment, so billing-accounts-api-v6 was stricter than the project permission model. What was changed Global Project Manager and Topcoder Project Manager callers can now use a non-deleted project assignment for billing-account detail fallback access when there is no direct BillingAccountAccess grant. Plain Topcoder User callers still need active project membership with an allowed management or copilot project role. Authorization documentation was updated to match the behavior. Any added/updated tests No automated tests were added because billing-accounts-api-v6 does not define a test script. Validation was performed with pnpm lint and pnpm build; pnpm test reports that the test script is missing. --- README.md | 4 +- .../billing-accounts.controller.ts | 2 +- .../billing-accounts.service.ts | 52 +++++++++----- .../external-budget-entry-lookup.service.ts | 68 ++++++++++++++++--- 4 files changed, 98 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index d865207..60e5340 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ Project-scoped billing-account readers are restricted to billing accounts granted to their own user id on `GET /billing-accounts`. On `GET /billing-accounts/:billingAccountId`, Project Manager callers can read - billing accounts granted to their own user id or assigned to active projects - they belong to. Plain `Topcoder User` project-member callers need a + billing accounts granted to their own user id or assigned to non-deleted + projects. Plain `Topcoder User` project-member callers need a management or copilot project role for the project fallback. Copilot-only callers receive billing-account responses with `markup` omitted and `memberPaymentsRemaining` derived server-side as total remaining minus diff --git a/src/billing-accounts/billing-accounts.controller.ts b/src/billing-accounts/billing-accounts.controller.ts index adc39a2..d042d75 100644 --- a/src/billing-accounts/billing-accounts.controller.ts +++ b/src/billing-accounts/billing-accounts.controller.ts @@ -179,7 +179,7 @@ export class BillingAccountsController { buildOperationDoc({ summary: "Get a billing account", description: - "Fetch a billing account by its identifier, including budget, client data, and normalized locked/consumed line items. Line items include amount, date, externalId, externalType, externalName, and challengeId only for legacy challenge compatibility. Project Managers can read billing accounts granted to them or assigned to projects they actively belong to; plain Topcoder User project members need an allowed project billing-account role. Copilot, Project Manager, Topcoder User, and Talent Manager callers only receive locked/consumed line items for projects they belong to. Copilot-only callers receive copilot-safe budget data without the raw markup field, including memberPaymentsRemaining and per-line-item memberPaymentAmount values.", + "Fetch a billing account by its identifier, including budget, client data, and normalized locked/consumed line items. Line items include amount, date, externalId, externalType, externalName, and challengeId only for legacy challenge compatibility. Project Managers can read billing accounts granted to them or assigned to non-deleted projects; plain Topcoder User project members need an allowed project billing-account role. Copilot, Project Manager, Topcoder User, and Talent Manager callers only receive locked/consumed line items for projects they belong to. Copilot-only callers receive copilot-safe budget data without the raw markup field, including memberPaymentsRemaining and per-line-item memberPaymentAmount values.", jwtRoles: BILLING_ACCOUNT_DETAIL_READ_ROLES, m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA], }), diff --git a/src/billing-accounts/billing-accounts.service.ts b/src/billing-accounts/billing-accounts.service.ts index 133a4da..595c138 100644 --- a/src/billing-accounts/billing-accounts.service.ts +++ b/src/billing-accounts/billing-accounts.service.ts @@ -229,28 +229,39 @@ function resolveProjectScopedBillingAccountReadUserId( } /** - * Returns whether project fallback access should enforce a management/copilot - * project member role. + * Returns whether the caller has a Topcoder role that can read project billing + * account details in Projects API. * - * Global Project Manager tokens already satisfy Projects API billing-account - * detail permission. They still need active membership on a project using the - * billing account, but the specific project member role does not need to carry - * the detail permission. Plain `Topcoder User` tokens must keep the stricter - * project-role check. + * These global roles can read a project billing account when the account is + * assigned to a non-deleted project. Plain `Topcoder User` project members + * must keep the stricter project membership role check. * * @param authUser Authenticated caller context from `req.authUser`. - * @returns `true` when the fallback must require an allowed project role. + * @returns `true` when the caller can use project-assigned billing-account access. */ -function shouldRequireProjectBillingAccountRoleForFallback( +function hasProjectBillingAccountTopcoderDetailRole( authUser?: BillingAccountsAuthUser, ): boolean { const normalizedRoles = getNormalizedAuthUserRoles(authUser); - return !PROJECT_BILLING_ACCOUNT_TOPCODER_DETAIL_ROLES.some((role) => + return PROJECT_BILLING_ACCOUNT_TOPCODER_DETAIL_ROLES.some((role) => normalizedRoles.includes(role.toLowerCase()), ); } +/** + * Returns whether project fallback access should enforce a management/copilot + * project member role. + * + * @param authUser Authenticated caller context from `req.authUser`. + * @returns `true` when the fallback must require an allowed project role. + */ +function shouldRequireProjectBillingAccountRoleForFallback( + authUser?: BillingAccountsAuthUser, +): boolean { + return !hasProjectBillingAccountTopcoderDetailRole(authUser); +} + /** * Returns the user id to use for project-level line-item filtering. * @@ -510,11 +521,11 @@ export class BillingAccountsService { * budget aggregates. * * Project-scoped callers can read billing accounts granted to their own - * `userId`, or billing accounts assigned to an active project they belong to. - * Plain `Topcoder User` callers need an allowed management/copilot project - * role for that project fallback, while global Project Manager callers only - * need active project membership. Missing access is surfaced as not found to - * avoid leaking account existence. Locked and consumed line items expose + * `userId`, or billing accounts assigned to a non-deleted project. Plain + * `Topcoder User` callers need an allowed management/copilot project role + * for that project fallback, while global Project Manager callers can use + * the project assignment directly. Missing access is surfaced as not found + * to avoid leaking account existence. Locked and consumed line items expose * `amount`, `date`, `externalId`, `externalType`, and `externalName`; * challenge rows also expose the deprecated `challengeId` compatibility * alias. Copilot-only callers also receive `memberPaymentAmount` on each @@ -533,6 +544,8 @@ export class BillingAccountsService { async get(billingAccountId: number, authUser?: BillingAccountsAuthUser) { const projectScopedBillingAccountReadUserId = resolveProjectScopedBillingAccountReadUserId(authUser); + const hasTopcoderProjectBillingDetailRole = + hasProjectBillingAccountTopcoderDetailRole(authUser); const include = { client: true, lockedAmounts: true, @@ -556,12 +569,17 @@ export class BillingAccountsService { include, }); - if (!ba && projectScopedBillingAccountReadUserId) { + if ( + !ba && + (projectScopedBillingAccountReadUserId || + hasTopcoderProjectBillingDetailRole) + ) { const hasProjectBillingAccountAccess = await this.externalBudgetEntryLookup.hasProjectBillingAccountAccess( billingAccountId, - projectScopedBillingAccountReadUserId, + projectScopedBillingAccountReadUserId ?? undefined, { + allowAnyAssignedProject: hasTopcoderProjectBillingDetailRole, requireAllowedProjectRole: shouldRequireProjectBillingAccountRoleForFallback(authUser), }, diff --git a/src/billing-accounts/external-budget-entry-lookup.service.ts b/src/billing-accounts/external-budget-entry-lookup.service.ts index 4989835..a0caa93 100644 --- a/src/billing-accounts/external-budget-entry-lookup.service.ts +++ b/src/billing-accounts/external-budget-entry-lookup.service.ts @@ -37,6 +37,7 @@ interface ProjectBillingAccountAccessRow { } interface ProjectBillingAccountAccessOptions { + allowAnyAssignedProject?: boolean; requireAllowedProjectRole?: boolean; } @@ -210,26 +211,34 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { * Project-scoped Work users may open billing-account details from project * pages even when the legacy billing-account resource grant was not imported * for that account. This lookup validates access against non-deleted - * projects and non-deleted project membership in projects-api-v6. Callers - * without a billing-account Topcoder role can require the membership to be a - * management/copilot project role. + * projects in projects-api-v6. Global billing-account Topcoder roles can + * allow any non-deleted project assigned to the account, while plain project + * members require non-deleted membership. Callers without a billing-account + * Topcoder role can require the membership to be a management/copilot + * project role. * * @param billingAccountId Topcoder billing-account id from the detail route. - * @param userId Topcoder user id from the authenticated caller. + * @param userId Topcoder user id from the authenticated caller; optional + * when global role access allows any assigned project. * @param options Access options for project-role enforcement. * @returns `true` when the user has non-deleted project membership for a - * project assigned to the billing account; otherwise `false`. + * project assigned to the billing account, or when any assigned project is + * allowed and exists; otherwise `false`. */ async hasProjectBillingAccountAccess( billingAccountId: number, - userId: string, + userId?: string, options: ProjectBillingAccountAccessOptions = {}, ): Promise { const normalizedBillingAccountId = this.normalizeNumericTextId(billingAccountId); const normalizedUserId = this.normalizeNumericTextId(userId); + const allowAnyAssignedProject = options.allowAnyAssignedProject === true; - if (!normalizedBillingAccountId || !normalizedUserId) { + if ( + !normalizedBillingAccountId || + (!allowAnyAssignedProject && !normalizedUserId) + ) { return false; } @@ -239,6 +248,18 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { return false; } + if (allowAnyAssignedProject) { + return this.hasBillingAccountAssignedProject( + client, + normalizedBillingAccountId, + ); + } + + const membershipUserId = normalizedUserId; + if (!membershipUserId) { + return false; + } + const projectRoleFilter = options.requireAllowedProjectRole === false ? Prisma.empty @@ -257,7 +278,7 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { normalizedBillingAccountId, )} AND project."deletedAt" IS NULL - AND project_member."userId" = ${BigInt(normalizedUserId)} + AND project_member."userId" = ${BigInt(membershipUserId)} ${projectRoleFilter} AND project_member."deletedAt" IS NULL LIMIT 1 @@ -273,6 +294,37 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { } } + /** + * Checks whether a billing account is assigned to any non-deleted project. + * + * @param client Projects API Prisma client. + * @param billingAccountId Normalized numeric billing-account id. + * @returns `true` when at least one non-deleted project uses the account. + */ + private async hasBillingAccountAssignedProject( + client: PrismaClient, + billingAccountId: string, + ): Promise { + try { + const rows = await client.$queryRaw( + Prisma.sql` + SELECT true AS "hasAccess" + FROM projects project + WHERE project."billingAccountId" = ${BigInt(billingAccountId)} + AND project."deletedAt" IS NULL + LIMIT 1 + `, + ); + + return rows.some((row) => row.hasAccess); + } catch (error) { + this.logger.warn( + `Failed to resolve project billing-account assignment: ${this.getErrorMessage(error)}`, + ); + return false; + } + } + /** * Disconnects optional lookup clients created for external stores. * From 16802410d9624ddde58f7127f4883557617c5986 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 4 May 2026 15:45:06 +1000 Subject: [PATCH 7/9] Project manager tweak --- README.md | 3 + .../external-budget-entry-lookup.service.ts | 170 ++++++++++++------ 2 files changed, 118 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 60e5340..f65d143 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ Manager callers only receive locked/consumed line items whose challenge or engagement project maps to an active `project_members` row for their user id. Line items with unresolved project access are omitted for those callers. + Projects DB access checks try both the configured connection search path and + the explicit `projects` schema so deployments do not need to rely on a + schema-qualified `PROJECTS_DB_URL`. Role checks are case-insensitive so mixed token casing does not block access. - Configure env: `AUTH_SECRET` or `AUTH0_URL/AUDIENCE/ISSUER` as needed. diff --git a/src/billing-accounts/external-budget-entry-lookup.service.ts b/src/billing-accounts/external-budget-entry-lookup.service.ts index a0caa93..4ac03ab 100644 --- a/src/billing-accounts/external-budget-entry-lookup.service.ts +++ b/src/billing-accounts/external-budget-entry-lookup.service.ts @@ -41,6 +41,12 @@ interface ProjectBillingAccountAccessOptions { requireAllowedProjectRole?: boolean; } +interface ProjectLookupTarget { + label: string; + projectMembersTable: Prisma.Sql; + projectsTable: Prisma.Sql; +} + const PROJECT_BILLING_ACCOUNT_DETAIL_ROLES = [ "manager", "account_manager", @@ -51,6 +57,19 @@ const PROJECT_BILLING_ACCOUNT_DETAIL_ROLES = [ "copilot", ]; +const PROJECT_LOOKUP_TARGETS: ProjectLookupTarget[] = [ + { + label: "configured projects search path", + projectMembersTable: Prisma.sql`project_members`, + projectsTable: Prisma.sql`projects`, + }, + { + label: "projects schema", + projectMembersTable: Prisma.sql`projects.project_members`, + projectsTable: Prisma.sql`projects.projects`, + }, +]; + /** * Resolves budget-entry external metadata from service-owned persistence stores. * @@ -58,7 +77,9 @@ const PROJECT_BILLING_ACCOUNT_DETAIL_ROLES = [ * not make one network/database call per line item. It resolves display names * for response rows and project access for role-filtered line-item visibility. * Missing DB URLs or missing referenced rows produce empty lookup mappings - * rather than failing the billing account response. + * rather than failing the billing account response. Projects API lookups try + * both the configured connection search path and the explicit `projects` + * schema, because deployment URLs are not always schema-qualified. */ @Injectable() export class ExternalBudgetEntryLookupService implements OnModuleDestroy { @@ -215,7 +236,8 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { * allow any non-deleted project assigned to the account, while plain project * members require non-deleted membership. Callers without a billing-account * Topcoder role can require the membership to be a management/copilot - * project role. + * project role. The Projects DB table lookup is tolerant of URLs with or + * without a `schema=projects` search path. * * @param billingAccountId Topcoder billing-account id from the detail route. * @param userId Topcoder user id from the authenticated caller; optional @@ -267,31 +289,44 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { PROJECT_BILLING_ACCOUNT_DETAIL_ROLES, )})`; - try { - const rows = await client.$queryRaw( - Prisma.sql` - SELECT true AS "hasAccess" - FROM projects project - INNER JOIN project_members project_member - ON project_member."projectId" = project."id" - WHERE project."billingAccountId" = ${BigInt( - normalizedBillingAccountId, - )} - AND project."deletedAt" IS NULL - AND project_member."userId" = ${BigInt(membershipUserId)} - ${projectRoleFilter} - AND project_member."deletedAt" IS NULL - LIMIT 1 - `, - ); + const errors: string[] = []; + let successfulLookups = 0; - return rows.some((row) => row.hasAccess); - } catch (error) { + for (const target of PROJECT_LOOKUP_TARGETS) { + try { + const rows = await client.$queryRaw( + Prisma.sql` + SELECT true AS "hasAccess" + FROM ${target.projectsTable} project + INNER JOIN ${target.projectMembersTable} project_member + ON project_member."projectId" = project."id" + WHERE project."billingAccountId" = ${BigInt( + normalizedBillingAccountId, + )} + AND project."deletedAt" IS NULL + AND project_member."userId" = ${BigInt(membershipUserId)} + ${projectRoleFilter} + AND project_member."deletedAt" IS NULL + LIMIT 1 + `, + ); + successfulLookups += 1; + + if (rows.some((row) => row.hasAccess)) { + return true; + } + } catch (error) { + errors.push(`${target.label}: ${this.getErrorMessage(error)}`); + } + } + + if (successfulLookups === 0 && errors.length > 0) { this.logger.warn( - `Failed to resolve project billing-account access: ${this.getErrorMessage(error)}`, + `Failed to resolve project billing-account access: ${errors.join("; ")}`, ); - return false; } + + return false; } /** @@ -305,24 +340,37 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { client: PrismaClient, billingAccountId: string, ): Promise { - try { - const rows = await client.$queryRaw( - Prisma.sql` - SELECT true AS "hasAccess" - FROM projects project - WHERE project."billingAccountId" = ${BigInt(billingAccountId)} - AND project."deletedAt" IS NULL - LIMIT 1 - `, - ); + const errors: string[] = []; + let successfulLookups = 0; - return rows.some((row) => row.hasAccess); - } catch (error) { + for (const target of PROJECT_LOOKUP_TARGETS) { + try { + const rows = await client.$queryRaw( + Prisma.sql` + SELECT true AS "hasAccess" + FROM ${target.projectsTable} project + WHERE project."billingAccountId" = ${BigInt(billingAccountId)} + AND project."deletedAt" IS NULL + LIMIT 1 + `, + ); + successfulLookups += 1; + + if (rows.some((row) => row.hasAccess)) { + return true; + } + } catch (error) { + errors.push(`${target.label}: ${this.getErrorMessage(error)}`); + } + } + + if (successfulLookups === 0 && errors.length > 0) { this.logger.warn( - `Failed to resolve project billing-account assignment: ${this.getErrorMessage(error)}`, + `Failed to resolve project billing-account assignment: ${errors.join("; ")}`, ); - return false; } + + return false; } /** @@ -552,6 +600,8 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { * @param userId Normalized numeric Topcoder user id. * @param projectIds Candidate project ids from line-item references. * @returns Set of candidate project ids with an active project member row. + * The lookup checks both the configured search path and the explicit + * `projects` schema. */ private async getAccessibleProjectIdsForUser( userId: string, @@ -576,29 +626,39 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { return result; } - try { - const rows = await client.$queryRaw( - Prisma.sql` - SELECT DISTINCT "projectId"::text AS "projectId" - FROM project_members - WHERE "userId" = ${BigInt(userId)} - AND "projectId" IN (${Prisma.join( - uniqueProjectIds.map((projectId) => BigInt(projectId)), - )}) - AND "deletedAt" IS NULL - `, - ); + const errors: string[] = []; + let successfulLookups = 0; - for (const row of rows) { - const projectId = this.normalizeNumericTextId(row.projectId); + for (const target of PROJECT_LOOKUP_TARGETS) { + try { + const rows = await client.$queryRaw( + Prisma.sql` + SELECT DISTINCT "projectId"::text AS "projectId" + FROM ${target.projectMembersTable} + WHERE "userId" = ${BigInt(userId)} + AND "projectId" IN (${Prisma.join( + uniqueProjectIds.map((projectId) => BigInt(projectId)), + )}) + AND "deletedAt" IS NULL + `, + ); + successfulLookups += 1; - if (projectId) { - result.add(projectId); + for (const row of rows) { + const projectId = this.normalizeNumericTextId(row.projectId); + + if (projectId) { + result.add(projectId); + } } + } catch (error) { + errors.push(`${target.label}: ${this.getErrorMessage(error)}`); } - } catch (error) { + } + + if (successfulLookups === 0 && errors.length > 0) { this.logger.warn( - `Failed to resolve project access for billing-account entries: ${this.getErrorMessage(error)}`, + `Failed to resolve project access for billing-account entries: ${errors.join("; ")}`, ); } From 65bb8b7dd19366fe2368628c0704f8d0b99c3db9 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 4 May 2026 16:00:39 +1000 Subject: [PATCH 8/9] Help with project manager access to BAs --- README.md | 4 ++- .../external-budget-entry-lookup.service.ts | 26 +++++++++---------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index f65d143..a10074e 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,9 @@ Line items with unresolved project access are omitted for those callers. Projects DB access checks try both the configured connection search path and the explicit `projects` schema so deployments do not need to rely on a - schema-qualified `PROJECTS_DB_URL`. + schema-qualified `PROJECTS_DB_URL`. Those checks compare ids as normalized + text so legacy varchar project columns and newer numeric project columns both + work. Role checks are case-insensitive so mixed token casing does not block access. - Configure env: `AUTH_SECRET` or `AUTH0_URL/AUDIENCE/ISSUER` as needed. diff --git a/src/billing-accounts/external-budget-entry-lookup.service.ts b/src/billing-accounts/external-budget-entry-lookup.service.ts index 4ac03ab..c3878b3 100644 --- a/src/billing-accounts/external-budget-entry-lookup.service.ts +++ b/src/billing-accounts/external-budget-entry-lookup.service.ts @@ -79,7 +79,9 @@ const PROJECT_LOOKUP_TARGETS: ProjectLookupTarget[] = [ * Missing DB URLs or missing referenced rows produce empty lookup mappings * rather than failing the billing account response. Projects API lookups try * both the configured connection search path and the explicit `projects` - * schema, because deployment URLs are not always schema-qualified. + * schema, because deployment URLs are not always schema-qualified. Project + * ids are compared as normalized text so legacy varchar columns and newer + * numeric columns both work. */ @Injectable() export class ExternalBudgetEntryLookupService implements OnModuleDestroy { @@ -237,7 +239,8 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { * members require non-deleted membership. Callers without a billing-account * Topcoder role can require the membership to be a management/copilot * project role. The Projects DB table lookup is tolerant of URLs with or - * without a `schema=projects` search path. + * without a `schema=projects` search path, and of project id columns stored + * as either numeric or varchar values. * * @param billingAccountId Topcoder billing-account id from the detail route. * @param userId Topcoder user id from the authenticated caller; optional @@ -299,12 +302,10 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { SELECT true AS "hasAccess" FROM ${target.projectsTable} project INNER JOIN ${target.projectMembersTable} project_member - ON project_member."projectId" = project."id" - WHERE project."billingAccountId" = ${BigInt( - normalizedBillingAccountId, - )} + ON project_member."projectId"::text = project."id"::text + WHERE project."billingAccountId"::text = ${normalizedBillingAccountId} AND project."deletedAt" IS NULL - AND project_member."userId" = ${BigInt(membershipUserId)} + AND project_member."userId"::text = ${membershipUserId} ${projectRoleFilter} AND project_member."deletedAt" IS NULL LIMIT 1 @@ -349,7 +350,7 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { Prisma.sql` SELECT true AS "hasAccess" FROM ${target.projectsTable} project - WHERE project."billingAccountId" = ${BigInt(billingAccountId)} + WHERE project."billingAccountId"::text = ${billingAccountId} AND project."deletedAt" IS NULL LIMIT 1 `, @@ -601,7 +602,8 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { * @param projectIds Candidate project ids from line-item references. * @returns Set of candidate project ids with an active project member row. * The lookup checks both the configured search path and the explicit - * `projects` schema. + * `projects` schema. Id comparisons are text-normalized so both legacy + * varchar columns and newer numeric columns work. */ private async getAccessibleProjectIdsForUser( userId: string, @@ -635,10 +637,8 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { Prisma.sql` SELECT DISTINCT "projectId"::text AS "projectId" FROM ${target.projectMembersTable} - WHERE "userId" = ${BigInt(userId)} - AND "projectId" IN (${Prisma.join( - uniqueProjectIds.map((projectId) => BigInt(projectId)), - )}) + WHERE "userId"::text = ${userId} + AND "projectId"::text IN (${Prisma.join(uniqueProjectIds)}) AND "deletedAt" IS NULL `, ); From 735e688765d0894eb007a604a706b19436ac9b51 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Mon, 4 May 2026 10:21:17 +0300 Subject: [PATCH 9/9] Delete .github/workflows/code_reviewer.yml --- .github/workflows/code_reviewer.yml | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 .github/workflows/code_reviewer.yml diff --git a/.github/workflows/code_reviewer.yml b/.github/workflows/code_reviewer.yml deleted file mode 100644 index 82c7862..0000000 --- a/.github/workflows/code_reviewer.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: AI PR Reviewer - -on: - pull_request: - types: - - opened - - synchronize -permissions: - pull-requests: write -jobs: - tc-ai-pr-review: - runs-on: ubuntu-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v3 - - - name: TC AI PR Reviewer - uses: topcoder-platform/tc-ai-pr-reviewer@master - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # The GITHUB_TOKEN is there by default so you just need to keep it like it is and not necessarily need to add it as secret as it will throw an error. [More Details](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#about-the-github_token-secret) - LAB45_API_KEY: ${{ secrets.LAB45_API_KEY }} - exclude: '**/*.json, **/*.md, **/*.jpg, **/*.png, **/*.jpeg, **/*.bmp, **/*.webp' # Optional: exclude patterns separated by commas \ No newline at end of file