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 }; + } +}