From bcc17c4dfa523e6e91e8051afc2c3fd151d716cb Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 31 Oct 2025 07:59:19 +1100 Subject: [PATCH 1/3] Registrant country report for a challenge, to help with challenges that only allow submissions from a subset of countries --- sql/reports/topcoder/registrant-countries.sql | 21 ++++++++ .../topcoder/dto/registrant-countries.dto.ts | 11 ++++ .../topcoder/topcoder-reports.controller.ts | 23 +++++++- .../topcoder/topcoder-reports.service.ts | 52 +++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 sql/reports/topcoder/registrant-countries.sql create mode 100644 src/reports/topcoder/dto/registrant-countries.dto.ts diff --git a/sql/reports/topcoder/registrant-countries.sql b/sql/reports/topcoder/registrant-countries.sql new file mode 100644 index 0000000..c2f2306 --- /dev/null +++ b/sql/reports/topcoder/registrant-countries.sql @@ -0,0 +1,21 @@ +SELECT DISTINCT + res."memberHandle" AS handle, + mem.email AS email, + COALESCE(home_code.name, home_id.name, mem."homeCountryCode") AS home_country, + COALESCE(comp_code.name, comp_id.name, mem."competitionCountryCode") AS competition_country +FROM resources."Resource" AS res +JOIN resources."ResourceRole" AS rr + ON rr.id = res."roleId" +LEFT JOIN members."member" AS mem + ON mem."userId"::text = res."memberId" +LEFT JOIN lookups."Country" AS home_code + ON UPPER(home_code."countryCode") = UPPER(mem."homeCountryCode") +LEFT JOIN lookups."Country" AS home_id + ON UPPER(home_id.id) = UPPER(mem."homeCountryCode") +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 rr.name = 'Submitter' + AND res."challengeId" = 'e12ee862-474a-4e40-9d2d-2699ae1dfc2a' +ORDER BY res."memberHandle"; diff --git a/src/reports/topcoder/dto/registrant-countries.dto.ts b/src/reports/topcoder/dto/registrant-countries.dto.ts new file mode 100644 index 0000000..6f6aee7 --- /dev/null +++ b/src/reports/topcoder/dto/registrant-countries.dto.ts @@ -0,0 +1,11 @@ +import { Transform } from "class-transformer"; +import { IsNotEmpty, IsString } from "class-validator"; + +export class RegistrantCountriesQueryDto { + @Transform(({ value }) => + typeof value === "string" ? value.trim() : value, + ) + @IsString() + @IsNotEmpty() + challengeId!: string; +} diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index 1e12ab5..2fca097 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -1,6 +1,8 @@ -import { Controller, Get } from "@nestjs/common"; +import { Controller, Get, Query, Res } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Response } from "express"; import { TopcoderReportsService } from "./topcoder-reports.service"; +import { RegistrantCountriesQueryDto } from "./dto/registrant-countries.dto"; @ApiTags("Topcoder Reports") @Controller("/topcoder") @@ -13,6 +15,25 @@ export class TopcoderReportsController { return this.reports.getMemberCount(); } + @Get("/registrant-countries") + @ApiOperation({ + summary: "Countries of all registrants for the specified challenge", + }) + async getRegistrantCountries( + @Query() query: RegistrantCountriesQueryDto, + @Res() res: Response, + ) { + 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); + } + @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 b8b3f67..21df975 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -2,6 +2,13 @@ import { Injectable } from "@nestjs/common"; import { DbService } from "../../db/db.service"; import { SqlLoaderService } from "../../common/sql-loader.service"; +type RegistrantCountriesRow = { + handle: string | null; + email: string | null; + home_country: string | null; + competition_country: string | null; +}; + @Injectable() export class TopcoderReportsService { constructor( @@ -387,4 +394,49 @@ export class TopcoderReportsService { ), })); } + + async getRegistrantCountriesCsv(challengeId: string) { + const query = this.sql.load("reports/topcoder/registrant-countries.sql"); + const rows = await this.db.query(query, [ + challengeId, + ]); + return this.rowsToCsv(rows); + } + + 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}"`; + } } From 07726f4605b3f89399c42ebf8dc01246df84faa0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 31 Oct 2025 08:01:15 +1100 Subject: [PATCH 2/3] Remove testing data --- sql/reports/topcoder/registrant-countries.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/reports/topcoder/registrant-countries.sql b/sql/reports/topcoder/registrant-countries.sql index c2f2306..7c0fe59 100644 --- a/sql/reports/topcoder/registrant-countries.sql +++ b/sql/reports/topcoder/registrant-countries.sql @@ -17,5 +17,5 @@ LEFT JOIN lookups."Country" AS comp_code LEFT JOIN lookups."Country" AS comp_id ON UPPER(comp_id.id) = UPPER(mem."competitionCountryCode") WHERE rr.name = 'Submitter' - AND res."challengeId" = 'e12ee862-474a-4e40-9d2d-2699ae1dfc2a' + AND res."challengeId" = $1::text ORDER BY res."memberHandle"; From a5d09141b3a49fa4fcbee9fa56a0b97e8e85ec44 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 31 Oct 2025 11:25:19 +1100 Subject: [PATCH 3/3] Add auth guard for M2M / admin token for topcoder reports --- src/app-constants.ts | 5 ++ src/auth/guards/topcoder-reports.guard.ts | 59 +++++++++++++++++++ .../topcoder/topcoder-reports.controller.ts | 7 ++- .../topcoder/topcoder-reports.module.ts | 5 +- 4 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 src/auth/guards/topcoder-reports.guard.ts diff --git a/src/app-constants.ts b/src/app-constants.ts index 62ca7fa..2c4678a 100644 --- a/src/app-constants.ts +++ b/src/app-constants.ts @@ -4,6 +4,7 @@ export const Scopes = { TopgearChallenge: "reports:topgear-challenge", TopgearCancelledChallenge: "reports:topgear-cancelled-challenge", AllReports: "reports:all", + TopcoderReports: "reports:topcoder", TopgearChallengeTechnology: "reports:topgear-challenge-technology", TopgearChallengeStatsByUser: "reports:topgear-challenge-stats-by-user", TopgearChallengeRegistrantDetails: @@ -18,3 +19,7 @@ export const Scopes = { SubmissionLinks: "reports:challenge-submission-links", }, }; + +export const UserRoles = { + Admin: "Administrator", +}; diff --git a/src/auth/guards/topcoder-reports.guard.ts b/src/auth/guards/topcoder-reports.guard.ts new file mode 100644 index 0000000..ba6b435 --- /dev/null +++ b/src/auth/guards/topcoder-reports.guard.ts @@ -0,0 +1,59 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + UnauthorizedException, +} from "@nestjs/common"; + +import { Scopes, UserRoles } from "../../app-constants"; + +@Injectable() +export class TopcoderReportsGuard implements CanActivate { + private static readonly adminRoles = new Set( + Object.values(UserRoles).map((role) => role.toLowerCase()), + ); + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const authUser = request.authUser; + + if (!authUser) { + throw new UnauthorizedException("You are not authenticated."); + } + + if (authUser.isMachine) { + const scopes: string[] = authUser.scopes ?? []; + if (this.hasRequiredScope(scopes)) { + return true; + } + + throw new ForbiddenException( + "You do not have the required permissions to access this resource.", + ); + } + + const roles: string[] = authUser.roles ?? []; + if (this.isAdmin(roles)) { + return true; + } + + throw new ForbiddenException( + "You do not have the required permissions to access this resource.", + ); + } + + private hasRequiredScope(scopes: string[]): boolean { + const normalizedScopes = scopes.map((scope) => scope?.toLowerCase()); + return ( + normalizedScopes.includes(Scopes.TopcoderReports.toLowerCase()) || + normalizedScopes.includes(Scopes.AllReports.toLowerCase()) + ); + } + + private isAdmin(roles: string[]): boolean { + return roles.some((role) => + TopcoderReportsGuard.adminRoles.has(role?.toLowerCase()), + ); + } +} diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index 2fca097..856f031 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -1,10 +1,13 @@ -import { Controller, Get, Query, Res } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Controller, Get, Query, Res, UseGuards } 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"; @ApiTags("Topcoder Reports") +@ApiBearerAuth() +@UseGuards(TopcoderReportsGuard) @Controller("/topcoder") export class TopcoderReportsController { constructor(private readonly reports: TopcoderReportsService) {} diff --git a/src/reports/topcoder/topcoder-reports.module.ts b/src/reports/topcoder/topcoder-reports.module.ts index 7fc7fa6..fd69017 100644 --- a/src/reports/topcoder/topcoder-reports.module.ts +++ b/src/reports/topcoder/topcoder-reports.module.ts @@ -1,10 +1,11 @@ import { Module } from "@nestjs/common"; -import { TopcoderReportsController } from "./topcoder-reports.controller"; 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"; @Module({ controllers: [TopcoderReportsController], - providers: [TopcoderReportsService, SqlLoaderService], + providers: [TopcoderReportsService, SqlLoaderService, TopcoderReportsGuard], }) export class TopcoderReportsModule {}