Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ workflows:
only:
- develop
- PM-4931
- skill-statistics
tags:
only: /^dev-.*/

Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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.
133 changes: 133 additions & 0 deletions SALES.md
Original file line number Diff line number Diff line change
@@ -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`.
60 changes: 60 additions & 0 deletions WIN.md
Original file line number Diff line number Diff line change
@@ -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 <JWT>
```

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.
10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading