diff --git a/README.md b/README.md index 8d7af7d..198b264 100644 --- a/README.md +++ b/README.md @@ -53,9 +53,16 @@ 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 +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/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/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/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..a23e7da --- /dev/null +++ b/src/reports/sales/sales-reports.service.ts @@ -0,0 +1,343 @@ +import { + BadGatewayException, + BadRequestException, + Injectable, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { compile } from "html-to-text"; +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; +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 + * 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 malformed markup; the parser handles incomplete HTML. + */ + private htmlLabel(html: string): string { + return htmlToPlainText(html).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.", + ); + } +}