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