diff --git a/.circleci/config.yml b/.circleci/config.yml index 7f67e8d..0adcae0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -65,7 +65,6 @@ workflows: only: - develop - PM-4931 - - skill-statistics tags: only: /^dev-.*/ diff --git a/README.md b/README.md index c18259f..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`. @@ -198,3 +205,8 @@ package and upgrades Alpine packages during the build so system security fixes, including OpenSSL updates, are applied. The runtime runs as the unprivileged `app` account (UID 10001) and intentionally excludes npm and pnpm; package installation and application compilation happen only in builder stages. + +## WIN showcase integration + +See [WIN showcase export](./WIN.md) for `GET /v6/reports/WIN`, its `reports:win` +scope and role checks, payload fields, pagination, and database requirements. diff --git a/SALES.md b/SALES.md new file mode 100644 index 0000000..c444926 --- /dev/null +++ b/SALES.md @@ -0,0 +1,133 @@ +# 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 | +| `dateColumn` | Column ID of a `date`/`datetime` column, such as Created Date or Close Date | +| `dateFrom`, `dateTo` | Inclusive `YYYY-MM-DD` bounds; either or both, and both require `dateColumn` | +| `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`, +`summary`. +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`. + +### Date range filtering (PM-6364) + +`dateColumn` selects which date the range applies to, so the same report answers +both pipeline generation (Created Date) and revenue realization (Close Date) +questions. Bounds are inclusive and combine with `search` and +`filterColumn`/`filterValue`. Selecting a `dateColumn` with no bound is a no-op, +which lets a client keep the field selected while the range is empty. + +Comparison uses each cell's **underlying** Salesforce value, never its localized +label: date and datetime values arrive as ISO 8601, and a datetime keeps the +report's own offset, so its day matches the day the report displays. A row whose +date cell is null or unparseable cannot satisfy a range and is excluded rather +than counted. `400` responses cover a bound without `dateColumn`, `dateFrom` +after `dateTo`, a `dateColumn` that is not a `date`/`datetime` column, and any +bound that is not a real `YYYY-MM-DD` calendar day (`2026-02-30` and non-leap +`2027-02-29` are rejected; datetimes and offsets are not accepted as bounds). + +### Summary aggregates (PM-6364) + +`summary` describes **every matching row in the snapshot**, not the returned +page, so counts and totals stay correct under pagination: + +| Field | Meaning | +| --- | --- | +| `recordCount` | Matching rows; always equal to `total` | +| `amounts[]` | One entry per `currency`/`double` column: `columnId`, `label`, `total`, contributing `count`, and `currencyCode` when the contributing rows agree | +| `groups[]` | Up to three `picklist`/`multipicklist`/`combobox`/`boolean` columns broken into `buckets[{label,count,total}]`, ordered by total then count, capped at 25 with the remainder in `otherBuckets` | + +Bucket totals use the report's first amount column, named in `amountColumnId`. +Totals round to cents so repeated floating-point addition cannot leak artifacts +into displayed currency. A `currencyCode` is omitted when contributing rows +declare different currencies; rows that declare none cannot contradict the rest. +Because aggregates cover received rows only, `allData: false` limits them +exactly as it limits `total`. + +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/WIN.md b/WIN.md new file mode 100644 index 0000000..1369230 --- /dev/null +++ b/WIN.md @@ -0,0 +1,60 @@ +# WIN showcase export + +`GET /v6/reports/WIN` returns showcase posts explicitly shared using **Send to WIN**. +Access requires a JWT with the `reports:win` scope, or an authenticated human with +the Administrator or Talent Manager role. The general `reports:all` scope alone +does not grant access. Role and scope normalization follow the existing reports +permission checks. The report is also listed in the report directory for these callers. + +## Request and response + +Optional query parameters: + +| Parameter | Default | Meaning | +| --- | --- | --- | +| `projectId` | all projects | Positive numeric string, up to 18 digits | +| `page` | 1 | Page number, 1–1,000,000 | +| `perPage` | 100 | Page size, 1–100 | + +```http +GET /v6/reports/WIN?projectId=123&page=1&perPage=100 +Authorization: Bearer +``` + +The response is `{ "data": [...], "total": 0, "page": 1, "perPage": 100 }`. +`total` counts all matches, including when a later page is empty. Rows are ordered +by post ID. Repeat requests read current records; this endpoint does not mark +records as delivered or push them to another service. + +Each row contains all stored showcase fields, including `title`, `type`, +`challenge`, `content` (The Solution), `businessImpact`, `keyWin`, `currentStatus`, +`owner`, `sendToWin`, lifecycle `status`, `challengeIds`, and publication/audit +metadata. `industries`, `categories`, and `media` are arrays with their stored +metadata. Media URLs are the stored asset URLs. `challengeMetadata` includes linked +challenge names, submission/registration counts, track, skills, and submitter countries. + +`customer`, `smu`, `smuOther`, and `dealCloseDate` come from the current project +details, so changes made in either Work form appear immediately. For `smu: "Others"`, +use `smuOther` as the custom SMU value. `dealCloseDate` is a date-only string. +`project` contains the project's stored scalar fields and JSON metadata. Bigint +post/project/taxonomy/media IDs are serialized as strings. Missing optional fields +may be null on older posts. + +Opted-in drafts and published posts are included. Opted-out and archived posts, +and posts belonging to deleted projects, are excluded. Invalid query parameters +return 400; missing authentication returns 401; insufficient access returns 403. + +## Deployment and tests + +The reporting `DATABASE_URL` needs read access to the `projects` schema including +the showcase taxonomy/media tables, plus `challenges`, `resources`, `members`, +and `skills` for linked challenge metadata. First deploy the projects-api-v6 migration +`20260916000000_showcase_win_metadata` and its application changes, then deploy +this endpoint and the platform-ui changes. No WIN push URL is required. + +After `nvm use`, run `pnpm lint`, `pnpm build`, and +`pnpm test --runInBand win report-directory permissions.util`. Set +`WIN_TEST_DATABASE_URL` to a disposable PostgreSQL database with the projects API +migrations applied to run the real SQL tests. Use a database containing only the +projects schema; the tests create minimal reference-schema fixtures for challenges, +resources, members and skills. All fixtures run in a transaction and are rolled back. 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/sql/reports/statistics/expert-skills/category-members.sql b/sql/reports/statistics/expert-skills/category-members.sql index 458069d..0f94522 100644 --- a/sql/reports/statistics/expert-skills/category-members.sql +++ b/sql/reports/statistics/expert-skills/category-members.sql @@ -11,6 +11,7 @@ WITH category_wins AS ( JOIN skills.source_type sest ON sest.id = se.source_type_id WHERE sk.category_id = $1::uuid + AND NOT (se.user_id::text = ANY($3)) AND ( LOWER(set_t.name) IN ( 'challenge_win', diff --git a/sql/reports/statistics/expert-skills/category-stats.sql b/sql/reports/statistics/expert-skills/category-stats.sql index 7cfedd6..089a1b6 100644 --- a/sql/reports/statistics/expert-skills/category-stats.sql +++ b/sql/reports/statistics/expert-skills/category-stats.sql @@ -20,6 +20,7 @@ member_counts AS ( ON sk.id = us.skill_id AND sk.deleted_at IS NULL WHERE sk.category_id = ANY($1::uuid[]) + AND NOT (us.user_id::text = ANY($2)) GROUP BY sk.category_id ), win_events AS ( @@ -37,6 +38,7 @@ win_events AS ( JOIN skills.source_type sest ON sest.id = se.source_type_id WHERE sk.category_id = ANY($1::uuid[]) + AND NOT (se.user_id::text = ANY($2)) AND ( LOWER(set_t.name) IN ( 'challenge_win', diff --git a/sql/reports/win/showcase.sql b/sql/reports/win/showcase.sql new file mode 100644 index 0000000..3e477ad --- /dev/null +++ b/sql/reports/win/showcase.sql @@ -0,0 +1,82 @@ +-- $1: optional project ID, $2: page size, $3: offset. +-- Use current project details so edits from either Work form remain synchronized. +WITH eligible AS ( + SELECT post.*, project.name AS "projectTitle", project.details, + to_jsonb(project) || jsonb_build_object( + 'id', project.id::text, + 'directProjectId', project."directProjectId"::text, + 'billingAccountId', project."billingAccountId"::text + ) AS "projectMetadata" + FROM projects.project_showcase_posts post + JOIN projects.projects project ON project.id = post."projectId" + WHERE post."sendToWin" = true + AND post.status <> 'ARCHIVED' + AND project."deletedAt" IS NULL + AND ($1::bigint IS NULL OR post."projectId" = $1::bigint) +), page AS ( + SELECT * FROM eligible ORDER BY id LIMIT $2 OFFSET $3 +), payload AS ( + SELECT post.id, (to_jsonb(post) - 'details' - 'projectMetadata') || jsonb_build_object( + 'id', post.id::text, + 'projectId', post."projectId"::text, + 'customer', post.details->>'customer', + 'smu', post.details->>'smu', + 'smuOther', post.details->>'smuOther', + 'dealCloseDate', post.details->>'dealCloseDate', + 'project', post."projectMetadata", + 'challengeMetadata', COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'challengeId', challenge.id, + 'name', challenge.name, + 'numOfSubmissions', challenge."numOfSubmissions", + 'numOfRegistrants', challenge."numOfRegistrants", + 'track', COALESCE(track.name, ''), + 'skills', COALESCE(( + SELECT jsonb_agg(jsonb_build_object('id', linked_skill."skillId", 'name', COALESCE(skill.name, '')) + ORDER BY linked_skill."skillId") + FROM challenges."ChallengeSkill" linked_skill + LEFT JOIN skills.skill skill ON skill.id::text = linked_skill."skillId" + WHERE linked_skill."challengeId" = challenge.id + ), '[]'::jsonb), + 'countries', COALESCE(( + SELECT jsonb_agg(country ORDER BY country) + FROM ( + SELECT DISTINCT COALESCE(NULLIF(member."competitionCountryCode", ''), + NULLIF(member.country, ''), NULLIF(member."homeCountryCode", '')) AS country + FROM resources."Resource" resource + JOIN resources."ResourceRole" role ON role.id = resource."roleId" AND role.name = 'Submitter' + JOIN members.member member ON member."userId"::text = resource."memberId" + WHERE resource."challengeId" = challenge.id + ) countries WHERE country IS NOT NULL + ), '[]'::jsonb) + ) ORDER BY challenge.id) + FROM challenges."Challenge" challenge + LEFT JOIN challenges."ChallengeTrack" track ON track.id = challenge."trackId" + WHERE challenge.id = ANY(post."challengeIds") + ), '[]'::jsonb), + 'industries', COALESCE(( + SELECT jsonb_agg(jsonb_build_object('id', industry.id::text, 'name', industry.name) ORDER BY industry.id) + FROM projects.project_showcase_post_industries link + JOIN projects.project_post_industries industry ON industry.id = link."industryId" + WHERE link."projectShowcasePostId" = post.id + ), '[]'::jsonb), + 'categories', COALESCE(( + SELECT jsonb_agg(jsonb_build_object('id', category.id::text, 'name', category.name) ORDER BY category.id) + FROM projects.project_showcase_post_categories link + JOIN projects.project_post_categories category ON category.id = link."categoryId" + WHERE link."projectShowcasePostId" = post.id + ), '[]'::jsonb), + 'media', COALESCE(( + SELECT jsonb_agg(to_jsonb(media) || jsonb_build_object( + 'id', media.id::text, 'projectShowcasePostId', media."projectShowcasePostId"::text, + 'createdBy', media."createdBy"::text + ) ORDER BY media.id) + FROM projects.project_showcase_post_media media + WHERE media."projectShowcasePostId" = post.id + ), '[]'::jsonb) + ) AS data + FROM page post +) +SELECT COALESCE(jsonb_agg(payload.data ORDER BY payload.id), '[]'::jsonb) AS data, + (SELECT count(*)::integer FROM eligible) AS total +FROM payload; diff --git a/src/app-constants.ts b/src/app-constants.ts index 72ca01d..bed47f8 100644 --- a/src/app-constants.ts +++ b/src/app-constants.ts @@ -1,4 +1,6 @@ export const Scopes = { + Sales: "reports:sales", + WIN: "reports:win", TopgearHourly: "reports:topgear-hourly", TopgearHandles: "reports:topgear-handles", TopgearPayments: "reports:topgear-payments", @@ -60,6 +62,7 @@ const challengeReportAccessRoles = [ const sfdcReportsTalentManagerRoles = [UserRoles.TalentManager] as const; export const ScopeRoleAccess: Record = { + [Scopes.WIN]: [UserRoles.TalentManager], [Scopes.Challenge.History]: challengeReportAccessRoles, [Scopes.Challenge.Registrants]: challengeReportAccessRoles, [Scopes.Challenge.SubmissionLinks]: challengeReportAccessRoles, diff --git a/src/app.module.ts b/src/app.module.ts index 1601f5e..a6cae1e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,6 +2,7 @@ import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; import { DbModule } from "./db/db.module"; import { AuthMiddleware } from "./auth/auth.middleware"; +import { WinReportsModule } from "./reports/win/win-reports.module"; import { HealthModule } from "./health/health.module"; import { TopgearReportsModule } from "./reports/topgear/topgear-reports.module"; @@ -14,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: [ @@ -26,9 +28,11 @@ import { DashboardReportsModule } from "./reports/dashboard/dashboard-reports.mo ChallengesReportsModule, IdentityReportsModule, ReportsModule, + WinReportsModule, MemberSearchModule, PaymentReportsModule, DashboardReportsModule, + SalesReportsModule, HealthModule, ], }) diff --git a/src/reports/report-directory.data.spec.ts b/src/reports/report-directory.data.spec.ts index ac93d29..6878760 100644 --- a/src/reports/report-directory.data.spec.ts +++ b/src/reports/report-directory.data.spec.ts @@ -71,6 +71,7 @@ describe("getAccessibleReportsDirectory", () => { "member", "sfdc", "statistics", + "win", ]); expect(directory.identity?.reports.map((report) => report.path)).toEqual([ "/identity/users-by-handles", @@ -148,4 +149,9 @@ describe("getAccessibleReportsDirectory", () => { it("returns an empty directory when no JWT user is present", () => { expect(getAccessibleReportsDirectory()).toEqual({}); }); + it("lists the WIN route only for its dedicated scope or allowed roles", () => { + expect(getAccessibleReportsDirectory({ scopes: ["reports:win"], isMachine: true }).win?.reports[0].path).toBe("/WIN"); + expect(getAccessibleReportsDirectory({ scopes: ["reports:all"], isMachine: true }).win).toBeUndefined(); + }); + }); diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index 56f93e8..d5272d4 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -14,7 +14,8 @@ export type ReportGroupKey = | "topcoder" | "member" | "payment" - | "identity"; + | "identity" + | "win"; type HttpMethod = "GET" | "POST"; @@ -414,6 +415,21 @@ const groupNameParam: ReportParameter = { }; const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { + win: { + label: "WIN", + basePath: "/WIN", + reports: [report( + "WIN showcases", + "/WIN", + "Showcase posts explicitly shared with WIN and their current project metadata.", + [AppScopes.WIN], + [ + { name: "projectId", type: "string", description: "Optional project ID." }, + { name: "page", type: "number", description: "Page number, starting at 1." }, + { name: "perPage", type: "number", description: "Page size, from 1 to 100." }, + ], + )], + }, challenges: { label: "Challenges Reports", basePath: "/challenges", 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..956679f --- /dev/null +++ b/src/reports/sales/sales-reports.dto.ts @@ -0,0 +1,231 @@ +import { Transform, Type } from "class-transformer"; +import { + IsBoolean, + IsIn, + IsInt, + IsISO8601, + IsOptional, + IsString, + Matches, + Max, + MaxLength, + Min, +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; + +/** Rejects datetimes and offsets so a bound is always a plain calendar day. */ +const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Applies both shape and calendar validation to a range bound: the pattern keeps + * the value date-only, and strict ISO 8601 rejects impossible days such as + * 2026-02-30 and non-leap 2027-02-29 before they reach the snapshot comparison. + * @param field Query parameter name used in the validation message. + * @returns The decorators to spread onto the property. + * @throws Does not throw. + */ +function IsCalendarDate(field: string): PropertyDecorator { + const message = `${field} must be a real YYYY-MM-DD calendar date.`; + return function apply(target: object, key: string | symbol): void { + Matches(DATE_ONLY, { message })(target, key); + IsISO8601({ strict: true, strictSeparator: true }, { message })( + target, + key, + ); + }; +} + +/** 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({ + description: + "Date or datetime column ID to range-filter; required with dateFrom/dateTo.", + }) + @IsOptional() + @IsString() + @MaxLength(200) + dateColumn?: string; + + @ApiPropertyOptional({ + description: "Inclusive lower bound as a YYYY-MM-DD calendar date.", + example: "2026-09-01", + }) + @IsOptional() + @IsCalendarDate("dateFrom") + dateFrom?: string; + + @ApiPropertyOptional({ + description: "Inclusive upper bound as a YYYY-MM-DD calendar date.", + example: "2026-09-30", + }) + @IsOptional() + @IsCalendarDate("dateTo") + dateTo?: 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[]; +} + +/** A numeric column totalled across every matching row, not only the current page. */ +export class SalesSummaryAmountDto { + @ApiProperty() columnId: string; + @ApiProperty() label: string; + @ApiProperty({ description: "Sum of the matching rows' underlying values." }) + total: number; + @ApiProperty({ description: "Rows contributing a value to this total." }) + count: number; + @ApiPropertyOptional({ + description: + "Shared currency of every contributing row; omitted when rows mix currencies.", + }) + currencyCode?: string; +} + +/** One distinct value of a category column, such as a pipeline stage. */ +export class SalesSummaryBucketDto { + @ApiProperty() label: string; + @ApiProperty() count: number; + @ApiProperty({ + description: "Sum of the primary amount column within this bucket.", + }) + total: number; +} + +/** A category column broken down into its distinct values, largest total first. */ +export class SalesSummaryGroupDto { + @ApiProperty() columnId: string; + @ApiProperty() label: string; + @ApiPropertyOptional({ description: "Column totalled in each bucket." }) + amountColumnId?: string; + @ApiPropertyOptional() currencyCode?: string; + @ApiProperty({ type: [SalesSummaryBucketDto] }) + buckets: SalesSummaryBucketDto[]; + @ApiProperty({ + description: "Buckets beyond the returned set, omitted from buckets[].", + }) + otherBuckets: number; +} + +/** Aggregates over every matching row in the snapshot, recomputed for each query. */ +export class SalesSummaryDto { + @ApiProperty({ description: "Matching rows; equal to total." }) + recordCount: number; + @ApiProperty({ type: [SalesSummaryAmountDto] }) + amounts: SalesSummaryAmountDto[]; + @ApiProperty({ type: [SalesSummaryGroupDto] }) + groups: SalesSummaryGroupDto[]; +} + +/** 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; + @ApiProperty({ + description: + "Aggregates over all matching rows in the snapshot, not just this page.", + type: SalesSummaryDto, + }) + summary: SalesSummaryDto; +} 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..9711702 --- /dev/null +++ b/src/reports/sales/sales-reports.service.ts @@ -0,0 +1,543 @@ +import { + BadGatewayException, + BadRequestException, + Injectable, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { compile } from "html-to-text"; +import { + SalesCellDto, + SalesColumnDto, + SalesReportDto, + SalesReportQueryDto, + SalesRowDto, + SalesSummaryDto, + SalesSummaryGroupDto, +} from "./sales-reports.dto"; +import { + SalesforceGrouping, + SalesforceReport, + SalesforceReportsClient, +} from "./salesforce-reports.client"; + +const CACHE_MS = 60000; +const REFRESH_COOLDOWN_MS = 5000; +/** Column types that can carry a pipeline or revenue amount worth totalling. */ +const AMOUNT_TYPES = ["currency", "double"]; +/** Column types that a date range can be applied to. */ +const DATE_TYPES = ["date", "datetime"]; +/** Column types that describe a category, such as a pipeline stage. */ +const CATEGORY_TYPES = ["picklist", "multipicklist", "combobox", "boolean"]; +/** Keeps a breakdown readable and the response bounded for very wide reports. */ +const MAX_SUMMARY_GROUPS = 3; +const MAX_SUMMARY_BUCKETS = 25; +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, + summary: this.summarize(rows, columns), + }; + } + + /** + * Reads the calendar day a date cell falls on, using the underlying Salesforce + * value rather than its locale-formatted label. + * @param cell Cell taken from a column whose dataType is date or datetime. + * @returns The YYYY-MM-DD day, or undefined when the cell holds no usable date. + * @throws Does not throw for null, blank or unparseable values. + */ + private day(cell: SalesCellDto | undefined): string | undefined { + const raw = cell?.value; + if (typeof raw === "number" && Number.isFinite(raw)) { + return new Date(raw).toISOString().slice(0, 10); + } + // Salesforce emits date and datetime values as ISO 8601; a datetime keeps + // the report's own offset, so slicing matches the day the report displays. + return typeof raw === "string" && /^\d{4}-\d{2}-\d{2}/.test(raw) + ? raw.slice(0, 10) + : undefined; + } + + /** + * Totals one numeric column across matching rows, tracking currency agreement. + * @param rows Matching rows, before pagination. + * @param column The numeric column being totalled. + * @param index The column's position in every row's cells. + * @returns The total, the number of contributing rows and a shared currency code when unanimous. + * @throws Does not throw for null or non-numeric cells, which are skipped. + */ + private amount( + rows: SalesRowDto[], + column: SalesColumnDto, + index: number, + ): SalesSummaryDto["amounts"][number] { + let total = 0; + let count = 0; + let currencyCode: string | undefined; + let mixed = false; + for (const row of rows) { + const cell = row.cells[index]; + if (typeof cell?.value !== "number" || !Number.isFinite(cell.value)) + continue; + total += cell.value; + count += 1; + if (cell.currencyCode === undefined) continue; + if (currencyCode === undefined) currencyCode = cell.currencyCode; + else if (currencyCode !== cell.currencyCode) mixed = true; + } + return { + columnId: column.id, + label: column.label, + // Rounded to cents: repeated float addition otherwise leaks artifacts + // such as 0.30000000000000004 into displayed currency totals. + total: Math.round(total * 100) / 100, + count, + // A mixed-currency total is still the report's own sum, but it must not + // be labelled with a currency the amounts do not share. + ...(currencyCode !== undefined && !mixed ? { currencyCode } : {}), + }; + } + + /** + * Breaks a category column into its distinct values with counts and amounts. + * @param rows Matching rows, before pagination. + * @param column The category column being broken down. + * @param index The column's position in every row's cells. + * @param amount The primary amount column to total per bucket, when the report has one. + * @returns Buckets ordered by total then count, capped with an explicit remainder. + * @throws Does not throw for blank category labels, which form their own bucket. + */ + private group( + rows: SalesRowDto[], + column: SalesColumnDto, + index: number, + amount?: { id: string; index: number }, + ): SalesSummaryGroupDto { + const buckets = new Map(); + let currencyCode: string | undefined; + let mixed = false; + for (const row of rows) { + const label = row.cells[index]?.label ?? ""; + const bucket = buckets.get(label) ?? { count: 0, total: 0 }; + bucket.count += 1; + const cell = amount ? row.cells[amount.index] : undefined; + if (typeof cell?.value === "number" && Number.isFinite(cell.value)) { + bucket.total += cell.value; + if (cell.currencyCode !== undefined) { + if (currencyCode === undefined) currencyCode = cell.currencyCode; + else if (currencyCode !== cell.currencyCode) mixed = true; + } + } + buckets.set(label, bucket); + } + const ordered = [...buckets.entries()] + .map(([label, bucket]) => ({ + label, + count: bucket.count, + total: Math.round(bucket.total * 100) / 100, + })) + .sort( + (left, right) => + right.total - left.total || + right.count - left.count || + left.label.localeCompare(right.label, "en", { sensitivity: "base" }), + ); + return { + columnId: column.id, + label: column.label, + ...(amount ? { amountColumnId: amount.id } : {}), + ...(currencyCode !== undefined && !mixed ? { currencyCode } : {}), + buckets: ordered.slice(0, MAX_SUMMARY_BUCKETS), + otherBuckets: Math.max(0, ordered.length - MAX_SUMMARY_BUCKETS), + }; + } + + /** + * Aggregates every matching row so counts and totals describe the filtered + * result rather than the page currently being displayed. + * @param rows Matching rows, before pagination. + * @param columns The snapshot's column schema, in cell order. + * @returns Record count, per-column amount totals and category breakdowns. + * @throws Does not throw for reports without numeric or category columns. + */ + private summarize( + rows: SalesRowDto[], + columns: SalesColumnDto[], + ): SalesSummaryDto { + const amountIndexes = columns + .map((column, index) => ({ column, index })) + .filter(({ column }) => AMOUNT_TYPES.includes(column.dataType)); + const primary = amountIndexes[0]; + return { + recordCount: rows.length, + amounts: amountIndexes.map(({ column, index }) => + this.amount(rows, column, index), + ), + groups: columns + .map((column, index) => ({ column, index })) + .filter(({ column }) => CATEGORY_TYPES.includes(column.dataType)) + .slice(0, MAX_SUMMARY_GROUPS) + .map(({ column, index }) => + this.group( + rows, + column, + index, + primary + ? { id: primary.column.id, index: primary.index } + : undefined, + ), + ), + }; + } + + /** + * 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, date range, sorting and refresh options. + * @returns Metadata, snapshot-wide aggregates and one page; total is explicitly the matched received-row count. + * @throws BadRequestException for unknown columns, incomplete filters or an inverted date range; upstream exceptions propagate. + */ + async getReport(query: SalesReportQueryDto): Promise { + if (!!query.filterColumn !== !!query.filterValue) { + throw new BadRequestException( + "filterColumn and filterValue must be supplied together.", + ); + } + if ((query.dateFrom || query.dateTo) && !query.dateColumn) { + throw new BadRequestException( + "dateColumn must be supplied with dateFrom or dateTo.", + ); + } + if (query.dateFrom && query.dateTo && query.dateFrom > query.dateTo) { + throw new BadRequestException("dateFrom must not be after dateTo."); + } + 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, + ); + const dateIndex = report.columns.findIndex( + (column) => column.id === query.dateColumn, + ); + if ( + (query.sortBy && sortIndex < 0) || + (query.filterColumn && filterIndex < 0) || + (query.dateColumn && dateIndex < 0) + ) { + throw new BadRequestException( + "Unknown report column. Use a column ID from the report response.", + ); + } + if ( + dateIndex >= 0 && + !DATE_TYPES.includes(report.columns[dateIndex].dataType) + ) { + throw new BadRequestException( + "dateColumn must reference a date or datetime column.", + ); + } + const search = query.search?.trim().toLowerCase(); + const filter = query.filterValue?.trim().toLowerCase(); + // A range only applies once a bound is given, so selecting a date field + // alone leaves the result set untouched. + const ranged = dateIndex >= 0 && !!(query.dateFrom || query.dateTo); + const rows = report.rows.filter((row) => { + if (ranged) { + // Rows without a usable date cannot satisfy a range, so they drop out + // rather than silently inflating counts and totals. + const day = this.day(row.cells[dateIndex]); + if ( + !day || + (query.dateFrom && day < query.dateFrom) || + (query.dateTo && day > query.dateTo) + ) { + return false; + } + } + return ( + (!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, + summary: this.summarize(rows, report.columns), + }; + } +} diff --git a/src/reports/sales/sales-reports.spec.ts b/src/reports/sales/sales-reports.spec.ts new file mode 100644 index 0000000..1e3b02c --- /dev/null +++ b/src/reports/sales/sales-reports.spec.ts @@ -0,0 +1,562 @@ +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("filters an inclusive date range on the selected column before counting", async () => { + const september = await service.getReport( + Object.assign(new SalesReportQueryDto(), { + dateColumn: "CLOSE_DATE", + dateFrom: "2026-09-01", + dateTo: "2026-09-30", + }), + ); + expect(september.total).toBe(1); + expect(september.rows[0].cells[0].label).toBe("Alpha"); + expect(september.sourceRowCount).toBe(3); + const openEnded = await service.getReport( + Object.assign(new SalesReportQueryDto(), { + dateColumn: "CLOSE_DATE", + dateFrom: "2026-09-01", + }), + ); + expect(openEnded.rows.map((row) => row.cells[0].label)).toEqual([ + "Alpha", + "Beta", + ]); + const upToOnly = await service.getReport( + Object.assign(new SalesReportQueryDto(), { + dateColumn: "CLOSE_DATE", + dateTo: "2026-08-31", + }), + ); + expect(upToOnly.rows.map((row) => row.cells[0].label)).toEqual(["Gamma"]); + }); + + it("selecting a date column without a bound leaves the result set untouched", async () => { + const result = await service.getReport( + Object.assign(new SalesReportQueryDto(), { dateColumn: "CLOSE_DATE" }), + ); + expect(result.total).toBe(3); + }); + + it("uses the underlying date value and drops rows the range cannot place", async () => { + const fixture = reportFixture(); + // A localized label with no usable underlying value must not be guessed at. + fixture.factMap["0!T"].rows![1].dataCells[2] = { + label: "10/1/2026", + value: null, + }; + // A datetime keeps the report's own offset; the displayed day is what counts. + fixture.factMap["0!T"].rows![0].dataCells[2] = { + label: "9/30/2026", + value: "2026-09-30T22:00:00-07:00", + }; + runReport.mockResolvedValue(fixture); + const result = await service.getReport( + Object.assign(new SalesReportQueryDto(), { + dateColumn: "CLOSE_DATE", + dateFrom: "2026-09-01", + dateTo: "2026-09-30", + }), + ); + expect(result.rows.map((row) => row.cells[0].label)).toEqual(["Alpha"]); + expect(result.total).toBe(1); + }); + + it("combines the date range with search and column filters", async () => { + const result = await service.getReport( + Object.assign(new SalesReportQueryDto(), { + dateColumn: "CLOSE_DATE", + dateFrom: "2026-08-01", + dateTo: "2026-10-31", + search: "a", + filterColumn: "NAME", + filterValue: "alpha", + }), + ); + expect(result.rows.map((row) => row.cells[0].label)).toEqual(["Alpha"]); + }); + + it("rejects incomplete, inverted and non-date range requests", async () => { + for (const query of [ + { dateFrom: "2026-09-01" }, + { dateTo: "2026-09-30" }, + { + dateColumn: "CLOSE_DATE", + dateFrom: "2026-09-30", + dateTo: "2026-09-01", + }, + { dateColumn: "AMOUNT", dateFrom: "2026-09-01" }, + { dateColumn: "missing", dateFrom: "2026-09-01" }, + ]) { + await expect( + service.getReport(Object.assign(new SalesReportQueryDto(), query)), + ).rejects.toBeInstanceOf(BadRequestException); + } + }); + + it("summarizes every matching row rather than the returned page", async () => { + const unfiltered = await service.getReport( + Object.assign(new SalesReportQueryDto(), { perPage: 1 }), + ); + expect(unfiltered.rows).toHaveLength(1); + expect(unfiltered.summary).toMatchObject({ recordCount: 3 }); + // Beta's plain 20 declares no currency, so it cannot contradict Alpha's USD. + expect(unfiltered.summary.amounts).toEqual([ + { + columnId: "AMOUNT", + label: "Amount", + total: 1020, + count: 2, + currencyCode: "USD", + }, + ]); + const september = await service.getReport( + Object.assign(new SalesReportQueryDto(), { + dateColumn: "CLOSE_DATE", + dateFrom: "2026-09-01", + dateTo: "2026-09-30", + }), + ); + expect(september.summary).toMatchObject({ recordCount: 1 }); + expect(september.summary.amounts[0]).toEqual({ + columnId: "AMOUNT", + label: "Amount", + total: 1000, + count: 1, + currencyCode: "USD", + }); + }); + + it("breaks stage groupings down by count and amount, largest total first", 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: "Closed Won", value: "Closed Won", groupings: [] }, + ], + }; + fixture.factMap["0!T"].rows![1].dataCells[1] = { + label: "$20", + value: { amount: 20, currencyCode: "USD" }, + }; + runReport.mockResolvedValue(fixture); + const result = await service.getReport(new SalesReportQueryDto()); + expect(result.summary.groups).toEqual([ + { + columnId: "STAGE_NAME", + label: "Stage", + amountColumnId: "AMOUNT", + currencyCode: "USD", + otherBuckets: 0, + buckets: [ + { label: "Proposal", count: 2, total: 1020 }, + { label: "Closed Won", count: 1, total: 0 }, + ], + }, + ]); + }); + + it("does not label a total with a currency the matching rows do not share", async () => { + const fixture = reportFixture(); + fixture.factMap["0!T"].rows![1].dataCells[1] = { + label: "\u20ac20", + value: { amount: 20, currencyCode: "EUR" }, + }; + runReport.mockResolvedValue(fixture); + const result = await service.getReport(new SalesReportQueryDto()); + expect(result.summary.amounts[0]).toEqual({ + columnId: "AMOUNT", + label: "Amount", + total: 1020, + count: 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("accepts a well-formed calendar range", async () => { + expect( + await pipe.transform( + { + dateColumn: "CLOSE_DATE", + dateFrom: "2028-02-29", + dateTo: "2026-09-30", + }, + metadata, + ), + ).toMatchObject({ + dateColumn: "CLOSE_DATE", + dateFrom: "2028-02-29", + dateTo: "2026-09-30", + }); + }); + it.each([ + { page: "0" }, + { perPage: "201" }, + { refresh: "1" }, + { sortOrder: "invalid" }, + { search: ["a", "b"] }, + { dateFrom: "09/01/2026" }, + { dateFrom: "2026-09-01T00:00:00Z" }, + { dateFrom: "2026-02-30" }, + { dateTo: "2027-02-29" }, + { dateTo: "2026-13-01" }, + ])("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.", + ); + } +} diff --git a/src/reports/win/win-reports.controller.spec.ts b/src/reports/win/win-reports.controller.spec.ts new file mode 100644 index 0000000..b61a264 --- /dev/null +++ b/src/reports/win/win-reports.controller.spec.ts @@ -0,0 +1,70 @@ +import { INestApplication, ValidationPipe } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; +import { DbModule } from "../../db/db.module"; +import { DbService } from "../../db/db.service"; +import { AuthUserLike } from "../../auth/permissions.util"; +import { WinReportsModule } from "./win-reports.module"; + +describe("WIN endpoint", () => { + let app: INestApplication; + let url: string; + let authUser: AuthUserLike | undefined; + const db = { query: jest.fn() }; + + beforeAll(async () => { + const module = await Test.createTestingModule({ imports: [DbModule, WinReportsModule] }) + .overrideProvider(DbService).useValue(db).compile(); + app = module.createNestApplication(); + app.setGlobalPrefix("v6/reports"); + app.use((req, _res, next) => { req.authUser = authUser; next(); }); + app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); + await app.listen(0, "127.0.0.1"); + url = `${await app.getUrl()}/v6/reports/WIN`; + }); + + afterAll(async () => { await app.close(); }); + + beforeEach(() => { + db.query.mockReset().mockResolvedValue([{ data: [], total: 0 }]); + authUser = { isMachine: true, scopes: ["reports:win"] }; + }); + + it.each([ + { isMachine: true, scopes: ["reports:win"] }, + { isMachine: false, scopes: "openid reports:win" }, + { roles: ["Administrator"] }, + { role: "Topcoder Talent Manager" }, + ])("allows the requested scope or human role: %j", async (user) => { + authUser = user; + const response = await fetch(url); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ data: [], total: 0, page: 1, perPage: 100 }); + expect(db.query).toHaveBeenCalledWith(expect.any(String), [null, 100, 0]); + }); + + it.each([ + undefined, + { roles: ["Project Manager"] }, + { scopes: ["reports:all"] }, + { isMachine: true, roles: ["Administrator"] }, + { isMachine: true, scopes: ["reports:win-other"] }, + ])("denies callers without WIN access: %j", async (user) => { + authUser = user; + expect((await fetch(url)).status).toBe(user ? 403 : 401); + expect(db.query).not.toHaveBeenCalled(); + }); + + it.each(["page=0", "page=1.5", "perPage=101", "projectId=1%20OR%201=1", "projectId=9223372036854775808"])( + "rejects invalid query %s before reading data", async (query) => { + expect((await fetch(`${url}?${query}`)).status).toBe(400); + expect(db.query).not.toHaveBeenCalled(); + }, + ); + + it("binds filters and preserves totals on an empty later page", async () => { + db.query.mockResolvedValue([{ data: [], total: 7 }]); + const response = await fetch(`${url}?projectId=9007199254740993&page=3&perPage=10`); + expect(await response.json()).toEqual({ data: [], total: 7, page: 3, perPage: 10 }); + expect(db.query).toHaveBeenCalledWith(expect.any(String), ["9007199254740993", 10, 20]); + }); +}); diff --git a/src/reports/win/win-reports.controller.ts b/src/reports/win/win-reports.controller.ts new file mode 100644 index 0000000..958c0cc --- /dev/null +++ b/src/reports/win/win-reports.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, Query, UseGuards } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { Scopes as AppScopes } from "../../app-constants"; +import { Scopes } from "../../auth/decorators/scopes.decorator"; +import { PermissionsGuard } from "../../auth/guards/permissions.guard"; +import { WinReportQueryDto, WinReportResponseDto } from "./win-reports.dto"; +import { WinReportsService } from "./win-reports.service"; + +/** Authenticated pull endpoint for showcase posts explicitly shared with WIN. */ +@ApiTags("WIN") +@ApiBearerAuth() +@UseGuards(PermissionsGuard) +@Scopes(AppScopes.WIN) +@Controller("WIN") +export class WinReportsController { + /** + * @param service WIN report reader injected by the module. + * @returns An authenticated WIN controller. + * @throws Does not throw during construction. + */ + constructor(private readonly service: WinReportsService) {} + + /** + * Exposes opted-in showcase and project metadata to authorized API callers. + * @param query Optional project ID and bounded pagination. + * @returns A page of WIN showcase records and its total count. + * @throws 400 for invalid filters, 401/403 for denied access, or database errors. + */ + @Get() + @ApiOperation({ + summary: "Showcases shared with WIN", + description: "Requires reports:win scope, or an Administrator or Talent Manager user role.", + }) + @ApiResponse({ status: 200, type: WinReportResponseDto }) + @ApiResponse({ status: 400, description: "Invalid query parameters" }) + @ApiResponse({ status: 401, description: "Unauthenticated" }) + @ApiResponse({ status: 403, description: "Missing WIN scope or role" }) + getReport(@Query() query: WinReportQueryDto): Promise { + return this.service.getReport(query); + } +} diff --git a/src/reports/win/win-reports.dto.ts b/src/reports/win/win-reports.dto.ts new file mode 100644 index 0000000..849a2d9 --- /dev/null +++ b/src/reports/win/win-reports.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsInt, IsOptional, Matches, Max, Min } from "class-validator"; + +/** Filters and bounded pagination accepted by GET /v6/reports/WIN. */ +export class WinReportQueryDto { + @ApiPropertyOptional({ description: "Project ID, represented as a string." }) + @IsOptional() + @Matches(/^[1-9]\d{0,17}$/) + projectId?: string; + + @ApiPropertyOptional({ default: 1, minimum: 1, maximum: 1000000 }) + @Type(() => Number) + @IsInt() + @Min(1) + @Max(1000000) + page = 1; + + @ApiPropertyOptional({ default: 100, minimum: 1, maximum: 100 }) + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + perPage = 100; +} + +/** + * WIN export rows retain all stored showcase fields and attach current project, + * taxonomy and media metadata. IDs are strings to preserve bigint precision. + */ +export class WinShowcasePostDto { + [key: string]: unknown; + + @ApiProperty() id: string; + @ApiProperty() projectId: string; + @ApiProperty() title: string; + @ApiPropertyOptional() type: string | null; + @ApiPropertyOptional() customer: string | null; + @ApiPropertyOptional() smu: string | null; + @ApiPropertyOptional() smuOther: string | null; + @ApiPropertyOptional({ description: "YYYY-MM-DD calendar date." }) + dealCloseDate: string | null; + @ApiPropertyOptional() challenge: string | null; + @ApiProperty({ description: "The Solution, in the existing content field." }) + content: string; + @ApiPropertyOptional() businessImpact: string | null; + @ApiPropertyOptional() keyWin: string | null; + @ApiPropertyOptional() currentStatus: string | null; + @ApiPropertyOptional() owner: string | null; + @ApiProperty() sendToWin: boolean; + @ApiProperty({ type: [Object] }) challengeMetadata: Record[]; + @ApiProperty({ type: [Object] }) industries: Record[]; + @ApiProperty({ type: [Object] }) categories: Record[]; + @ApiProperty({ type: [Object] }) media: Record[]; + @ApiProperty({ type: Object }) project: Record; +} + +/** Paginated snapshot returned to a WIN API caller, including an empty-page total. */ +export class WinReportResponseDto { + @ApiProperty({ type: [WinShowcasePostDto] }) data: WinShowcasePostDto[]; + @ApiProperty() total: number; + @ApiProperty() page: number; + @ApiProperty() perPage: number; +} diff --git a/src/reports/win/win-reports.integration.spec.ts b/src/reports/win/win-reports.integration.spec.ts new file mode 100644 index 0000000..8f1a618 --- /dev/null +++ b/src/reports/win/win-reports.integration.spec.ts @@ -0,0 +1,104 @@ +import { readFileSync } from "fs"; +import { resolve } from "path"; +import { Client } from "pg"; + +const databaseTests = process.env.WIN_TEST_DATABASE_URL ? describe : describe.skip; + +// Run against a disposable PostgreSQL database with the projects-api-v6 migrations applied. +// All fixture writes are rolled back, including when an assertion fails. +databaseTests("WIN report SQL with PostgreSQL", () => { + const db = new Client({ connectionString: process.env.WIN_TEST_DATABASE_URL }); + const sql = readFileSync(resolve(process.cwd(), "sql/reports/win/showcase.sql"), "utf8"); + const projectId = "9007199254740993"; + + beforeAll(async () => { + await db.connect(); + await db.query("BEGIN"); + await db.query(` + CREATE SCHEMA challenges; + CREATE SCHEMA resources; + CREATE SCHEMA members; + CREATE SCHEMA skills; + CREATE TABLE challenges."Challenge" (id text PRIMARY KEY, name text, "trackId" text, "numOfRegistrants" integer, "numOfSubmissions" integer); + CREATE TABLE challenges."ChallengeTrack" (id text PRIMARY KEY, name text); + CREATE TABLE challenges."ChallengeSkill" ("challengeId" text, "skillId" text); + CREATE TABLE skills.skill (id uuid PRIMARY KEY, name text); + CREATE TABLE resources."ResourceRole" (id text PRIMARY KEY, name text); + CREATE TABLE resources."Resource" ("challengeId" text, "memberId" text, "roleId" text); + CREATE TABLE members.member ("userId" bigint PRIMARY KEY, "competitionCountryCode" text, country text, "homeCountryCode" text); + INSERT INTO challenges."Challenge" VALUES ('challenge-id', 'Linked challenge', 'track-id', 3, 2); + INSERT INTO challenges."ChallengeTrack" VALUES ('track-id', 'Development'); + INSERT INTO challenges."ChallengeSkill" VALUES ('challenge-id', '11111111-1111-4111-8111-111111111111'); + INSERT INTO skills.skill VALUES ('11111111-1111-4111-8111-111111111111', 'Skill'); + INSERT INTO resources."ResourceRole" VALUES ('submitter', 'Submitter'), ('reviewer', 'Reviewer'); + INSERT INTO resources."Resource" VALUES ('challenge-id', '42', 'submitter'), ('challenge-id', '43', 'reviewer'); + INSERT INTO members.member VALUES (42, 'US', 'CA', 'GB'), (43, 'AU', 'AU', 'AU'); + INSERT INTO projects.projects + (id, name, type, status, details, "lastActivityAt", "lastActivityUserId", "updatedAt", "createdBy", "updatedBy", "deletedAt") + VALUES + (9007199254740993, 'WIN project', 'app', 'active', + '{"customer":"Customer","smu":"Europe","dealCloseDate":"2026-09-16","unrelated":true}', now(), '42', now(), 42, 42, NULL), + (9007199254740994, 'Deleted project', 'app', 'active', '{}', now(), '42', now(), 42, 42, now()); + INSERT INTO projects.project_showcase_posts + (id, title, content, status, "projectId", "createdById", "updatedById", "updatedAt", type, challenge, + "businessImpact", "keyWin", "currentStatus", owner, "sendToWin", "challengeIds") + VALUES + (9007199254740993, 'Draft opt-in', 'Solution', 'DRAFT', 9007199254740993, 42, 42, now(), + 'Open Innovation', 'Challenge', 'Impact', 'Win', 'Delivered', 'Owner', true, ARRAY['challenge-id']), + (9007199254740994, 'Published opt-in', 'Solution', 'PUBLISHED', 9007199254740993, 42, 42, now(), + NULL, NULL, NULL, NULL, NULL, NULL, true, ARRAY[]::text[]), + (9007199254740995, 'Not shared', 'Solution', 'PUBLISHED', 9007199254740993, 42, 42, now(), + NULL, NULL, NULL, NULL, NULL, NULL, false, ARRAY[]::text[]), + (9007199254740996, 'Archived', 'Solution', 'ARCHIVED', 9007199254740993, 42, 42, now(), + NULL, NULL, NULL, NULL, NULL, NULL, true, ARRAY[]::text[]), + (9007199254740997, 'Deleted project post', 'Solution', 'PUBLISHED', 9007199254740994, 42, 42, now(), + NULL, NULL, NULL, NULL, NULL, NULL, true, ARRAY[]::text[]); + INSERT INTO projects.project_post_industries (id, name) VALUES (9007199254740993, 'WIN industry'); + INSERT INTO projects.project_post_categories (id, name) VALUES (9007199254740993, 'WIN technology'); + INSERT INTO projects.project_showcase_post_industries ("projectShowcasePostId", "industryId") + VALUES (9007199254740993, 9007199254740993); + INSERT INTO projects.project_showcase_post_categories ("projectShowcasePostId", "categoryId") + VALUES (9007199254740993, 9007199254740993); + INSERT INTO projects.project_showcase_post_media ("projectShowcasePostId", type, url, "createdBy") + VALUES (9007199254740993, 'image/png', 'https://example.com/win.png', 42); + SAVEPOINT fixture; + `); + }); + + afterEach(async () => { await db.query("ROLLBACK TO SAVEPOINT fixture"); }); + afterAll(async () => { await db.query("ROLLBACK"); await db.end(); }); + + it("includes opted-in drafts and published posts, with complete metadata and precise IDs", async () => { + const result = (await db.query(sql, [null, 100, 0])).rows[0]; + expect(result.total).toBe(2); + expect(result.data).toHaveLength(2); + expect(result.data[0]).toMatchObject({ + id: projectId, projectId, title: "Draft opt-in", type: "Open Innovation", content: "Solution", + challenge: "Challenge", businessImpact: "Impact", keyWin: "Win", currentStatus: "Delivered", owner: "Owner", + customer: "Customer", smu: "Europe", dealCloseDate: "2026-09-16", challengeIds: ["challenge-id"], + project: { id: projectId, details: { unrelated: true } }, + challengeMetadata: [{ + challengeId: 'challenge-id', name: 'Linked challenge', numOfRegistrants: 3, numOfSubmissions: 2, + track: 'Development', countries: ['US'], skills: [{ id: '11111111-1111-4111-8111-111111111111', name: 'Skill' }], + }], + industries: [{ id: projectId, name: "WIN industry" }], + categories: [{ id: projectId, name: "WIN technology" }], + media: [{ url: "https://example.com/win.png", createdBy: "42" }], + }); + }); + + it("reads project edits immediately and removes an opt-out from the result", async () => { + await db.query(`UPDATE projects.projects SET details = details || '{"customer":"Updated","smu":"Others","smuOther":"Custom"}' WHERE id = $1`, [projectId]); + expect((await db.query(sql, [projectId, 100, 0])).rows[0].data[0]).toMatchObject({ + customer: "Updated", smu: "Others", smuOther: "Custom", + }); + await db.query('UPDATE projects.project_showcase_posts SET "sendToWin" = false WHERE id = $1', [projectId]); + expect((await db.query(sql, [projectId, 100, 0])).rows[0].total).toBe(1); + }); + + it("paginates deterministically and returns a count even for an empty page", async () => { + expect((await db.query(sql, [projectId, 1, 1])).rows[0].data[0].id).toBe("9007199254740994"); + expect((await db.query(sql, [projectId, 1, 2])).rows[0]).toEqual({ data: [], total: 2 }); + expect((await db.query(sql, ["1", 100, 0])).rows[0]).toEqual({ data: [], total: 0 }); + }); +}); diff --git a/src/reports/win/win-reports.module.ts b/src/reports/win/win-reports.module.ts new file mode 100644 index 0000000..5f97819 --- /dev/null +++ b/src/reports/win/win-reports.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { SqlLoaderService } from "../../common/sql-loader.service"; +import { WinReportsController } from "./win-reports.controller"; +import { WinReportsService } from "./win-reports.service"; + +/** Registers the WIN endpoint and its SQL-backed report reader. */ +@Module({ + controllers: [WinReportsController], + providers: [WinReportsService, SqlLoaderService], +}) +export class WinReportsModule {} diff --git a/src/reports/win/win-reports.service.ts b/src/reports/win/win-reports.service.ts new file mode 100644 index 0000000..79ea638 --- /dev/null +++ b/src/reports/win/win-reports.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from "@nestjs/common"; +import { SqlLoaderService } from "../../common/sql-loader.service"; +import { DbService } from "../../db/db.service"; +import { WinReportQueryDto, WinReportResponseDto } from "./win-reports.dto"; + +/** Loads opted-in showcases with current project metadata for the WIN integration. */ +@Injectable() +export class WinReportsService { + /** + * @param db Shared reporting database connection. + * @param sql Repository SQL loader used by report services. + * @returns A service ready to execute the WIN query. + * @throws Does not throw during construction. + */ + constructor( + private readonly db: DbService, + private readonly sql: SqlLoaderService, + ) {} + + /** + * Reads a consistent page and total from the current opted-in showcase records. + * @param query Validated project filter and pagination from the controller. + * @returns Current metadata, ordered by post ID, with pagination information. + * @throws Propagates SQL loading and database errors to Nest's error handler. + */ + async getReport(query: WinReportQueryDto): Promise { + const rows = await this.db.query>( + this.sql.load("reports/win/showcase.sql"), + [query.projectId ?? null, query.perPage, (query.page - 1) * query.perPage], + ); + return { ...rows[0], page: query.page, perPage: query.perPage }; + } +} diff --git a/src/statistics/expert-skills-statistics.service.spec.ts b/src/statistics/expert-skills-statistics.service.spec.ts index 3cc8b1f..da0d58b 100644 --- a/src/statistics/expert-skills-statistics.service.spec.ts +++ b/src/statistics/expert-skills-statistics.service.spec.ts @@ -1,4 +1,5 @@ import { NotFoundException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { DbService } from "../db/db.service"; import { SqlLoaderService } from "../common/sql-loader.service"; import { ExpertSkillsStatisticsService } from "./expert-skills-statistics.service"; @@ -10,9 +11,18 @@ describe("ExpertSkillsStatisticsService", () => { const sql = { load: jest.fn().mockReturnValue("SELECT expert skills"), }; + const config = { + get: jest.fn((key: string, defaultValue?: string) => { + if (key === "REPORTS_EXCLUDED_USER_IDS") { + return '["22838965", "8547899"]'; + } + return defaultValue; + }), + }; const service = new ExpertSkillsStatisticsService( db as unknown as DbService, sql as unknown as SqlLoaderService, + config as unknown as ConfigService, ); beforeEach(() => { @@ -63,6 +73,7 @@ describe("ExpertSkillsStatisticsService", () => { "481b5ebc-2fe6-45ed-a90c-736936d458d7", "1f5ed3e8-8d22-44ea-b75d-ea85147a04da", ], + ["22838965", "8547899"], ]); expect(result[0]).toEqual( expect.objectContaining({ @@ -126,6 +137,7 @@ describe("ExpertSkillsStatisticsService", () => { expect(db.query).toHaveBeenNthCalledWith(2, "SELECT expert skills", [ "481b5ebc-2fe6-45ed-a90c-736936d458d7", 100, + ["22838965", "8547899"], ]); expect(result).toEqual([ { diff --git a/src/statistics/expert-skills-statistics.service.ts b/src/statistics/expert-skills-statistics.service.ts index 49f86ec..b04503c 100644 --- a/src/statistics/expert-skills-statistics.service.ts +++ b/src/statistics/expert-skills-statistics.service.ts @@ -1,4 +1,5 @@ import { Injectable, NotFoundException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { alpha3ToCountryName, toAlpha2CountryCode, @@ -76,10 +77,45 @@ function formatMemberName( @Injectable() export class ExpertSkillsStatisticsService { + private readonly excludedUserIds: string[]; + constructor( private readonly db: DbService, private readonly sql: SqlLoaderService, - ) {} + private readonly config: ConfigService, + ) { + this.excludedUserIds = this.parseListConfig( + "REPORTS_EXCLUDED_USER_IDS", + "[]", + ); + } + + // Accepts either a JSON array string ('["1","2"]') or a comma-separated list. + private parseListConfig(key: string, defaultValue: string): string[] { + const raw = (this.config.get(key, defaultValue) ?? "").trim(); + + if (!raw) { + return []; + } + + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed + .map((item) => + typeof item === "number" ? String(item) : String(item ?? "").trim(), + ) + .filter(Boolean); + } + } catch { + // ignore JSON parse failure and fall back to comma-separated values + } + + return raw + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } async getCategories() { const categories = await this.loadCategories(); @@ -122,6 +158,7 @@ export class ExpertSkillsStatisticsService { const rows = await this.db.query(q, [ category.id, MEMBERS_LIMIT, + this.excludedUserIds, ]); return rows.map((row) => { @@ -180,7 +217,10 @@ export class ExpertSkillsStatisticsService { const q = this.sql.load( "reports/statistics/expert-skills/category-stats.sql", ); - const rows = await this.db.query(q, [categoryIds]); + const rows = await this.db.query(q, [ + categoryIds, + this.excludedUserIds, + ]); return new Map(rows.map((row) => [row.id, row])); } diff --git a/src/statistics/statistics-expert-skills.sql.spec.ts b/src/statistics/statistics-expert-skills.sql.spec.ts index 0e9a0ee..d1d5026 100644 --- a/src/statistics/statistics-expert-skills.sql.spec.ts +++ b/src/statistics/statistics-expert-skills.sql.spec.ts @@ -25,6 +25,8 @@ describe("Expert skills statistics SQL", () => { expect(sql).toContain("challenge_win"); expect(sql).toContain("gig_completion"); expect(sql).toContain("ts.rn <= 3"); + expect(sql).toContain("NOT (us.user_id::text = ANY($2))"); + expect(sql).toContain("NOT (se.user_id::text = ANY($2))"); expect(sql).not.toContain("NOT ILIKE 'Test Cat%'"); }); @@ -37,6 +39,7 @@ describe("Expert skills statistics SQL", () => { expect(sql).toContain("JOIN members.member m"); expect(sql).toContain('members."memberMaxRating"'); expect(sql).toContain("ORDER BY cw.wins DESC, m.handle ASC"); + expect(sql).toContain("NOT (se.user_id::text = ANY($3))"); expect(sql).toContain("LIMIT $2"); }); });