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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
60 changes: 60 additions & 0 deletions WIN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# WIN showcase export

`GET /v6/reports/WIN` returns showcase posts explicitly shared using **Send to WIN**.
Access requires a JWT with the `reports:win` scope, or an authenticated human with
the Administrator or Talent Manager role. The general `reports:all` scope alone
does not grant access. Role and scope normalization follow the existing reports
permission checks. The report is also listed in the report directory for these callers.

## Request and response

Optional query parameters:

| Parameter | Default | Meaning |
| --- | --- | --- |
| `projectId` | all projects | Positive numeric string, up to 18 digits |
| `page` | 1 | Page number, 1–1,000,000 |
| `perPage` | 100 | Page size, 1–100 |

```http
GET /v6/reports/WIN?projectId=123&page=1&perPage=100
Authorization: Bearer <JWT>
```

The response is `{ "data": [...], "total": 0, "page": 1, "perPage": 100 }`.
`total` counts all matches, including when a later page is empty. Rows are ordered
by post ID. Repeat requests read current records; this endpoint does not mark
records as delivered or push them to another service.

Each row contains all stored showcase fields, including `title`, `type`,
`challenge`, `content` (The Solution), `businessImpact`, `keyWin`, `currentStatus`,
`owner`, `sendToWin`, lifecycle `status`, `challengeIds`, and publication/audit
metadata. `industries`, `categories`, and `media` are arrays with their stored
metadata. Media URLs are the stored asset URLs. `challengeMetadata` includes linked
challenge names, submission/registration counts, track, skills, and submitter countries.

`customer`, `smu`, `smuOther`, and `dealCloseDate` come from the current project
details, so changes made in either Work form appear immediately. For `smu: "Others"`,
use `smuOther` as the custom SMU value. `dealCloseDate` is a date-only string.
`project` contains the project's stored scalar fields and JSON metadata. Bigint
post/project/taxonomy/media IDs are serialized as strings. Missing optional fields
may be null on older posts.

Opted-in drafts and published posts are included. Opted-out and archived posts,
and posts belonging to deleted projects, are excluded. Invalid query parameters
return 400; missing authentication returns 401; insufficient access returns 403.

## Deployment and tests

The reporting `DATABASE_URL` needs read access to the `projects` schema including
the showcase taxonomy/media tables, plus `challenges`, `resources`, `members`,
and `skills` for linked challenge metadata. First deploy the projects-api-v6 migration
`20260916000000_showcase_win_metadata` and its application changes, then deploy
this endpoint and the platform-ui changes. No WIN push URL is required.

After `nvm use`, run `pnpm lint`, `pnpm build`, and
`pnpm test --runInBand win report-directory permissions.util`. Set
`WIN_TEST_DATABASE_URL` to a disposable PostgreSQL database with the projects API
migrations applied to run the real SQL tests. Use a database containing only the
projects schema; the tests create minimal reference-schema fixtures for challenges,
resources, members and skills. All fixtures run in a transaction and are rolled back.
82 changes: 82 additions & 0 deletions sql/reports/win/showcase.sql
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions src/app-constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const Scopes = {
WIN: "reports:win",
TopgearHourly: "reports:topgear-hourly",
TopgearHandles: "reports:topgear-handles",
TopgearPayments: "reports:topgear-payments",
Expand Down Expand Up @@ -60,6 +61,7 @@ const challengeReportAccessRoles = [
const sfdcReportsTalentManagerRoles = [UserRoles.TalentManager] as const;

export const ScopeRoleAccess: Record<string, readonly string[]> = {
[Scopes.WIN]: [UserRoles.TalentManager],
[Scopes.Challenge.History]: challengeReportAccessRoles,
[Scopes.Challenge.Registrants]: challengeReportAccessRoles,
[Scopes.Challenge.SubmissionLinks]: challengeReportAccessRoles,
Expand Down
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -26,6 +27,7 @@ import { DashboardReportsModule } from "./reports/dashboard/dashboard-reports.mo
ChallengesReportsModule,
IdentityReportsModule,
ReportsModule,
WinReportsModule,
MemberSearchModule,
PaymentReportsModule,
DashboardReportsModule,
Expand Down
6 changes: 6 additions & 0 deletions src/reports/report-directory.data.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ describe("getAccessibleReportsDirectory", () => {
"member",
"sfdc",
"statistics",
"win",
]);
expect(directory.identity?.reports.map((report) => report.path)).toEqual([
"/identity/users-by-handles",
Expand Down Expand Up @@ -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();
});

});
18 changes: 17 additions & 1 deletion src/reports/report-directory.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ export type ReportGroupKey =
| "topcoder"
| "member"
| "payment"
| "identity";
| "identity"
| "win";

type HttpMethod = "GET" | "POST";

Expand Down Expand Up @@ -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",
Expand Down
70 changes: 70 additions & 0 deletions src/reports/win/win-reports.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
41 changes: 41 additions & 0 deletions src/reports/win/win-reports.controller.ts
Original file line number Diff line number Diff line change
@@ -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<WinReportResponseDto> {
return this.service.getReport(query);
}
}
Loading
Loading