From 3aeed516574c31bb6831b20b2f35ea9916d2c26c Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 14 Nov 2025 12:22:01 +1100 Subject: [PATCH 1/4] MM stats for future use, based on what Daniela asked for for the 2025 MM finals --- sql/reports/topcoder/mm-stats.sql | 60 ++++++++++++++++ .../topcoder/topcoder-reports.controller.ts | 10 ++- .../topcoder/topcoder-reports.service.ts | 71 ++++++++++++++++++- 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 sql/reports/topcoder/mm-stats.sql diff --git a/sql/reports/topcoder/mm-stats.sql b/sql/reports/topcoder/mm-stats.sql new file mode 100644 index 0000000..64f4255 --- /dev/null +++ b/sql/reports/topcoder/mm-stats.sql @@ -0,0 +1,60 @@ +WITH member_base AS ( + SELECT + mem."userId", + mem.handle, + mem."firstName", + mem."lastName", + COALESCE( + comp_code.name, + comp_id.name, + NULLIF(TRIM(mem."competitionCountryCode"), '') + ) AS competition_country, + usr.create_date AS member_since + FROM members."member" AS mem + LEFT JOIN identity."user" AS usr + ON usr.user_id::bigint = mem."userId" + LEFT JOIN lookups."Country" AS comp_code + ON UPPER(comp_code."countryCode") = UPPER(mem."competitionCountryCode") + LEFT JOIN lookups."Country" AS comp_id + ON UPPER(comp_id.id) = UPPER(mem."competitionCountryCode") + WHERE mem."handleLower" = LOWER($1) +) +SELECT + mb.handle, + mb."firstName" AS first_name, + mb."lastName" AS last_name, + mb.competition_country, + mb.member_since, + marathon_stats.rating AS marathon_match_rating, + marathon_stats.rank AS marathon_match_rank, + max_rating.max_rating AS highest_track_rating, + marathon_stats.challenges AS marathon_matches_registered, + marathon_stats.wins AS marathon_match_wins, + marathon_stats."topFiveFinishes" AS marathon_match_top_five_finishes, + marathon_stats."avgRank" AS average_marathon_match_placement, + CASE + WHEN marathon_stats.challenges IS NULL + OR marathon_stats.challenges = 0 THEN NULL + ELSE marathon_stats.competitions::double precision + / marathon_stats.challenges::double precision + END AS marathon_submission_rate +FROM member_base AS mb +LEFT JOIN LATERAL ( + SELECT MAX(mmr.rating) AS max_rating + FROM members."memberMaxRating" AS mmr + WHERE mmr."userId" = mb."userId" +) AS max_rating ON TRUE +LEFT JOIN LATERAL ( + SELECT mmar.* + FROM members."memberStats" AS ms + JOIN members."memberDataScienceStats" AS mds + ON mds."memberStatsId" = ms.id + JOIN members."memberMarathonStats" AS mmar + ON mmar."dataScienceStatsId" = mds.id + WHERE ms."userId" = mb."userId" + ORDER BY + CASE WHEN ms."isPrivate" THEN 1 ELSE 0 END, + ms."updatedAt" DESC NULLS LAST, + ms.id DESC + LIMIT 1 +) AS marathon_stats ON TRUE; diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index 856f031..d44994f 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Query, Res, UseGuards } from "@nestjs/common"; +import { Controller, Get, Param, Query, Res, UseGuards } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { Response } from "express"; import { TopcoderReportsService } from "./topcoder-reports.service"; @@ -37,6 +37,14 @@ export class TopcoderReportsController { res.send(csv); } + @Get("/mm-stats/:handle") + @ApiOperation({ + summary: "Marathon match performance snapshot for a specific handle", + }) + getMarathonMatchStats(@Param("handle") handle: string) { + return this.reports.getMarathonMatchStats(handle); + } + @Get("/total-copilots") @ApiOperation({ summary: "Total number of Copilots" }) getTotalCopilots() { diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 21df975..6cd4780 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, NotFoundException } from "@nestjs/common"; import { DbService } from "../../db/db.service"; import { SqlLoaderService } from "../../common/sql-loader.service"; @@ -9,6 +9,22 @@ type RegistrantCountriesRow = { competition_country: string | null; }; +type MarathonMatchStatsRow = { + handle: string; + first_name: string | null; + last_name: string | null; + competition_country: string | null; + member_since: Date | string | null; + marathon_match_rating: string | number | null; + marathon_match_rank: string | number | null; + highest_track_rating: string | number | null; + marathon_matches_registered: string | number | null; + marathon_match_wins: string | number | null; + marathon_match_top_five_finishes: string | number | null; + average_marathon_match_placement: string | number | null; + marathon_submission_rate: string | number | null; +}; + @Injectable() export class TopcoderReportsService { constructor( @@ -403,6 +419,42 @@ export class TopcoderReportsService { return this.rowsToCsv(rows); } + async getMarathonMatchStats(handle: string) { + const query = this.sql.load("reports/topcoder/mm-stats.sql"); + const rows = await this.db.query(query, [handle]); + const row = rows?.[0]; + + if (!row) { + throw new NotFoundException( + `No member marathon stats found for handle: ${handle}`, + ); + } + + return { + handle: row.handle, + firstName: row.first_name ?? null, + lastName: row.last_name ?? null, + competitionCountry: row.competition_country ?? null, + memberSince: this.normalizeDate(row.member_since), + marathonMatchRating: this.toNullableNumber(row.marathon_match_rating), + marathonMatchRank: this.toNullableNumber(row.marathon_match_rank), + highestTrackRating: this.toNullableNumber(row.highest_track_rating), + marathonMatchesRegistered: this.toNullableNumber( + row.marathon_matches_registered, + ), + marathonMatchWins: this.toNullableNumber(row.marathon_match_wins), + marathonMatchTopFiveFinishes: this.toNullableNumber( + row.marathon_match_top_five_finishes, + ), + averageMarathonMatchPlacement: this.toNullableNumber( + row.average_marathon_match_placement, + ), + marathonSubmissionRate: this.toNullableNumber( + row.marathon_submission_rate, + ), + }; + } + private rowsToCsv(rows: RegistrantCountriesRow[]) { const header = [ "Handle", @@ -439,4 +491,21 @@ export class TopcoderReportsService { const escaped = text.replace(/"/g, '""'); return `"${escaped}"`; } + + private toNullableNumber(value: string | number | null | undefined) { + if (value === null || value === undefined) { + return null; + } + return Number(value); + } + + private normalizeDate(value: Date | string | null | undefined) { + if (value === null || value === undefined) { + return null; + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; + } } From 47f43d5a9a6a3f4acb0704651d555a70c505dd1c Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 20 Nov 2025 09:28:28 +1100 Subject: [PATCH 2/4] Add 30 day reports endpoint for Tony --- sql/reports/topcoder/30-day-payments.sql | 74 +++++++++++++++++++ .../topcoder/challenge-type-submissions.sql | 69 +++++++++++++++++ .../topcoder/topcoder-reports.controller.ts | 8 ++ .../topcoder/topcoder-reports.service.ts | 38 ++++++++++ 4 files changed, 189 insertions(+) create mode 100644 sql/reports/topcoder/30-day-payments.sql create mode 100644 sql/reports/topcoder/challenge-type-submissions.sql diff --git a/sql/reports/topcoder/30-day-payments.sql b/sql/reports/topcoder/30-day-payments.sql new file mode 100644 index 0000000..f44b558 --- /dev/null +++ b/sql/reports/topcoder/30-day-payments.sql @@ -0,0 +1,74 @@ +WITH latest_payment AS ( + SELECT + p.winnings_id, + MAX(p.version) AS max_version + FROM finance.payment p + GROUP BY p.winnings_id +), +recent_payments AS ( + SELECT + w.winning_id, + w.winner_id, + w.category, + w.external_id AS challenge_id, + w.created_at AS winning_created_at, + p.payment_id, + p.payment_status, + p.installment_number, + p.billing_account, + p.total_amount, + p.gross_amount, + p.challenge_fee, + p.challenge_markup, + p.date_paid, + p.created_at AS payment_created_at + FROM finance.winnings w + JOIN finance.payment p + ON p.winnings_id = w.winning_id + JOIN latest_payment lp + ON lp.winnings_id = p.winnings_id + AND lp.max_version = p.version + WHERE w.type = 'PAYMENT' + AND p.installment_number = 1 + AND p.payment_status = 'PAID' + AND COALESCE(p.date_paid, p.created_at) >= (CURRENT_DATE - INTERVAL '3 months') +) +SELECT + cl."name" AS customer, + COALESCE(c."projectId"::text, ba."projectId") AS project_id, + proj.name AS project_name, + ba.id::text AS billing_account_id, + ba."name" AS billing_account_name, + rp.challenge_id, + c."name" AS challenge_name, + c."createdAt" AS challenge_created_at, + rp.winner_id AS member_id, + mem.handle AS member_handle, + CASE + WHEN rp.category::text ILIKE '%REVIEW%' THEN 'review' + WHEN rp.category::text ILIKE '%COPILOT%' THEN 'copilot' + ELSE 'prize' + END AS payment_type, + COALESCE(rp.gross_amount, rp.total_amount) AS member_payment, + COALESCE( + rp.challenge_fee, + COALESCE(rp.gross_amount, rp.total_amount) * (rp.challenge_markup / 100.0) + ) AS fee, + COALESCE(rp.date_paid, rp.payment_created_at) AS payment_date +FROM recent_payments rp +LEFT JOIN challenges."Challenge" c + ON c."id" = rp.challenge_id +LEFT JOIN challenges."ChallengeBilling" cb + ON cb."challengeId" = c."id" +LEFT JOIN "billing-accounts"."BillingAccount" ba + ON ba."id" = COALESCE( + NULLIF(rp.billing_account, '')::int, + NULLIF(cb."billingAccountId", '')::int + ) +LEFT JOIN "billing-accounts"."Client" cl + ON cl."id" = ba."clientId" +LEFT JOIN projects.projects proj + ON proj.id = c."projectId"::bigint +LEFT JOIN members.member mem + ON mem."userId"::text = rp.winner_id +ORDER BY payment_date DESC, rp.payment_created_at DESC; diff --git a/sql/reports/topcoder/challenge-type-submissions.sql b/sql/reports/topcoder/challenge-type-submissions.sql new file mode 100644 index 0000000..2d61ade --- /dev/null +++ b/sql/reports/topcoder/challenge-type-submissions.sql @@ -0,0 +1,69 @@ +WITH typed_challenges AS ( + SELECT + c.id, + c.name + FROM challenges."Challenge" AS c + WHERE c."typeId" = '929bc408-9cf2-4b3e-ba71-adfbf693046c' +), +submission_participants AS ( + SELECT DISTINCT ON (s."memberId", tc.id) + s."memberId"::bigint AS member_id, + COALESCE(NULLIF(TRIM(m.handle), ''), m.handle) AS member_handle, + tc.id AS challenge_id, + s.placement + FROM typed_challenges AS tc + JOIN reviews."submission" AS s + ON s."challengeId" = tc.id + LEFT JOIN members."member" AS m + ON m."userId" = s."memberId"::bigint + ORDER BY + s."memberId", + tc.id, + s.placement NULLS FIRST, + s.id DESC +), +winner_participants AS ( + SELECT DISTINCT ON (cw."userId", tc.id) + cw."userId"::bigint AS member_id, + COALESCE( + NULLIF(TRIM(cw.handle), ''), + NULLIF(TRIM(m.handle), ''), + m.handle + ) AS member_handle, + tc.id AS challenge_id, + cw.placement + FROM typed_challenges AS tc + JOIN challenges."ChallengeWinner" AS cw + ON cw."challengeId" = tc.id + LEFT JOIN members."member" AS m + ON m."userId" = cw."userId"::bigint + ORDER BY + cw."userId", + tc.id, + cw.placement NULLS FIRST, + cw.id DESC +), +combined_participants AS ( + SELECT + COALESCE(wp.member_id, sp.member_id) AS member_id, + COALESCE(wp.member_handle, sp.member_handle) AS member_handle, + COALESCE(wp.challenge_id, sp.challenge_id) AS challenge_id, + COALESCE(wp.placement, sp.placement) AS placement + FROM submission_participants AS sp + FULL OUTER JOIN winner_participants AS wp + ON wp.member_id = sp.member_id + AND wp.challenge_id = sp.challenge_id +) +SELECT + cp.member_id AS "memberId", + cp.member_handle AS "memberHandle", + COUNT(*)::int AS "uniqueChallengesSubmitted", + COUNT(*) FILTER (WHERE cp.placement = 1)::int AS "placementsOfOne", + COUNT(*) FILTER (WHERE cp.placement BETWEEN 1 AND 5)::int AS "placementsOneThroughFive" +FROM combined_participants AS cp +GROUP BY + cp.member_id, + cp.member_handle +ORDER BY + "uniqueChallengesSubmitted" DESC, + "memberHandle" ASC; diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index d44994f..e397b0a 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -69,6 +69,14 @@ export class TopcoderReportsController { return this.reports.getWeeklyMemberParticipation(); } + @Get("/30-day-payments") + @ApiOperation({ + summary: "Member payments for the last 30 days", + }) + get30DayPayments() { + return this.reports.get30DayPayments(); + } + @Get("/90-day-member-spend") @ApiOperation({ summary: "Total gross amount paid to members in the last 90 days", diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 6cd4780..2c01990 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -25,6 +25,23 @@ type MarathonMatchStatsRow = { marathon_submission_rate: string | number | null; }; +type ThirtyDayPaymentRow = { + customer: string | null; + project_id: string | null; + project_name: string | null; + billing_account_id: string | null; + billing_account_name: string | null; + challenge_id: string | null; + challenge_name: string | null; + challenge_created_at: Date | string | null; + member_id: string | null; + member_handle: string | null; + payment_type: string | null; + member_payment: string | number | null; + fee: string | number | null; + payment_date: Date | string | null; +}; + @Injectable() export class TopcoderReportsService { constructor( @@ -411,6 +428,27 @@ export class TopcoderReportsService { })); } + async get30DayPayments() { + const query = this.sql.load("reports/topcoder/30-day-payments.sql"); + const rows = await this.db.query(query); + return rows.map((row) => ({ + customer: row.customer ?? null, + projectId: row.project_id ?? null, + projectName: row.project_name ?? null, + billingAccountId: row.billing_account_id ?? null, + billingAccountName: row.billing_account_name ?? null, + challengeId: row.challenge_id ?? null, + challengeName: row.challenge_name ?? null, + challengeCreatedAt: this.normalizeDate(row.challenge_created_at), + memberId: row.member_id ?? null, + memberHandle: row.member_handle ?? null, + paymentType: row.payment_type ?? null, + memberPayment: this.toNullableNumber(row.member_payment), + fee: this.toNullableNumber(row.fee), + paymentDate: this.normalizeDate(row.payment_date), + })); + } + async getRegistrantCountriesCsv(challengeId: string) { const query = this.sql.load("reports/topcoder/registrant-countries.sql"); const rows = await this.db.query(query, [ From ca11264c88a83c65ca83509a58f028c168b021b1 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 20 Nov 2025 12:14:19 +1100 Subject: [PATCH 3/4] Updates based on feedback from Tony --- sql/reports/topcoder/30-day-payments.sql | 6 +- src/app.module.ts | 2 + src/auth/guards/permissions.guard.ts | 45 ++- src/common/csv/csv-serializer.ts | 116 ++++++ .../interceptors/csv-response.interceptor.ts | 46 +++ src/reports/report-directory.data.ts | 353 ++++++++++++++++++ src/reports/reports.controller.ts | 24 ++ src/reports/reports.module.ts | 7 + .../topcoder/topcoder-reports.controller.ts | 26 +- .../topcoder/topcoder-reports.module.ts | 10 +- .../topcoder/topcoder-reports.service.ts | 48 +-- 11 files changed, 622 insertions(+), 61 deletions(-) create mode 100644 src/common/csv/csv-serializer.ts create mode 100644 src/common/interceptors/csv-response.interceptor.ts create mode 100644 src/reports/report-directory.data.ts create mode 100644 src/reports/reports.controller.ts create mode 100644 src/reports/reports.module.ts diff --git a/sql/reports/topcoder/30-day-payments.sql b/sql/reports/topcoder/30-day-payments.sql index f44b558..58eebea 100644 --- a/sql/reports/topcoder/30-day-payments.sql +++ b/sql/reports/topcoder/30-day-payments.sql @@ -35,6 +35,7 @@ recent_payments AS ( ) SELECT cl."name" AS customer, + cl."codeName" AS client_codename, COALESCE(c."projectId"::text, ba."projectId") AS project_id, proj.name AS project_name, ba.id::text AS billing_account_id, @@ -54,7 +55,8 @@ SELECT rp.challenge_fee, COALESCE(rp.gross_amount, rp.total_amount) * (rp.challenge_markup / 100.0) ) AS fee, - COALESCE(rp.date_paid, rp.payment_created_at) AS payment_date + rp.payment_created_at AS payment_created_at, + rp.date_paid AS paid_date FROM recent_payments rp LEFT JOIN challenges."Challenge" c ON c."id" = rp.challenge_id @@ -71,4 +73,4 @@ LEFT JOIN projects.projects proj ON proj.id = c."projectId"::bigint LEFT JOIN members.member mem ON mem."userId"::text = rp.winner_id -ORDER BY payment_date DESC, rp.payment_created_at DESC; +ORDER BY payment_created_at DESC; \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 582724e..2d8eed5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,7 @@ import { TopcoderReportsModule } from "./reports/topcoder/topcoder-reports.modul import { StatisticsModule } from "./statistics/statistics.module"; import { SfdcReportsModule } from "./reports/sfdc/sfdc-reports.module"; import { ChallengesReportsModule } from "./reports/challenges/challenges-reports.module"; +import { ReportsModule } from "./reports/reports.module"; @Module({ imports: [ @@ -19,6 +20,7 @@ import { ChallengesReportsModule } from "./reports/challenges/challenges-reports StatisticsModule, SfdcReportsModule, ChallengesReportsModule, + ReportsModule, HealthModule, ], }) diff --git a/src/auth/guards/permissions.guard.ts b/src/auth/guards/permissions.guard.ts index 13ee927..8adf313 100644 --- a/src/auth/guards/permissions.guard.ts +++ b/src/auth/guards/permissions.guard.ts @@ -7,9 +7,14 @@ import { } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import { SCOPES_KEY } from "../decorators/scopes.decorator"; +import { UserRoles } from "../../app-constants"; @Injectable() export class PermissionsGuard implements CanActivate { + private static readonly adminRoles = new Set( + Object.values(UserRoles).map((role) => role.toLowerCase()), + ); + constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { @@ -28,15 +33,45 @@ export class PermissionsGuard implements CanActivate { throw new UnauthorizedException("You are not authenticated."); } - if (requiredScopes && authUser.scopes?.length) { - const hasScope = requiredScopes.some((scope) => - authUser.scopes.includes(scope), - ); - if (hasScope) return true; + if (authUser.isMachine) { + const scopes: string[] = authUser.scopes ?? []; + if (this.hasRequiredScope(scopes, requiredScopes)) { + return true; + } + } else { + const roles: string[] = authUser.roles ?? []; + if (this.isAdmin(roles)) { + return true; + } + + const scopes: string[] = authUser.scopes ?? []; + if (this.hasRequiredScope(scopes, requiredScopes)) { + return true; + } } throw new ForbiddenException( "You do not have the required permissions to access this resource.", ); } + + private hasRequiredScope( + scopes: string[], + requiredScopes: string[], + ): boolean { + if (!scopes?.length) { + return false; + } + + const normalizedScopes = scopes.map((scope) => scope?.toLowerCase()); + return requiredScopes.some((scope) => + normalizedScopes.includes(scope?.toLowerCase()), + ); + } + + private isAdmin(roles: string[]): boolean { + return roles.some((role) => + PermissionsGuard.adminRoles.has(role?.toLowerCase()), + ); + } } diff --git a/src/common/csv/csv-serializer.ts b/src/common/csv/csv-serializer.ts new file mode 100644 index 0000000..e15a812 --- /dev/null +++ b/src/common/csv/csv-serializer.ts @@ -0,0 +1,116 @@ +export class CsvSerializer { + serialize(data: unknown): string { + if (data === null || data === undefined) { + return ""; + } + + if (Array.isArray(data)) { + return this.serializeArray(data); + } + + if (typeof data === "object") { + return this.serializeObject(data as Record); + } + + return String(data); + } + + private serializeArray(values: unknown[]): string { + if (values.length === 0) { + return ""; + } + + if (values.every((value) => this.isRecord(value))) { + return this.serializeRecords(values as Record[]); + } + + const header = ["value"]; + const rows = values.map((value) => [this.normalizeValue(value)]); + return this.buildCsv(header, rows); + } + + private serializeObject(value: Record): string { + const columns = Object.keys(value); + if (columns.length === 0) { + return ""; + } + + const rows = [columns.map((column) => this.normalizeValue(value[column]))]; + return this.buildCsv(columns, rows); + } + + private serializeRecords(records: Record[]): string { + const columns = this.collectColumns(records); + if (columns.length === 0) { + return ""; + } + + const rows = records.map((record) => + columns.map((column) => this.normalizeValue(record[column])), + ); + + return this.buildCsv(columns, rows); + } + + private buildCsv(header: string[], rows: string[][]): string { + const lines = [ + this.serializeRow(header), + ...rows.map((row) => this.serializeRow(row)), + ]; + + return lines.join("\n"); + } + + private serializeRow(values: string[]): string { + return values.map((value) => this.escapeCsvCell(value)).join(","); + } + + private collectColumns(records: Record[]) { + const seen = new Set(); + const columns: string[] = []; + + for (const record of records) { + for (const key of Object.keys(record)) { + if (!seen.has(key)) { + seen.add(key); + columns.push(key); + } + } + } + + return columns; + } + + private normalizeValue(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value) || typeof value === "object") { + return JSON.stringify(value); + } + + return String(value); + } + + private escapeCsvCell(value: string): string { + if (value === "") { + return ""; + } + + if (!/[",\r\n]/.test(value)) { + return value; + } + + const escaped = value.replace(/"/g, '""'); + return `"${escaped}"`; + } + + private isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); + } +} diff --git a/src/common/interceptors/csv-response.interceptor.ts b/src/common/interceptors/csv-response.interceptor.ts new file mode 100644 index 0000000..1ec626b --- /dev/null +++ b/src/common/interceptors/csv-response.interceptor.ts @@ -0,0 +1,46 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from "@nestjs/common"; +import { Request, Response } from "express"; +import { Observable } from "rxjs"; +import { map } from "rxjs/operators"; +import { CsvSerializer } from "../csv/csv-serializer"; + +@Injectable() +export class CsvResponseInterceptor implements NestInterceptor { + constructor(private readonly csvSerializer: CsvSerializer) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const httpContext = context.switchToHttp(); + const request = httpContext.getRequest(); + const response = httpContext.getResponse(); + + if (!this.shouldReturnCsv(request)) { + return next.handle(); + } + + return next.handle().pipe( + map((data) => { + const csv = this.csvSerializer.serialize(data); + response.setHeader("Content-Type", "text/csv; charset=utf-8"); + return csv; + }), + ); + } + + private shouldReturnCsv(request: Request) { + const header = request.headers.accept; + if (!header) { + return false; + } + + const values = Array.isArray(header) ? header.join(",") : header; + return values + .split(",") + .map((value) => value.split(";")[0]?.trim().toLowerCase()) + .some((value) => value === "text/csv"); + } +} diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts new file mode 100644 index 0000000..2aebac7 --- /dev/null +++ b/src/reports/report-directory.data.ts @@ -0,0 +1,353 @@ +export type ReportGroupKey = "challenges" | "sfdc" | "statistics" | "topcoder"; + +type HttpMethod = "GET"; + +export type AvailableReport = { + name: string; + path: string; + description: string; + method: HttpMethod; +}; + +export type ReportGroup = { + label: string; + basePath: string; + reports: AvailableReport[]; +}; + +export type ReportsDirectory = Record; + +const report = ( + name: string, + path: string, + description: string, +): AvailableReport => ({ + name, + path, + description, + method: "GET", +}); + +export const REPORTS_DIRECTORY: ReportsDirectory = { + challenges: { + label: "Challenges Reports", + basePath: "/challenges", + reports: [ + report( + "Challenge History", + "/challenges", + "Return the challenge history report", + ), + report( + "Challenge Registrants", + "/challenges/registrants", + "Return the challenge registrants history report", + ), + report( + "Submission Links", + "/challenges/submission-links", + "Return the submission links report", + ), + ], + }, + sfdc: { + label: "SFDC Reports", + basePath: "/sfdc", + reports: [ + report("Payments", "/sfdc/payments", "SFDC Payments report"), + report( + "BA Fees", + "/sfdc/ba-fees", + "Report of BA to fee / member payment", + ), + ], + }, + statistics: { + label: "Statistics", + basePath: "/statistics", + reports: [ + report("SRM Top Rated", "/statistics/srm/top-rated", "Highest rated SRMs (static)"), + report( + "SRM Country Ratings", + "/statistics/srm/country-ratings", + "SRM country ratings (static)", + ), + report( + "SRM Competitions Count", + "/statistics/srm/competitions-count", + "SRM number of competitions (static)", + ), + report( + "MM Top Rated", + "/statistics/mm/top-rated", + "Highest rated Marathon Matches (static)", + ), + report( + "MM Country Ratings", + "/statistics/mm/country-ratings", + "Marathon Match country ratings (static)", + ), + report( + "MM Top 10 Finishes", + "/statistics/mm/top-10-finishes", + "Marathon Match Top 10 finishes (static)", + ), + report( + "MM Competitions Count", + "/statistics/mm/competitions-count", + "Marathon Match number of competitions (static)", + ), + report( + "Member Count", + "/statistics/general/member-count", + "Total number of member records", + ), + report( + "Total Prizes", + "/statistics/general/total-prizes", + "Total amount of all payments", + ), + report( + "Completed Challenges", + "/statistics/general/completed-challenges", + "Total number of completed challenges", + ), + report( + "Countries Represented", + "/statistics/general/countries-represented", + "Member count by country (desc)", + ), + report( + "First Place by Country", + "/statistics/general/first-place-by-country", + "First place finishes by country (desc)", + ), + report( + "Copiloted Challenges", + "/statistics/general/copiloted-challenges", + "Copiloted challenges by member (desc)", + ), + report( + "Reviews by Member", + "/statistics/general/reviews-by-member", + "Review participation by member (desc)", + ), + report( + "UI Design Wins", + "/statistics/design/ui-design-wins", + "Design 'Challenge' wins by member (desc)", + ), + report( + "Design First2Finish Wins", + "/statistics/design/f2f-wins", + "Design First2Finish wins by member (desc)", + ), + report( + "LUX First Place Wins", + "/statistics/design/lux-first-place-wins", + "Design LUX first place wins by member (desc)", + ), + report( + "LUX Placements", + "/statistics/design/lux-placements", + "Design LUX placements by member (desc)", + ), + report( + "RUX Placements", + "/statistics/design/rux-placements", + "Design RUX placements by member (desc)", + ), + report( + "First-time Design Submitters", + "/statistics/design/first-time-submitters", + "First-time design submitters in last 3 months", + ), + report( + "Design Countries Represented", + "/statistics/design/countries-represented", + "Design submitters by country (desc)", + ), + report( + "Design First Place by Country", + "/statistics/design/first-place-by-country", + "Design first place finishes by country (desc)", + ), + report( + "RUX First Place Wins", + "/statistics/design/rux-first-place-wins", + "RUX first place design challenge wins by member (desc)", + ), + report( + "Wireframe Wins", + "/statistics/design/wireframe-wins", + "Design wireframe challenge wins by member (desc)", + ), + report( + "Development Challenge Wins", + "/statistics/development/code-wins", + "Development challenge wins by member (desc)", + ), + report( + "Development First2Finish Wins", + "/statistics/development/f2f-wins", + "Development First2Finish wins by member (desc)", + ), + report( + "Prototype Wins", + "/statistics/development/prototype-wins", + "Development prototype challenge wins by member (desc)", + ), + report( + "Development First Place Wins", + "/statistics/development/first-place-wins", + "Development overall wins by member (desc)", + ), + report( + "First-time Development Submitters", + "/statistics/development/first-time-submitters", + "First-time development submitters in last 3 months", + ), + report( + "Development Countries Represented", + "/statistics/development/countries-represented", + "Development submitters by country (desc)", + ), + report( + "Development Challenges by Technology", + "/statistics/development/challenges-technology", + "Development challenges by standardized skill (desc)", + ), + report( + "QA Wins", + "/statistics/qa/wins", + "Quality Assurance challenge wins by member (desc)", + ), + ], + }, + topcoder: { + label: "Topcoder Reports", + basePath: "/topcoder", + reports: [ + report( + "Member Count", + "/topcoder/member-count", + "Total number of active members", + ), + report( + "Registrant Countries", + "/topcoder/registrant-countries", + "Countries of all registrants for the specified challenge", + ), + report( + "Marathon Match Stats", + "/topcoder/mm-stats/:handle", + "Marathon match performance snapshot for a specific handle", + ), + report( + "Total Copilots", + "/topcoder/total-copilots", + "Total number of Copilots", + ), + report( + "Weekly Active Copilots", + "/topcoder/weekly-active-copilots", + "Weekly challenge and copilot counts by track for the last six months", + ), + report( + "Weekly Member Participation", + "/topcoder/weekly-member-participation", + "Weekly distinct registrants and submitters for the last five weeks", + ), + report( + "30 Day Payments", + "/topcoder/30-day-payments", + "Member payments for the last 30 days", + ), + report( + "90 Day Member Spend", + "/topcoder/90-day-member-spend", + "Total gross amount paid to members in the last 90 days", + ), + report( + "90 Day Members Paid", + "/topcoder/90-day-members-paid", + "Total number of distinct members paid in the last 90 days", + ), + report( + "90 Day New Members", + "/topcoder/90-day-new-members", + "Total number of new active members created in the last 90 days", + ), + report( + "90 Day Active Copilots", + "/topcoder/90-day-active-copilots", + "Total number of distinct copilots active in the last 90 days", + ), + report( + "90 Day User Login", + "/topcoder/90-day-user-login", + "Total number of active members who logged in during the last 90 days", + ), + report( + "90 Day Challenge Volume", + "/topcoder/90-day-challenge-volume", + "Total number of challenges launched in the last 90 days", + ), + report( + "90 Day Challenge Duration", + "/topcoder/90-day-challenge-duration", + "Total duration and count of completed challenges in the last 90 days", + ), + report( + "90 Day Challenge Registrants", + "/topcoder/90-day-challenge-registrants", + "Distinct challenge registrants and submitters in the last 90 days", + ), + report( + "90 Day Challenge Submitters", + "/topcoder/90-day-challenge-submitters", + "Distinct challenge registrants and submitters in the last 90 days", + ), + report( + "90 Day Challenge Member Cost", + "/topcoder/90-day-challenge-member-cost", + "Member payment totals and averages for challenges completed in the last 90 days", + ), + report( + "90 Day Task Member Cost", + "/topcoder/90-day-task-member-cost", + "Member payment totals and averages for tasks completed in the last 90 days", + ), + report( + "90 Day Fulfillment", + "/topcoder/90-day-fulfillment", + "Share of challenges completed versus cancelled in the last 90 days", + ), + report( + "90 Day Fulfillment With Tasks", + "/topcoder/90-day-fulfillment-with-tasks", + "Share of challenges and tasks completed versus cancelled in the last 90 days", + ), + report( + "Weekly Challenge Fulfillment", + "/topcoder/weekly-challenge-fulfillment", + "Weekly share of challenges completed versus cancelled for the last four weeks", + ), + report( + "Weekly Challenge Volume", + "/topcoder/weekly-challenge-volume", + "Weekly challenge counts by task indicator for the last four weeks", + ), + report( + "90 Day Membership Participation Funnel", + "/topcoder/90-day-membership-participation-funnel", + "New member counts with design and development participation indicators for the last 90 days", + ), + report( + "Membership Participation Funnel Data", + "/topcoder/membership-participation-funnel-data", + "Weekly new member counts with design and development participation indicators for the last four weeks", + ), + ], + }, +}; diff --git a/src/reports/reports.controller.ts b/src/reports/reports.controller.ts new file mode 100644 index 0000000..c5f693a --- /dev/null +++ b/src/reports/reports.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, UseGuards } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { PermissionsGuard } from "src/auth/guards/permissions.guard"; +import { Scopes } from "src/auth/decorators/scopes.decorator"; +import { Scopes as AppScopes } from "src/app-constants"; +import { + REPORTS_DIRECTORY, + ReportsDirectory, +} from "./report-directory.data"; + +@ApiTags("Reports") +@Controller("/reports") +export class ReportsController { + @Get() + @UseGuards(PermissionsGuard) + @Scopes(AppScopes.AllReports) + @ApiBearerAuth() + @ApiOperation({ + summary: "List available report endpoints grouped by sub-path", + }) + getReports(): ReportsDirectory { + return REPORTS_DIRECTORY; + } +} diff --git a/src/reports/reports.module.ts b/src/reports/reports.module.ts new file mode 100644 index 0000000..06ad5c8 --- /dev/null +++ b/src/reports/reports.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { ReportsController } from "./reports.controller"; + +@Module({ + controllers: [ReportsController], +}) +export class ReportsModule {} diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index e397b0a..582f2ac 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -1,13 +1,21 @@ -import { Controller, Get, Param, Query, Res, UseGuards } from "@nestjs/common"; +import { + Controller, + Get, + Param, + Query, + UseGuards, + UseInterceptors, +} from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; -import { Response } from "express"; import { TopcoderReportsService } from "./topcoder-reports.service"; import { RegistrantCountriesQueryDto } from "./dto/registrant-countries.dto"; import { TopcoderReportsGuard } from "../../auth/guards/topcoder-reports.guard"; +import { CsvResponseInterceptor } from "../../common/interceptors/csv-response.interceptor"; @ApiTags("Topcoder Reports") @ApiBearerAuth() @UseGuards(TopcoderReportsGuard) +@UseInterceptors(CsvResponseInterceptor) @Controller("/topcoder") export class TopcoderReportsController { constructor(private readonly reports: TopcoderReportsService) {} @@ -22,19 +30,9 @@ export class TopcoderReportsController { @ApiOperation({ summary: "Countries of all registrants for the specified challenge", }) - async getRegistrantCountries( - @Query() query: RegistrantCountriesQueryDto, - @Res() res: Response, - ) { + async getRegistrantCountries(@Query() query: RegistrantCountriesQueryDto) { const { challengeId } = query; - const csv = await this.reports.getRegistrantCountriesCsv(challengeId); - const filename = - challengeId.length > 0 - ? `registrant-countries-${challengeId}.csv` - : "registrant-countries.csv"; - res.setHeader("Content-Type", "text/csv"); - res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); - res.send(csv); + return this.reports.getRegistrantCountries(challengeId); } @Get("/mm-stats/:handle") diff --git a/src/reports/topcoder/topcoder-reports.module.ts b/src/reports/topcoder/topcoder-reports.module.ts index fd69017..e92ab4c 100644 --- a/src/reports/topcoder/topcoder-reports.module.ts +++ b/src/reports/topcoder/topcoder-reports.module.ts @@ -3,9 +3,17 @@ import { TopcoderReportsService } from "./topcoder-reports.service"; import { SqlLoaderService } from "../../common/sql-loader.service"; import { TopcoderReportsController } from "./topcoder-reports.controller"; import { TopcoderReportsGuard } from "../../auth/guards/topcoder-reports.guard"; +import { CsvSerializer } from "../../common/csv/csv-serializer"; +import { CsvResponseInterceptor } from "../../common/interceptors/csv-response.interceptor"; @Module({ controllers: [TopcoderReportsController], - providers: [TopcoderReportsService, SqlLoaderService, TopcoderReportsGuard], + providers: [ + TopcoderReportsService, + SqlLoaderService, + TopcoderReportsGuard, + CsvSerializer, + CsvResponseInterceptor, + ], }) export class TopcoderReportsModule {} diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 2c01990..6f7bd59 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -27,6 +27,7 @@ type MarathonMatchStatsRow = { type ThirtyDayPaymentRow = { customer: string | null; + client_codename: string | null; project_id: string | null; project_name: string | null; billing_account_id: string | null; @@ -433,6 +434,7 @@ export class TopcoderReportsService { const rows = await this.db.query(query); return rows.map((row) => ({ customer: row.customer ?? null, + clientCodeName: row.client_codename ?? null, projectId: row.project_id ?? null, projectName: row.project_name ?? null, billingAccountId: row.billing_account_id ?? null, @@ -449,12 +451,17 @@ export class TopcoderReportsService { })); } - async getRegistrantCountriesCsv(challengeId: string) { + async getRegistrantCountries(challengeId: string) { const query = this.sql.load("reports/topcoder/registrant-countries.sql"); const rows = await this.db.query(query, [ challengeId, ]); - return this.rowsToCsv(rows); + return rows.map((row) => ({ + handle: row.handle ?? null, + email: row.email ?? null, + homeCountry: row.home_country ?? null, + competitionCountry: row.competition_country ?? null, + })); } async getMarathonMatchStats(handle: string) { @@ -493,43 +500,6 @@ export class TopcoderReportsService { }; } - private rowsToCsv(rows: RegistrantCountriesRow[]) { - const header = [ - "Handle", - "Email", - "Home country", - "Competition country", - ]; - - const lines = [ - header.map((value) => this.toCsvCell(value)).join(","), - ...rows.map((row) => - [ - row.handle, - row.email, - row.home_country, - row.competition_country, - ] - .map((value) => this.toCsvCell(value)) - .join(","), - ), - ]; - - return lines.join("\n"); - } - - private toCsvCell(value: string | null | undefined) { - if (value === null || value === undefined) { - return ""; - } - const text = String(value); - if (!/[",\r\n]/.test(text)) { - return text; - } - const escaped = text.replace(/"/g, '""'); - return `"${escaped}"`; - } - private toNullableNumber(value: string | number | null | undefined) { if (value === null || value === undefined) { return null; From dde1904d44083674dc560eee138894c699b3a183 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 20 Nov 2025 15:09:31 +1100 Subject: [PATCH 4/4] Route fix for new report directory --- src/reports/reports.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reports/reports.controller.ts b/src/reports/reports.controller.ts index c5f693a..5069434 100644 --- a/src/reports/reports.controller.ts +++ b/src/reports/reports.controller.ts @@ -9,7 +9,7 @@ import { } from "./report-directory.data"; @ApiTags("Reports") -@Controller("/reports") +@Controller() export class ReportsController { @Get() @UseGuards(PermissionsGuard)