From 2fd1c35a55fb56ca5922e12bc0651c1274ed23b8 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 16 Sep 2026 10:38:14 +1000 Subject: [PATCH 1/2] PM-6343 Add read-only Salesforce sales reports for Sales and WIN --- README.md | 7 + SALES.md | 95 +++++ src/app-constants.ts | 1 + src/app.module.ts | 2 + src/reports/sales/sales-reports.controller.ts | 72 ++++ src/reports/sales/sales-reports.dto.ts | 126 ++++++ src/reports/sales/sales-reports.guard.ts | 57 +++ src/reports/sales/sales-reports.module.ts | 12 + src/reports/sales/sales-reports.service.ts | 354 +++++++++++++++++ src/reports/sales/sales-reports.spec.ts | 366 ++++++++++++++++++ .../sales/salesforce-reports.client.spec.ts | 105 +++++ .../sales/salesforce-reports.client.ts | 260 +++++++++++++ 12 files changed, 1457 insertions(+) create mode 100644 SALES.md create mode 100644 src/reports/sales/sales-reports.controller.ts create mode 100644 src/reports/sales/sales-reports.dto.ts create mode 100644 src/reports/sales/sales-reports.guard.ts create mode 100644 src/reports/sales/sales-reports.module.ts create mode 100644 src/reports/sales/sales-reports.service.ts create mode 100644 src/reports/sales/sales-reports.spec.ts create mode 100644 src/reports/sales/salesforce-reports.client.spec.ts create mode 100644 src/reports/sales/salesforce-reports.client.ts diff --git a/README.md b/README.md index 8d7af7d..75df5ee 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,13 @@ Dashboard figures use these shared definitions: Human access is limited to Administrator and Talent Manager roles. Machine tokens require the `reports:all` scope. +## Salesforce Sales report + +The read-only Sales UI and machine-only WIN sales integration dynamically run a +saved Salesforce report. See [SALES.md](SALES.md) for the two endpoints, dedicated +`reports:sales` scope, server credentials, response schema, refresh behavior, +and Salesforce completeness limits. + ## Security Currently, an M2M token is required to pull any report, and each report has its own scope associated with it that must be applied to the M2M token client ID diff --git a/SALES.md b/SALES.md new file mode 100644 index 0000000..c8725ca --- /dev/null +++ b/SALES.md @@ -0,0 +1,95 @@ +# Sales and WIN integration (PM-6343) + +Salesforce report `00O1K00000A7UGDUA3` is the source of truth. This module executes +the saved report with `includeDetails=true` and derives every column from report +metadata. It never creates, updates or deletes Salesforce records or reports. + +## Authentication + +- `GET /v6/reports/sales`: authenticated human Administrator or Talent Manager. +- `GET /v6/reports/win/sales`: **machine token with `reports:sales`**. Human + administrator tokens and `reports:all` alone do not grant access. +- Register `reports:sales` on the identity provider's API resource and grant it + to the WIN client before requesting a client-credentials token. Never place a + WIN machine credential or Salesforce secret in the browser. + +Both routes use the existing JWT authentication middleware, then independent +role/scope checks. Responses use `Cache-Control: private, no-store`. + +## Server configuration + +| Environment variable | Value | +| --- | --- | +| `SALESFORCE_API_CONSUMER_KEY` | Required connected-app consumer key, injected from secret storage | +| `SALESFORCE_API_CONSUMER_SECRET` | Required connected-app consumer secret, injected from secret storage | +| `SALESFORCE_LOGIN_URL` | Default `https://topcoder.my.salesforce.com` | +| `SALESFORCE_API_VERSION` | Default `65.0`, without the `v` prefix | +| `SALESFORCE_SALES_REPORT_ID` | Default `00O1K00000A7UGDUA3` | + +Enable the connected app's OAuth Client Credentials Flow and configure a **Run +As user** with API access, permission to run reports, report-folder access, and +access to the report's underlying objects/fields. The OAuth error `no client +credentials user enabled` means this Run As setup is missing. The app starts +without these settings, but Sales requests return 503 until configured. + +For the current dev deployment convention, inject the variables from secure SSM +parameters under `/config/reports-api-v6/appvar/`. Do not commit real values. + +## Query and response contract + +Both endpoints accept the same query parameters: + +| Parameter | Meaning | +| --- | --- | +| `page`, `perPage` | One-based page (default 1); page size 1–200 (default 25) | +| `search` | Case-insensitive substring across all displayed cells, up to 200 characters | +| `filterColumn`, `filterValue` | Column ID and case-insensitive displayed-value substring; supply both | +| `sortBy`, `sortOrder` | Column ID and `asc`/`desc`; numeric and ISO date values sort before pagination | +| `refresh` | `true` to refresh, subject to the five-second minimum interval; default `false` | + +Response fields: `reportId`, `reportName`, `columns[{id,label,dataType}]`, +`rows[{id,cells:[{label,value,currencyCode?}]}]`, `allData`, `sourceRowCount`, +`total`, `page`, `perPage`, `totalPages`, `refreshedAt`, `refreshAfterSeconds`. +Cells follow column order. Labels are plain text, never HTML. Currency values +retain their amount and currency code. Null values are preserved. Row IDs are +snapshot-local fact-map keys, not durable Salesforce record identifiers. +Grouping-only fields, including Stage in Bookings By Stage, precede the detail +columns and support the same filtering and sorting. Lookup names sort by their +displayed labels, while dates and currency amounts use underlying typed values. +HTML formulas are projected to text; Forecast Alert uses its image's alt label +without fetching a protected Salesforce image. + +Filtering and sorting operate over the complete **received snapshot**, before +pagination. `total` is the matching received-row count; `sourceRowCount` is its +unfiltered count. Out-of-range pages clamp to the final available page. Empty +reports return zero rows and `totalPages: 0`, `page: 1`. + +Salesforce Analytics limits detail responses to 2,000 rows. `allData: false` +explicitly flags an incomplete upstream snapshot; the UI warns that search, +filtering and counts apply only to returned rows. It must never be treated as a +complete export by WIN. Refine the saved Salesforce report if the limit is hit; +this API does not replace report semantics with a guessed SOQL query. Joined +reports and reports without details are rejected. See Salesforce's +[Reports API limits](https://help.salesforce.com/s/articleView?id=rd_reports_dashboards_limits.htm&language=en_US&type=5) +and [report execution contract](https://developer.salesforce.com/docs/analytics/salesforce-analytics-rest-api/guide/sforce-analytics-rest-api-getreportrundata.html). + +## Freshness, failures and extension + +One in-memory snapshot per service instance lasts 60 seconds. Concurrent reads +share an in-flight request; manual refresh has a five-second cooldown. No report +data is persisted. A failed refresh returns an error, with a five-second retry +cooldown, and never changes the last successful timestamp. The UI refreshes +visible pages every minute and on return to a visible tab; hidden tabs do not +poll. It displays stale-data status when a refresh fails. + +OAuth and report requests time out after 15 seconds per attempt. Network +failures, HTTP 429 and 5xx retry up to three attempts with bounded backoff; +401 report responses renew OAuth once. Errors and logs omit tokens and upstream +response bodies. Validation returns 400, missing configuration 503, and upstream +failures 502. Authorization returns 401/403 before Salesforce is contacted. + +Future reports can reuse `SalesforceReportsClient.runReport(reportId)` and the +metadata normalization pattern. Add explicit server-side report selection and +authorization for each; do not accept arbitrary report IDs under the sales scope. + +Run `nvm use`, `pnpm lint`, `pnpm build` and `pnpm test --runInBand`. diff --git a/src/app-constants.ts b/src/app-constants.ts index d99df87..bed47f8 100644 --- a/src/app-constants.ts +++ b/src/app-constants.ts @@ -1,4 +1,5 @@ export const Scopes = { + Sales: "reports:sales", WIN: "reports:win", TopgearHourly: "reports:topgear-hourly", TopgearHandles: "reports:topgear-handles", diff --git a/src/app.module.ts b/src/app.module.ts index 290c5d9..a6cae1e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,6 +15,7 @@ import { ReportsModule } from "./reports/reports.module"; import { MemberSearchModule } from "./reports/member/member-search.module"; import { PaymentReportsModule } from "./reports/payment/payment-reports.module"; import { DashboardReportsModule } from "./reports/dashboard/dashboard-reports.module"; +import { SalesReportsModule } from "./reports/sales/sales-reports.module"; @Module({ imports: [ @@ -31,6 +32,7 @@ import { DashboardReportsModule } from "./reports/dashboard/dashboard-reports.mo MemberSearchModule, PaymentReportsModule, DashboardReportsModule, + SalesReportsModule, HealthModule, ], }) diff --git a/src/reports/sales/sales-reports.controller.ts b/src/reports/sales/sales-reports.controller.ts new file mode 100644 index 0000000..e8e52fa --- /dev/null +++ b/src/reports/sales/sales-reports.controller.ts @@ -0,0 +1,72 @@ +import { Controller, Get, Header, Query, UseGuards } from "@nestjs/common"; +import { + ApiBadGatewayResponse, + ApiBadRequestResponse, + ApiBearerAuth, + ApiForbiddenResponse, + ApiOkResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, + ApiUnauthorizedResponse, +} from "@nestjs/swagger"; +import { Scopes } from "../../auth/decorators/scopes.decorator"; +import { Scopes as AppScopes } from "../../app-constants"; +import { SalesReportDto, SalesReportQueryDto } from "./sales-reports.dto"; +import { SalesReportsGuard } from "./sales-reports.guard"; +import { SalesReportsService } from "./sales-reports.service"; + +/** Exposes read-only Sales and machine-only WIN views over the same Salesforce snapshot. */ +@ApiTags("Sales") +@ApiBearerAuth() +@ApiUnauthorizedResponse({ description: "Missing or invalid bearer token." }) +@ApiForbiddenResponse({ + description: + "Sales requires Admin/Talent Manager; WIN requires M2M reports:sales.", +}) +@ApiBadRequestResponse({ + description: "Invalid pagination, sort column or filter.", +}) +@ApiBadGatewayResponse({ + description: "Salesforce unavailable or returned an invalid report.", +}) +@ApiServiceUnavailableResponse({ + description: "Server Salesforce configuration is missing or invalid.", +}) +@UseGuards(SalesReportsGuard) +@Controller() +export class SalesReportsController { + /** @param reports Shared read-only report service. Does not throw. */ + constructor(private readonly reports: SalesReportsService) {} + + /** + * Supplies the Sales app with report metadata and a page of detail rows. + * @param query Validated view options. + * @returns Current Salesforce report page for an authorized human caller. + * @throws BadRequestException for invalid columns; sanitized upstream errors propagate. + */ + @Get("sales") + @Header("Cache-Control", "private, no-store") + @ApiOperation({ + summary: "Sales report for Administrators and Talent Managers", + }) + @ApiOkResponse({ type: SalesReportDto }) + getSales(@Query() query: SalesReportQueryDto): Promise { + return this.reports.getReport(query); + } + + /** + * Supplies WIN with the same report contract using dedicated machine authorization. + * @param query Validated view options. + * @returns Current Salesforce report page for an M2M reports:sales caller. + * @throws BadRequestException for invalid columns; sanitized upstream errors propagate. + */ + @Get("win/sales") + @Scopes(AppScopes.Sales) + @Header("Cache-Control", "private, no-store") + @ApiOperation({ summary: "WIN sales report (M2M reports:sales required)" }) + @ApiOkResponse({ type: SalesReportDto }) + getWinSales(@Query() query: SalesReportQueryDto): Promise { + return this.reports.getReport(query); + } +} diff --git a/src/reports/sales/sales-reports.dto.ts b/src/reports/sales/sales-reports.dto.ts new file mode 100644 index 0000000..d940868 --- /dev/null +++ b/src/reports/sales/sales-reports.dto.ts @@ -0,0 +1,126 @@ +import { Transform, Type } from "class-transformer"; +import { + IsBoolean, + IsIn, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; + +/** Validated view options shared by the Sales UI and WIN report endpoint. */ +export class SalesReportQueryDto { + @ApiPropertyOptional({ default: 1, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(1000000) + page = 1; + + @ApiPropertyOptional({ default: 25, minimum: 1, maximum: 200 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(200) + perPage = 25; + + @ApiPropertyOptional({ + description: "Case-insensitive search across displayed values.", + }) + @IsOptional() + @IsString() + @MaxLength(200) + search?: string; + + @ApiPropertyOptional({ description: "Column ID returned in columns[].id." }) + @IsOptional() + @IsString() + @MaxLength(200) + sortBy?: string; + + @ApiPropertyOptional({ enum: ["asc", "desc"], default: "asc" }) + @IsOptional() + @IsIn(["asc", "desc"]) + sortOrder: "asc" | "desc" = "asc"; + + @ApiPropertyOptional({ + description: "Column ID to filter; requires filterValue.", + }) + @IsOptional() + @IsString() + @MaxLength(200) + filterColumn?: string; + + @ApiPropertyOptional({ + description: "Case-insensitive substring of the column's displayed value.", + }) + @IsOptional() + @IsString() + @MaxLength(200) + filterValue?: string; + + @ApiPropertyOptional({ + default: false, + description: "Refresh Salesforce data (minimum five-second interval).", + }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => + value === "true" ? true : value === "false" ? false : value, + ) + @IsBoolean() + refresh = false; +} + +/** Salesforce report metadata determines display labels and sortable column IDs. */ +export class SalesColumnDto { + @ApiProperty() id: string; + @ApiProperty() label: string; + @ApiProperty() dataType: string; +} + +/** A safe display label paired with a typed value for sorting and WIN consumption. */ +export class SalesCellDto { + @ApiProperty() label: string; + @ApiProperty({ + nullable: true, + oneOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }], + }) + value: string | number | boolean | null; + @ApiPropertyOptional() currencyCode?: string; +} + +/** Detail cells in the same order as columns; ID identifies a row within a report snapshot. */ +export class SalesRowDto { + @ApiProperty() id: string; + @ApiProperty({ type: [SalesCellDto] }) cells: SalesCellDto[]; +} + +/** Read-only report snapshot with explicit completeness and filtered pagination metadata. */ +export class SalesReportDto { + @ApiProperty() reportId: string; + @ApiProperty() reportName: string; + @ApiProperty({ type: [SalesColumnDto] }) columns: SalesColumnDto[]; + @ApiProperty({ type: [SalesRowDto] }) rows: SalesRowDto[]; + @ApiProperty({ + description: + "Whether Salesforce returned every detail row, before local filtering.", + }) + allData: boolean; + @ApiProperty({ + description: + "Number of detail rows received from Salesforce before local filtering.", + }) + sourceRowCount: number; + @ApiProperty({ description: "Number of matching rows in this snapshot." }) + total: number; + @ApiProperty() page: number; + @ApiProperty() perPage: number; + @ApiProperty() totalPages: number; + @ApiProperty({ format: "date-time" }) refreshedAt: string; + @ApiProperty() refreshAfterSeconds: number; +} diff --git a/src/reports/sales/sales-reports.guard.ts b/src/reports/sales/sales-reports.guard.ts new file mode 100644 index 0000000..ec67dd9 --- /dev/null +++ b/src/reports/sales/sales-reports.guard.ts @@ -0,0 +1,57 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + UnauthorizedException, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { Scopes, UserRoles } from "../../app-constants"; +import { SCOPES_KEY } from "../../auth/decorators/scopes.decorator"; +import { + AuthUserLike, + getNormalizedRoles, + hasAdminRole, + hasRequiredScope, +} from "../../auth/permissions.util"; + +/** Separates human Sales access from the machine-only WIN endpoint; neither grants a role/scope bypass. */ +@Injectable() +export class SalesReportsGuard implements CanActivate { + /** @param reflector Reads the WIN endpoint's explicit scope metadata. Does not throw. */ + constructor(private readonly reflector: Reflector) {} + + /** + * Authorizes an already authenticated caller for the selected endpoint. + * @param context Nest request containing middleware-verified authUser claims. + * @returns True for Admin/Talent Manager humans on Sales, or scoped machines on WIN. + * @throws UnauthorizedException for missing identity; ForbiddenException for other callers. + */ + canActivate(context: ExecutionContext): boolean { + const user = context + .switchToHttp() + .getRequest<{ authUser?: AuthUserLike }>().authUser; + if (!user) throw new UnauthorizedException("You are not authenticated."); + const scopes = this.reflector.getAllAndOverride(SCOPES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (scopes) { + if ( + user.isMachine === true && + hasRequiredScope(user.scopes, [Scopes.Sales]) + ) + return true; + } else if (!user.isMachine) { + const roles = getNormalizedRoles(user); + if ( + hasAdminRole(roles) || + roles.includes(UserRoles.TalentManager.toLowerCase()) + ) + return true; + } + throw new ForbiddenException( + "You do not have permission to access this sales report.", + ); + } +} diff --git a/src/reports/sales/sales-reports.module.ts b/src/reports/sales/sales-reports.module.ts new file mode 100644 index 0000000..ecb4d7f --- /dev/null +++ b/src/reports/sales/sales-reports.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { SalesReportsController } from "./sales-reports.controller"; +import { SalesReportsGuard } from "./sales-reports.guard"; +import { SalesReportsService } from "./sales-reports.service"; +import { SalesforceReportsClient } from "./salesforce-reports.client"; + +/** Wires Salesforce reporting, shared snapshots and Sales/WIN authorization without database storage. */ +@Module({ + controllers: [SalesReportsController], + providers: [SalesReportsGuard, SalesReportsService, SalesforceReportsClient], +}) +export class SalesReportsModule {} diff --git a/src/reports/sales/sales-reports.service.ts b/src/reports/sales/sales-reports.service.ts new file mode 100644 index 0000000..ac84d3a --- /dev/null +++ b/src/reports/sales/sales-reports.service.ts @@ -0,0 +1,354 @@ +import { + BadGatewayException, + BadRequestException, + Injectable, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + SalesCellDto, + SalesReportDto, + SalesReportQueryDto, + SalesRowDto, +} from "./sales-reports.dto"; +import { + SalesforceGrouping, + SalesforceReport, + SalesforceReportsClient, +} from "./salesforce-reports.client"; + +const CACHE_MS = 60000; +const REFRESH_COOLDOWN_MS = 5000; + +/** + * Normalizes live Salesforce report data for both Sales and WIN. Maintains one + * short-lived memory snapshot, never a database copy, and coalesces refreshes. + */ +@Injectable() +export class SalesReportsService { + private snapshot?: SalesReportDto; + private loading?: Promise; + private lastFailure?: { error: unknown; time: number }; + + /** @param client Reusable Analytics client. @param config Server report selection. Does not throw. */ + constructor( + private readonly client: SalesforceReportsClient, + private readonly config: ConfigService, + ) {} + + /** + * Converts a Salesforce cell into safe plain text and a sortable scalar. + * @param cell Raw detail cell, including Salesforce currency objects when present. + * @param dataType Report column type; HTML formulas are projected to plain text. + * @returns Display label, raw scalar and optional currency code; nulls remain null. + * @throws Does not throw for missing cells. + */ + private cell( + cell: { label?: string; value?: unknown } | undefined, + dataType = "string", + ): SalesCellDto { + const raw = cell?.value; + const currency = + raw && typeof raw === "object" && "amount" in raw + ? (raw as { + amount: unknown; + currencyCode?: unknown; + currency?: unknown; + }) + : undefined; + const scalar = currency ? currency.amount : raw; + const value = + typeof scalar === "string" || + typeof scalar === "boolean" || + (typeof scalar === "number" && Number.isFinite(scalar)) + ? scalar + : null; + const label = + typeof cell?.label === "string" + ? cell.label + : value === null + ? "" + : String(value); + const plainLabel = dataType === "html" ? this.htmlLabel(label) : label; + const currencyCode = currency?.currencyCode ?? currency?.currency; + return { + label: plainLabel, + value: dataType === "html" ? plainLabel : value, + ...(typeof currencyCode === "string" ? { currencyCode } : {}), + }; + } + + /** + * Converts Salesforce HTML formula labels to text, preserving image alt text. + * This is a text projection, not an HTML sanitizer: clients must render the result as text. + * @param html Formula label, such as the Forecast Alert image. + * @returns Readable text without fetching protected images or executing markup. + * @throws Does not throw for unknown entities; they remain literal text. + */ + private htmlLabel(html: string): string { + const entities: Record = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: " ", + }; + return html + .replace(/]*\balt\s*=\s*["']([^"']*)["'][^>]*>/gi, "$1") + .replace(/<[^>]*>/g, "") + .replace( + /&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, + (entity: string, name: string) => { + if (!name.startsWith("#")) + return entities[name.toLowerCase()] ?? entity; + const code = + name.slice(0, 2).toLowerCase() === "#x" + ? parseInt(name.slice(2), 16) + : Number(name.slice(1)); + return code > 0 && code <= 0x10ffff + ? String.fromCodePoint(code) + : entity; + }, + ) + .trim(); + } + + /** + * Indexes grouping paths so a detail bucket retains dimensions omitted from detailColumns. + * @param groups Row or column axis groupings returned by Salesforce. + * @param ancestors Parent grouping cells, used during recursion. + * @param result Accumulator keyed by the exact Salesforce grouping key. + * @returns Every grouping key mapped to its ordered ancestor and current cells. + * @throws Does not throw for an empty axis. + */ + private groupingPaths( + groups: SalesforceGrouping[], + ancestors: SalesCellDto[] = [], + result = new Map(), + ): Map { + for (const group of groups) { + const path = [...ancestors, this.cell(group)]; + result.set(group.key, path); + this.groupingPaths(group.groupings ?? [], path, result); + } + return result; + } + + /** + * Flattens detail-bearing fact-map buckets, ignoring aggregate-only totals. + * @param report Salesforce tabular, summary or matrix report with detail rows. + * @returns A schema-driven snapshot preserving column order, labels and completeness. + * @throws BadGatewayException for malformed or detail-disabled reports. + */ + private normalize(report: SalesforceReport): SalesReportDto { + const ids = report?.reportMetadata?.detailColumns; + const info = report?.reportExtendedMetadata?.detailColumnInfo; + if ( + !Array.isArray(ids) || + !ids.length || + !ids.every((id) => typeof id === "string" && info?.[id]) || + !report.factMap || + typeof report.factMap !== "object" || + report.hasDetailRows === false || + report.reportMetadata.reportFormat === "MULTI_BLOCK" + ) { + throw new BadGatewayException( + "Salesforce report must provide a supported report with detail rows.", + ); + } + const details = ids.map((id) => ({ + id, + label: info[id].label || id, + dataType: info[id].dataType || "string", + })); + const groupingInfo = report.reportExtendedMetadata.groupingColumnInfo ?? {}; + const groupingColumns = [ + ...(report.reportMetadata.groupingsDown ?? []).map((group, index) => ({ + ...group, + index, + axis: "down", + })), + ...(report.reportMetadata.groupingsAcross ?? []).map((group, index) => ({ + ...group, + index, + axis: "across", + })), + ].filter((group) => !ids.includes(group.name)); + const columns = [ + ...groupingColumns.map((group) => ({ + id: group.name, + label: groupingInfo[group.name]?.label ?? group.name, + dataType: groupingInfo[group.name]?.dataType ?? "string", + })), + ...details, + ]; + const down = this.groupingPaths(report.groupingsDown?.groupings ?? []); + const across = this.groupingPaths(report.groupingsAcross?.groupings ?? []); + const rows: SalesRowDto[] = []; + for (const [key, bucket] of Object.entries(report.factMap)) { + if ( + !bucket || + (bucket.rows !== undefined && !Array.isArray(bucket.rows)) + ) { + throw new BadGatewayException( + "Salesforce returned invalid report rows.", + ); + } + (bucket.rows ?? []).forEach((row, index) => { + if ( + !Array.isArray(row.dataCells) || + row.dataCells.length !== details.length + ) { + throw new BadGatewayException( + "Salesforce returned invalid report columns.", + ); + } + const [downKey, acrossKey] = key.split("!"); + const groupCells = groupingColumns.map( + (group) => + (group.axis === "down" + ? down.get(downKey) + : across.get(acrossKey))?.[group.index] ?? this.cell(undefined), + ); + rows.push({ + id: `${key}:${index}`, + cells: [ + ...groupCells, + ...row.dataCells.map((cell, index) => + this.cell(cell, details[index].dataType), + ), + ], + }); + }); + } + return { + reportId: report.reportMetadata.id, + reportName: report.reportMetadata.name, + columns, + rows, + allData: report.allData === true, + sourceRowCount: rows.length, + total: rows.length, + page: 1, + perPage: 25, + totalPages: Math.ceil(rows.length / 25), + refreshedAt: new Date().toISOString(), + refreshAfterSeconds: CACHE_MS / 1000, + }; + } + + /** + * Loads or reuses the current snapshot. Failed refreshes never relabel stale data as fresh. + * @param refresh Whether to bypass the regular TTL, subject to a five-second cooldown. + * @returns A report fetched within the cache interval. + * @throws The sanitized client/normalization error; failures are throttled for five seconds. + */ + private async getSnapshot(refresh: boolean): Promise { + if (this.loading) return this.loading; + if ( + this.lastFailure && + Date.now() - this.lastFailure.time < REFRESH_COOLDOWN_MS + ) + throw this.lastFailure.error; + if ( + this.snapshot && + Date.now() - Date.parse(this.snapshot.refreshedAt) < + (refresh ? REFRESH_COOLDOWN_MS : CACHE_MS) + ) + return this.snapshot; + const reportId = this.config.get( + "SALESFORCE_SALES_REPORT_ID", + "00O1K00000A7UGDUA3", + ); + this.loading = this.client + .runReport(reportId) + .then((report) => { + this.snapshot = this.normalize(report); + this.lastFailure = undefined; + return this.snapshot; + }) + .catch((error: unknown) => { + this.lastFailure = { error, time: Date.now() }; + throw error; + }); + try { + return await this.loading; + } finally { + this.loading = undefined; + } + } + + /** + * Filters, stably sorts and paginates a live report snapshot for UI or WIN callers. + * @param query Validated page, search, column filter, sorting and refresh options. + * @returns Metadata and one page; total is explicitly the matched received-row count. + * @throws BadRequestException for unknown columns or incomplete filters; upstream exceptions propagate. + */ + async getReport(query: SalesReportQueryDto): Promise { + if (!!query.filterColumn !== !!query.filterValue) { + throw new BadRequestException( + "filterColumn and filterValue must be supplied together.", + ); + } + const report = await this.getSnapshot(query.refresh); + const sortIndex = report.columns.findIndex( + (column) => column.id === query.sortBy, + ); + const filterIndex = report.columns.findIndex( + (column) => column.id === query.filterColumn, + ); + if ( + (query.sortBy && sortIndex < 0) || + (query.filterColumn && filterIndex < 0) + ) { + throw new BadRequestException( + "Unknown report column. Use a column ID from the report response.", + ); + } + const search = query.search?.trim().toLowerCase(); + const filter = query.filterValue?.trim().toLowerCase(); + const rows = report.rows.filter( + (row) => + (!search || + row.cells.some((cell) => + cell.label.toLowerCase().includes(search), + )) && + (!filter || + row.cells[filterIndex].label.toLowerCase().includes(filter)), + ); + if (sortIndex >= 0) { + rows.sort((left, right) => { + const a = left.cells[sortIndex]; + const b = right.cells[sortIndex]; + // Empty cells sort last in both directions. Array.sort is stable in supported Node versions. + if (a.value === null || b.value === null) + return a.value === b.value ? 0 : a.value === null ? 1 : -1; + const useRawValue = ["date", "datetime", "boolean"].includes( + report.columns[sortIndex].dataType, + ); + const comparison = + typeof a.value === "number" && typeof b.value === "number" + ? a.value - b.value + : String(useRawValue ? a.value : a.label).localeCompare( + String(useRawValue ? b.value : b.label), + "en", + { + numeric: true, + sensitivity: "base", + }, + ); + return comparison * (query.sortOrder === "desc" ? -1 : 1); + }); + } + const totalPages = Math.ceil(rows.length / query.perPage); + const page = Math.min(query.page, Math.max(1, totalPages)); + return { + ...report, + rows: rows.slice((page - 1) * query.perPage, page * query.perPage), + total: rows.length, + totalPages, + page, + perPage: query.perPage, + }; + } +} diff --git a/src/reports/sales/sales-reports.spec.ts b/src/reports/sales/sales-reports.spec.ts new file mode 100644 index 0000000..8150225 --- /dev/null +++ b/src/reports/sales/sales-reports.spec.ts @@ -0,0 +1,366 @@ +import { + BadGatewayException, + BadRequestException, + ExecutionContext, + ForbiddenException, + UnauthorizedException, + ValidationPipe, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Reflector } from "@nestjs/core"; +import { AuthUserLike } from "../../auth/permissions.util"; +import { SalesReportsController } from "./sales-reports.controller"; +import { SalesReportQueryDto } from "./sales-reports.dto"; +import { SalesReportsGuard } from "./sales-reports.guard"; +import { SalesReportsService } from "./sales-reports.service"; +import { + SalesforceReport, + SalesforceReportsClient, +} from "./salesforce-reports.client"; + +/** Creates a synthetic grouped report; no customer data or credentials are used. @returns Test report. Does not throw. */ +function reportFixture(): SalesforceReport { + return { + allData: true, + hasDetailRows: true, + reportMetadata: { + id: "00O1K00000A7UGDUA3", + name: "Sales pipeline", + detailColumns: ["NAME", "AMOUNT", "CLOSE_DATE"], + }, + reportExtendedMetadata: { + detailColumnInfo: { + NAME: { label: "Opportunity", dataType: "string" }, + AMOUNT: { label: "Amount", dataType: "currency" }, + CLOSE_DATE: { label: "Close date", dataType: "date" }, + }, + }, + factMap: { + "0!T": { + rows: [ + { + dataCells: [ + { label: "Alpha", value: "Alpha" }, + { label: "$1,000", value: { amount: 1000, currencyCode: "USD" } }, + { label: "9/1/2026", value: "2026-09-01" }, + ], + }, + { + dataCells: [ + { label: "Beta", value: "Beta" }, + { label: "$20", value: 20 }, + { label: "10/1/2026", value: "2026-10-01" }, + ], + }, + ], + }, + "1!T": { + rows: [ + { + dataCells: [ + { label: "Gamma", value: "Gamma" }, + { label: "", value: null }, + { label: "8/1/2026", value: "2026-08-01" }, + ], + }, + ], + }, + "T!T": {}, + }, + }; +} + +describe("SalesReportsService", () => { + let service: SalesReportsService; + let runReport: jest.Mock; + beforeEach(() => { + jest.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-16T01:00:00Z")); + jest.useFakeTimers({ now: Date.now() }); + runReport = jest.fn().mockResolvedValue(reportFixture()); + service = new SalesReportsService( + { runReport } as unknown as SalesforceReportsClient, + new ConfigService(), + ); + }); + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it("preserves stage groupings and readable image formulas from Bookings By Stage", async () => { + const fixture = reportFixture(); + fixture.reportMetadata.groupingsDown = [{ name: "STAGE_NAME" }]; + fixture.reportExtendedMetadata.groupingColumnInfo = { + STAGE_NAME: { label: "Stage", dataType: "picklist" }, + }; + fixture.groupingsDown = { + groupings: [ + { key: "0", label: "Proposal", value: "Proposal", groupings: [] }, + { + key: "1", + label: "Qualification", + value: "Qualification", + groupings: [], + }, + ], + }; + fixture.reportMetadata.detailColumns.push("ALERT"); + fixture.reportExtendedMetadata.detailColumnInfo.ALERT = { + label: "Forecast Alert", + dataType: "html", + }; + Object.values(fixture.factMap).forEach((bucket) => + bucket.rows?.forEach((row) => + row.dataCells.push({ + label: 'Red & overdue', + value: '', + }), + ), + ); + fixture.factMap["0!T"].rows![0].dataCells[1].value = { + amount: 1000, + currency: "USD", + }; + runReport.mockResolvedValue(fixture); + const result = await service.getReport( + Object.assign(new SalesReportQueryDto(), { + filterColumn: "STAGE_NAME", + filterValue: "Proposal", + }), + ); + expect(result.total).toBe(2); + expect(result.columns[0]).toEqual({ + id: "STAGE_NAME", + label: "Stage", + dataType: "picklist", + }); + expect(result.rows[0].cells[0].label).toBe("Proposal"); + expect(result.rows[0].cells[2]).toMatchObject({ + value: 1000, + currencyCode: "USD", + }); + expect(result.rows[0].cells[4]).toEqual({ + label: "Red & overdue", + value: "Red & overdue", + }); + }); + + it("sorts lookup names by their displayed labels rather than Salesforce IDs", async () => { + const fixture = reportFixture(); + fixture.factMap["0!T"].rows![0].dataCells[0].value = "zz-id"; + fixture.factMap["0!T"].rows![1].dataCells[0].value = "aa-id"; + runReport.mockResolvedValue(fixture); + const result = await service.getReport( + Object.assign(new SalesReportQueryDto(), { sortBy: "NAME" }), + ); + expect(result.rows.map((row) => row.cells[0].label)).toEqual([ + "Alpha", + "Beta", + "Gamma", + ]); + }); + + it("preserves all detail buckets, metadata, currency scalars and nulls", async () => { + const result = await service.getReport(new SalesReportQueryDto()); + expect(result).toMatchObject({ + total: 3, + sourceRowCount: 3, + allData: true, + reportName: "Sales pipeline", + }); + expect(result.columns.map((column) => column.id)).toEqual([ + "NAME", + "AMOUNT", + "CLOSE_DATE", + ]); + expect(result.rows[0].cells[1]).toEqual({ + label: "$1,000", + value: 1000, + currencyCode: "USD", + }); + expect(result.rows[2].cells[1].value).toBeNull(); + expect(runReport).toHaveBeenCalledWith("00O1K00000A7UGDUA3"); + }); + + it("sorts numeric and date values globally before pagination and keeps nulls last", async () => { + const result = await service.getReport( + Object.assign(new SalesReportQueryDto(), { + sortBy: "AMOUNT", + perPage: 1, + page: 2, + }), + ); + expect(result.rows[0].cells[0].label).toBe("Alpha"); + expect(result.totalPages).toBe(3); + const dates = await service.getReport( + Object.assign(new SalesReportQueryDto(), { sortBy: "CLOSE_DATE" }), + ); + expect(dates.rows.map((row) => row.cells[0].label)).toEqual([ + "Gamma", + "Alpha", + "Beta", + ]); + const descending = await service.getReport( + Object.assign(new SalesReportQueryDto(), { + sortBy: "AMOUNT", + sortOrder: "desc", + }), + ); + expect(descending.rows.map((row) => row.cells[0].label)).toEqual([ + "Alpha", + "Beta", + "Gamma", + ]); + }); + + it("combines search and column filters before counting and clamps shrinking pages", async () => { + const result = await service.getReport( + Object.assign(new SalesReportQueryDto(), { + search: "ALP", + filterColumn: "AMOUNT", + filterValue: "1,000", + page: 99, + }), + ); + expect(result).toMatchObject({ total: 1, page: 1, sourceRowCount: 3 }); + await expect( + service.getReport( + Object.assign(new SalesReportQueryDto(), { sortBy: "missing" }), + ), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.getReport( + Object.assign(new SalesReportQueryDto(), { filterValue: "Alpha" }), + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("coalesces requests, caches for a minute, and honors manual refresh after its cooldown", async () => { + await Promise.all([ + service.getReport(new SalesReportQueryDto()), + service.getReport(new SalesReportQueryDto()), + ]); + await service.getReport( + Object.assign(new SalesReportQueryDto(), { refresh: true }), + ); + expect(runReport).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(5001); + await service.getReport( + Object.assign(new SalesReportQueryDto(), { refresh: true }), + ); + expect(runReport).toHaveBeenCalledTimes(2); + jest.advanceTimersByTime(60001); + await service.getReport(new SalesReportQueryDto()); + expect(runReport).toHaveBeenCalledTimes(3); + }); + + it("does not serve failed refreshes as successful fresh data and throttles repeated failures", async () => { + await service.getReport(new SalesReportQueryDto()); + jest.advanceTimersByTime(60001); + runReport.mockRejectedValue( + new BadGatewayException("Upstream unavailable"), + ); + await expect( + service.getReport(new SalesReportQueryDto()), + ).rejects.toBeInstanceOf(BadGatewayException); + await expect( + service.getReport(new SalesReportQueryDto()), + ).rejects.toBeInstanceOf(BadGatewayException); + expect(runReport).toHaveBeenCalledTimes(2); + }); + + it("distinguishes an empty report, truncated data and an invalid detail-disabled report", async () => { + const fixture = reportFixture(); + fixture.factMap = { "T!T": { rows: [] } }; + fixture.allData = false; + runReport.mockResolvedValue(fixture); + expect(await service.getReport(new SalesReportQueryDto())).toMatchObject({ + total: 0, + allData: false, + }); + jest.advanceTimersByTime(60001); + fixture.hasDetailRows = false; + await expect( + service.getReport(new SalesReportQueryDto()), + ).rejects.toBeInstanceOf(BadGatewayException); + }); +}); + +describe("Sales authorization", () => { + const guard = new SalesReportsGuard(new Reflector()); + /** @param user Verified test claims. @param win Selects endpoint. @returns Mock Nest context. Does not throw. */ + function context( + user: AuthUserLike | undefined, + win = false, + ): ExecutionContext { + return { + getHandler: () => + win + ? SalesReportsController.prototype.getWinSales + : SalesReportsController.prototype.getSales, + getClass: () => SalesReportsController, + switchToHttp: () => ({ getRequest: () => ({ authUser: user }) }), + } as unknown as ExecutionContext; + } + it.each(["Administrator", "Talent Manager", "Topcoder Talent Manager"])( + "allows the %s human role", + (role) => { + expect(guard.canActivate(context({ roles: [role] }))).toBe(true); + }, + ); + it("requires a verified identity and never grants human access from scopes", () => { + expect(() => guard.canActivate(context(undefined))).toThrow( + UnauthorizedException, + ); + expect(() => + guard.canActivate( + context({ roles: ["Project Manager"], scopes: ["reports:sales"] }), + ), + ).toThrow(ForbiddenException); + expect(() => + guard.canActivate( + context({ isMachine: true, scopes: ["reports:sales"] }), + ), + ).toThrow(ForbiddenException); + }); + it("requires exactly the dedicated machine scope for WIN, including for admins", () => { + expect( + guard.canActivate( + context({ isMachine: true, scopes: "openid reports:sales" }, true), + ), + ).toBe(true); + for (const user of [ + { roles: ["Administrator"], scopes: ["reports:sales"] }, + { isMachine: true, scopes: ["reports:all"] }, + { isMachine: true, roles: ["Administrator"] }, + ]) { + expect(() => guard.canActivate(context(user, true))).toThrow( + ForbiddenException, + ); + } + }); +}); + +describe("Sales query validation", () => { + const pipe = new ValidationPipe({ transform: true, whitelist: true }); + const metadata = { type: "query" as const, metatype: SalesReportQueryDto }; + it("parses booleans without interpreting false as true", async () => { + expect( + await pipe.transform( + { refresh: "false", page: "2", perPage: "50" }, + metadata, + ), + ).toMatchObject({ refresh: false, page: 2, perPage: 50 }); + }); + it.each([ + { page: "0" }, + { perPage: "201" }, + { refresh: "1" }, + { sortOrder: "invalid" }, + { search: ["a", "b"] }, + ])("rejects invalid input %j", async (query) => { + await expect(pipe.transform(query, metadata)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); +}); diff --git a/src/reports/sales/salesforce-reports.client.spec.ts b/src/reports/sales/salesforce-reports.client.spec.ts new file mode 100644 index 0000000..9194167 --- /dev/null +++ b/src/reports/sales/salesforce-reports.client.spec.ts @@ -0,0 +1,105 @@ +import { ConfigService } from "@nestjs/config"; +import { + BadGatewayException, + ServiceUnavailableException, +} from "@nestjs/common"; +import { SalesforceReportsClient } from "./salesforce-reports.client"; + +describe("SalesforceReportsClient", () => { + const reportId = "00O1K00000A7UGDUA3"; + let client: SalesforceReportsClient; + let request: jest.SpyInstance; + /** @param token Synthetic test token. @returns A mock OAuth response. Does not throw. */ + function oauth(token = "test-token"): Response { + return Response.json({ + access_token: token, + instance_url: "https://topcoder.my.salesforce.com", + }); + } + beforeEach(() => { + request = jest.spyOn(global, "fetch"); + client = new SalesforceReportsClient( + new ConfigService({ + SALESFORCE_API_CONSUMER_KEY: "test-client", + SALESFORCE_API_CONSUMER_SECRET: "test-secret", + }), + ); + }); + afterEach(() => jest.restoreAllMocks()); + + it("keeps OAuth on the server and performs only GET report reads with details", async () => { + request + .mockResolvedValueOnce(oauth()) + .mockResolvedValueOnce(Response.json({ allData: true })); + expect(await client.runReport(reportId)).toEqual({ allData: true }); + expect(request.mock.calls[0][1].method).toBe("POST"); + expect(request.mock.calls[0][1].body.toString()).toContain( + "grant_type=client_credentials", + ); + expect(request.mock.calls[1][0]).toBe( + `https://topcoder.my.salesforce.com/services/data/v65.0/analytics/reports/${reportId}?includeDetails=true`, + ); + expect(request.mock.calls[1][1]).toMatchObject({ + redirect: "error", + headers: { Authorization: "Bearer test-token" }, + }); + expect(request.mock.calls[1][1].method).toBeUndefined(); + }); + it("renews expired sessions once", async () => { + request + .mockResolvedValueOnce(oauth()) + .mockResolvedValueOnce(new Response("", { status: 401 })) + .mockResolvedValueOnce(oauth("renewed")) + .mockResolvedValueOnce(Response.json({ allData: true })); + await client.runReport(reportId); + expect(request).toHaveBeenCalledTimes(4); + expect(request.mock.calls[3][1].headers.Authorization).toBe( + "Bearer renewed", + ); + }); + it("retries transient failures and does not retry forbidden reports", async () => { + request + .mockResolvedValueOnce(oauth()) + .mockResolvedValueOnce( + new Response("private upstream body", { status: 503 }), + ) + .mockResolvedValueOnce(new Response("", { status: 429 })) + .mockResolvedValueOnce(Response.json({ allData: true })); + await expect(client.runReport(reportId)).resolves.toEqual({ + allData: true, + }); + request.mockResolvedValueOnce( + new Response("private upstream body", { status: 403 }), + ); + await expect(client.runReport(reportId)).rejects.toThrow( + "Salesforce report could not be loaded", + ); + expect(request).toHaveBeenCalledTimes(5); + }); + it("bounds network retries and sanitizes errors", async () => { + request.mockRejectedValue(new Error("secret network details")); + await expect(client.runReport(reportId)).rejects.toBeInstanceOf( + BadGatewayException, + ); + expect(request).toHaveBeenCalledTimes(3); + }); + it("rejects unconfigured credentials, invalid IDs and untrusted OAuth origins", async () => { + await expect( + new SalesforceReportsClient(new ConfigService()).runReport(reportId), + ).rejects.toBeInstanceOf(ServiceUnavailableException); + await expect(client.runReport("../../secrets")).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + expect(request).not.toHaveBeenCalled(); + request.mockResolvedValueOnce( + Response.json({ + access_token: "test-token", + instance_url: "https://example.com", + }), + ); + await expect(client.runReport(reportId)).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + expect(request).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/reports/sales/salesforce-reports.client.ts b/src/reports/sales/salesforce-reports.client.ts new file mode 100644 index 0000000..99654e5 --- /dev/null +++ b/src/reports/sales/salesforce-reports.client.ts @@ -0,0 +1,260 @@ +import { + BadGatewayException, + Injectable, + Logger, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { setTimeout as delay } from "node:timers/promises"; + +export interface SalesforceGrouping { + key: string; + label: string; + value?: unknown; + groupings: SalesforceGrouping[]; +} + +export interface SalesforceReport { + allData?: boolean; + hasDetailRows?: boolean; + reportMetadata: { + id: string; + name: string; + detailColumns: string[]; + reportFormat?: string; + groupingsDown?: Array<{ name: string }>; + groupingsAcross?: Array<{ name: string }>; + }; + reportExtendedMetadata: { + detailColumnInfo: Record; + groupingColumnInfo?: Record; + }; + groupingsDown?: { groupings: SalesforceGrouping[] }; + groupingsAcross?: { groupings: SalesforceGrouping[] }; + factMap: Record< + string, + { rows?: Array<{ dataCells: Array<{ label?: string; value?: unknown }> }> } + >; +} + +interface SalesforceSession { + access_token: string; + instance_url: string; +} + +/** + * Server-only Salesforce Analytics client. Runs existing reports without modifying + * records or report definitions. Reusable for future authorized report services. + */ +@Injectable() +export class SalesforceReportsClient { + private readonly logger = new Logger(SalesforceReportsClient.name); + private session?: SalesforceSession; + private authenticating?: Promise; + + /** @param config Server environment configuration. Creates a lazy client; does not authenticate or throw. */ + constructor(private readonly config: ConfigService) {} + + /** + * Validates a configured or OAuth-provided Salesforce origin before sending credentials. + * @param origin HTTPS Salesforce origin, without a path, credentials, query, or custom port. + * @returns Normalized trusted origin. + * @throws ServiceUnavailableException for missing or invalid configuration. + */ + private salesforceOrigin(origin: string): string { + try { + const url = new URL(origin); + if ( + url.protocol === "https:" && + !url.username && + !url.password && + !url.port && + url.pathname === "/" && + !url.search && + !url.hash && + (url.hostname.endsWith(".my.salesforce.com") || + ["login.salesforce.com", "test.salesforce.com"].includes( + url.hostname, + )) + ) { + return url.origin; + } + } catch { + /* Invalid URLs use the same sanitized configuration error. */ + } + throw new ServiceUnavailableException( + "Salesforce report integration is not configured.", + ); + } + + /** + * Performs a bounded request with retries for network errors, throttling and 5xx. + * @param url Trusted Salesforce API URL. + * @param init HTTP request options; bodies and credentials are never logged. + * @returns The first non-transient HTTP response. + * @throws BadGatewayException after three failed attempts or an unreadable response. + */ + private async request(url: string, init: RequestInit): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + let retryAfter = 0; + try { + const response = await fetch(url, { + ...init, + redirect: "error", + signal: AbortSignal.timeout(15000), + }); + if (response.status !== 429 && response.status < 500) return response; + const header = response.headers.get("retry-after"); + retryAfter = header ? Number(header) * 1000 : 0; + await response.body?.cancel(); + this.logger.warn( + `Salesforce temporarily unavailable (HTTP ${response.status}).`, + ); + } catch { + this.logger.warn("Salesforce request timed out or failed to connect."); + } + if (attempt < 2) { + await delay( + Math.min( + 2000, + Math.max( + 250 * 2 ** attempt, + Number.isFinite(retryAfter) ? retryAfter : 0, + ), + ), + ); + } + } + throw new BadGatewayException( + "Salesforce is temporarily unavailable. Please try again.", + ); + } + + /** + * Obtains a client-credentials session, coalescing concurrent token requests. + * @returns A trusted instance URL and an access token kept only in server memory. + * @throws ServiceUnavailableException for missing credentials; BadGatewayException on OAuth failure. + */ + private async authenticate(): Promise { + if (this.session) return this.session; + if (this.authenticating) return this.authenticating; + const clientId = this.config.get("SALESFORCE_API_CONSUMER_KEY"); + const clientSecret = this.config.get( + "SALESFORCE_API_CONSUMER_SECRET", + ); + const origin = this.salesforceOrigin( + this.config.get( + "SALESFORCE_LOGIN_URL", + "https://topcoder.my.salesforce.com", + ), + ); + if (!clientId || !clientSecret) { + throw new ServiceUnavailableException( + "Salesforce report integration is not configured.", + ); + } + this.authenticating = (async () => { + const response = await this.request(`${origin}/services/oauth2/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "client_credentials", + client_id: clientId, + client_secret: clientSecret, + }), + }); + if (!response.ok) { + await response.body?.cancel(); + this.logger.warn( + `Salesforce authentication rejected (HTTP ${response.status}).`, + ); + throw new BadGatewayException( + "Salesforce authentication failed. Contact your administrator.", + ); + } + let token: SalesforceSession; + try { + token = (await response.json()) as SalesforceSession; + } catch { + throw new BadGatewayException( + "Salesforce returned an invalid authentication response.", + ); + } + if ( + !token || + typeof token.access_token !== "string" || + !token.access_token + ) { + throw new BadGatewayException( + "Salesforce returned an invalid authentication response.", + ); + } + this.session = { + access_token: token.access_token, + instance_url: this.salesforceOrigin(token.instance_url), + }; + return this.session; + })(); + try { + return await this.authenticating; + } finally { + this.authenticating = undefined; + } + } + + /** + * Runs a saved report using GET with includeDetails=true; renews expired OAuth once. + * @param reportId Server-selected 15/18-character Salesforce report ID. + * @returns Unmodified Analytics report JSON for metadata-driven normalization. + * @throws ServiceUnavailableException for invalid configuration; BadGatewayException on upstream failure. + */ + async runReport(reportId: string): Promise { + if (!/^00O[a-zA-Z0-9]{12}(?:[a-zA-Z0-9]{3})?$/.test(reportId)) { + throw new ServiceUnavailableException( + "Salesforce report ID is not configured correctly.", + ); + } + const version = this.config.get("SALESFORCE_API_VERSION", "65.0"); + if (!/^\d{2,3}\.0$/.test(version)) { + throw new ServiceUnavailableException( + "Salesforce API version is not configured correctly.", + ); + } + for (let attempt = 0; attempt < 2; attempt++) { + const session = await this.authenticate(); + const response = await this.request( + `${session.instance_url}/services/data/v${version}/analytics/reports/${reportId}?includeDetails=true`, + { + headers: { + Authorization: `Bearer ${session.access_token}`, + Accept: "application/json", + }, + }, + ); + if (response.status === 401 && attempt === 0) { + await response.body?.cancel(); + if (this.session === session) this.session = undefined; + continue; + } + if (!response.ok) { + await response.body?.cancel(); + this.logger.warn( + `Salesforce report request rejected (HTTP ${response.status}).`, + ); + throw new BadGatewayException( + "Salesforce report could not be loaded. Please try again.", + ); + } + try { + return (await response.json()) as SalesforceReport; + } catch { + throw new BadGatewayException( + "Salesforce returned an invalid report response.", + ); + } + } + throw new BadGatewayException( + "Salesforce authentication failed. Contact your administrator.", + ); + } +} From 147163e16c1fee6c9bf9420194ea8eb73d8ad3c3 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 16 Sep 2026 10:43:44 +1000 Subject: [PATCH 2/2] PM-6343 Parse Salesforce formula labels with html-to-text --- README.md | 2 +- package.json | 10 +- pnpm-lock.yaml | 112 +++++++++++++++++++++ src/reports/sales/sales-reports.service.ts | 43 +++----- 4 files changed, 135 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 75df5ee..198b264 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ and Salesforce completeness limits. ## Security -Currently, an M2M token is required to pull any report, and each report has its own scope associated with it that must be applied to the M2M token client ID +Report endpoints enforce their documented roles and scopes. Machine clients require the scopes granted to their client ID. The Sales UI endpoint is limited to Administrator/Talent Manager users, while the WIN Sales endpoint requires an M2M token with `reports:sales`. The report directory (list of endpoints and parameters) is available at `GET /v6/reports/directory` and uses the same authorization rules as other endpoints. The service accepts bearer tokens from the standard `Authorization` header, and also from proxies that forward the token in `X-Authorization`/`X-Forwarded-Authorization`. diff --git a/package.json b/package.json index c0f1e56..8caeeab 100644 --- a/package.json +++ b/package.json @@ -25,25 +25,27 @@ "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.18", "@nestjs/platform-express": "^11.1.18", + "@nestjs/schematics": "^11.0.9", "@nestjs/swagger": "^11.2.3", + "@nestjs/testing": "^11.1.18", "@prisma/client": "^7.0.1", "@types/express": "^5.0.5", + "@types/jest": "^29.5.8", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "date-fns": "^4.1.0", + "html-to-text": "^10.0.1", "i18n-iso-countries": "^3.7.1", "json-stringify-safe": "^5.0.1", "pg": "^8.16.3", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.2", - "tc-core-library-js": "github:topcoder-platform/tc-core-library-js#master", - "@nestjs/schematics": "^11.0.9", - "@types/jest": "^29.5.8", - "@nestjs/testing": "^11.1.18" + "tc-core-library-js": "github:topcoder-platform/tc-core-library-js#master" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.18.0", + "@types/html-to-text": "^9.0.4", "@types/node": "^20.11.24", "@types/pg": "^8.15.5", "@typescript-eslint/eslint-plugin": "^7.13.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90e40a5..0a5c18f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + html-to-text: + specifier: ^10.0.1 + version: 10.0.1 i18n-iso-countries: specifier: ^3.7.1 version: 3.7.8 @@ -114,6 +117,9 @@ importers: '@eslint/js': specifier: ^9.18.0 version: 9.33.0 + '@types/html-to-text': + specifier: ^9.0.4 + version: 9.0.4 '@types/node': specifier: ^20.11.24 version: 20.19.11 @@ -893,6 +899,11 @@ packages: '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@selderee/plugin-htmlparser2@0.12.0': + resolution: {integrity: sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==} + peerDependencies: + selderee: ~0.12.0 + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -966,6 +977,9 @@ packages: '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + '@types/html-to-text@9.0.4': + resolution: {integrity: sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -1680,6 +1694,19 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv-expand@12.0.1: resolution: {integrity: sha512-LaKRbou8gt0RNID/9RoI+J2rvXsBRPMV7p+ElHlPhcSARbCPDYcYG2s1TIzAfWv4YSgyY5taidWzzs31lNV3yQ==} engines: {node: '>=12'} @@ -1737,6 +1764,14 @@ packages: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} @@ -2105,6 +2140,13 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-to-text@10.0.1: + resolution: {integrity: sha512-GiVhRI1BatGARSCmlXWNCjDT0cWrwBWoeduLoV0WSKAgaV/wa+hUWy5LiQLUs4UwiUrE52ZCMfBGiKD87TDPrg==} + engines: {node: '>=20.19.0'} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -2453,6 +2495,9 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + leac@0.7.0: + resolution: {integrity: sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -2773,6 +2818,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parseley@0.13.1: + resolution: {integrity: sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -2806,6 +2854,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + peberminta@0.10.0: + resolution: {integrity: sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -3078,6 +3129,9 @@ packages: resolution: {integrity: sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==} engines: {node: '>= 10.13.0'} + selderee@0.12.0: + resolution: {integrity: sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==} + semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -4500,6 +4554,12 @@ snapshots: '@scarf/scarf@1.4.0': {} + '@selderee/plugin-htmlparser2@0.12.0(selderee@0.12.0)': + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + selderee: 0.12.0 + '@sinclair/typebox@0.27.8': {} '@sinonjs/commons@3.0.1': @@ -4602,6 +4662,8 @@ snapshots: dependencies: '@types/node': 20.19.11 + '@types/html-to-text@9.0.4': {} + '@types/http-errors@2.0.5': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -5402,6 +5464,24 @@ snapshots: dependencies: path-type: 4.0.0 + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dotenv-expand@12.0.1: dependencies: dotenv: 16.4.7 @@ -5451,6 +5531,10 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.2.2 + entities@4.5.0: {} + + entities@7.0.1: {} + error-ex@1.3.2: dependencies: is-arrayish: 0.2.1 @@ -5891,6 +5975,21 @@ snapshots: html-escaper@2.0.2: {} + html-to-text@10.0.1: + dependencies: + '@selderee/plugin-htmlparser2': 0.12.0(selderee@0.12.0) + deepmerge-ts: 8.0.0 + dom-serializer: 2.0.0 + htmlparser2: 10.1.0 + selderee: 0.12.0 + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -6419,6 +6518,8 @@ snapshots: kleur@3.0.3: {} + leac@0.7.0: {} + leven@3.1.0: {} levn@0.4.1: @@ -6711,6 +6812,11 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parseley@0.13.1: + dependencies: + leac: 0.7.0 + peberminta: 0.10.0 + parseurl@1.3.3: {} path-exists@4.0.0: {} @@ -6732,6 +6838,8 @@ snapshots: pathe@2.0.3: {} + peberminta@0.10.0: {} + perfect-debounce@1.0.0: {} pg-cloudflare@1.2.7: @@ -6989,6 +7097,10 @@ snapshots: ajv-formats: 2.1.1(ajv@8.18.0) ajv-keywords: 5.1.0(ajv@8.18.0) + selderee@0.12.0: + dependencies: + parseley: 0.13.1 + semver@5.7.2: {} semver@6.3.1: {} diff --git a/src/reports/sales/sales-reports.service.ts b/src/reports/sales/sales-reports.service.ts index ac84d3a..a23e7da 100644 --- a/src/reports/sales/sales-reports.service.ts +++ b/src/reports/sales/sales-reports.service.ts @@ -4,6 +4,7 @@ import { Injectable, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; +import { compile } from "html-to-text"; import { SalesCellDto, SalesReportDto, @@ -18,6 +19,19 @@ import { const CACHE_MS = 60000; const REFRESH_COOLDOWN_MS = 5000; +const htmlToPlainText = compile({ + wordwrap: false, + selectors: [ + { + selector: "img", + options: { + /** Omits image source paths, keeping only parsed alt text. Returns an empty path; does not throw. */ + pathRewrite: () => "", + }, + }, + { selector: "a", options: { ignoreHref: true } }, + ], +}); /** * Normalizes live Salesforce report data for both Sales and WIN. Maintains one @@ -82,35 +96,10 @@ export class SalesReportsService { * This is a text projection, not an HTML sanitizer: clients must render the result as text. * @param html Formula label, such as the Forecast Alert image. * @returns Readable text without fetching protected images or executing markup. - * @throws Does not throw for unknown entities; they remain literal text. + * @throws Does not throw for malformed markup; the parser handles incomplete HTML. */ private htmlLabel(html: string): string { - const entities: Record = { - amp: "&", - lt: "<", - gt: ">", - quot: '"', - apos: "'", - nbsp: " ", - }; - return html - .replace(/]*\balt\s*=\s*["']([^"']*)["'][^>]*>/gi, "$1") - .replace(/<[^>]*>/g, "") - .replace( - /&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, - (entity: string, name: string) => { - if (!name.startsWith("#")) - return entities[name.toLowerCase()] ?? entity; - const code = - name.slice(0, 2).toLowerCase() === "#x" - ? parseInt(name.slice(2), 16) - : Number(name.slice(1)); - return code > 0 && code <= 0x10ffff - ? String.fromCodePoint(code) - : entity; - }, - ) - .trim(); + return htmlToPlainText(html).trim(); } /**