Skip to content

Commit 0cbeb1a

Browse files
authored
Merge pull request #20 from topcoder-platform/topcoder-reports
Registrant country report for a challenge, to help with challenges that only allow submissions from a subset of countries
2 parents 6fa1bff + a5d0914 commit 0cbeb1a

File tree

7 files changed

+177
-4
lines changed

7 files changed

+177
-4
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
SELECT DISTINCT
2+
res."memberHandle" AS handle,
3+
mem.email AS email,
4+
COALESCE(home_code.name, home_id.name, mem."homeCountryCode") AS home_country,
5+
COALESCE(comp_code.name, comp_id.name, mem."competitionCountryCode") AS competition_country
6+
FROM resources."Resource" AS res
7+
JOIN resources."ResourceRole" AS rr
8+
ON rr.id = res."roleId"
9+
LEFT JOIN members."member" AS mem
10+
ON mem."userId"::text = res."memberId"
11+
LEFT JOIN lookups."Country" AS home_code
12+
ON UPPER(home_code."countryCode") = UPPER(mem."homeCountryCode")
13+
LEFT JOIN lookups."Country" AS home_id
14+
ON UPPER(home_id.id) = UPPER(mem."homeCountryCode")
15+
LEFT JOIN lookups."Country" AS comp_code
16+
ON UPPER(comp_code."countryCode") = UPPER(mem."competitionCountryCode")
17+
LEFT JOIN lookups."Country" AS comp_id
18+
ON UPPER(comp_id.id) = UPPER(mem."competitionCountryCode")
19+
WHERE rr.name = 'Submitter'
20+
AND res."challengeId" = $1::text
21+
ORDER BY res."memberHandle";

src/app-constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export const Scopes = {
44
TopgearChallenge: "reports:topgear-challenge",
55
TopgearCancelledChallenge: "reports:topgear-cancelled-challenge",
66
AllReports: "reports:all",
7+
TopcoderReports: "reports:topcoder",
78
TopgearChallengeTechnology: "reports:topgear-challenge-technology",
89
TopgearChallengeStatsByUser: "reports:topgear-challenge-stats-by-user",
910
TopgearChallengeRegistrantDetails:
@@ -18,3 +19,7 @@ export const Scopes = {
1819
SubmissionLinks: "reports:challenge-submission-links",
1920
},
2021
};
22+
23+
export const UserRoles = {
24+
Admin: "Administrator",
25+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {
2+
CanActivate,
3+
ExecutionContext,
4+
ForbiddenException,
5+
Injectable,
6+
UnauthorizedException,
7+
} from "@nestjs/common";
8+
9+
import { Scopes, UserRoles } from "../../app-constants";
10+
11+
@Injectable()
12+
export class TopcoderReportsGuard implements CanActivate {
13+
private static readonly adminRoles = new Set(
14+
Object.values(UserRoles).map((role) => role.toLowerCase()),
15+
);
16+
17+
canActivate(context: ExecutionContext): boolean {
18+
const request = context.switchToHttp().getRequest();
19+
const authUser = request.authUser;
20+
21+
if (!authUser) {
22+
throw new UnauthorizedException("You are not authenticated.");
23+
}
24+
25+
if (authUser.isMachine) {
26+
const scopes: string[] = authUser.scopes ?? [];
27+
if (this.hasRequiredScope(scopes)) {
28+
return true;
29+
}
30+
31+
throw new ForbiddenException(
32+
"You do not have the required permissions to access this resource.",
33+
);
34+
}
35+
36+
const roles: string[] = authUser.roles ?? [];
37+
if (this.isAdmin(roles)) {
38+
return true;
39+
}
40+
41+
throw new ForbiddenException(
42+
"You do not have the required permissions to access this resource.",
43+
);
44+
}
45+
46+
private hasRequiredScope(scopes: string[]): boolean {
47+
const normalizedScopes = scopes.map((scope) => scope?.toLowerCase());
48+
return (
49+
normalizedScopes.includes(Scopes.TopcoderReports.toLowerCase()) ||
50+
normalizedScopes.includes(Scopes.AllReports.toLowerCase())
51+
);
52+
}
53+
54+
private isAdmin(roles: string[]): boolean {
55+
return roles.some((role) =>
56+
TopcoderReportsGuard.adminRoles.has(role?.toLowerCase()),
57+
);
58+
}
59+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Transform } from "class-transformer";
2+
import { IsNotEmpty, IsString } from "class-validator";
3+
4+
export class RegistrantCountriesQueryDto {
5+
@Transform(({ value }) =>
6+
typeof value === "string" ? value.trim() : value,
7+
)
8+
@IsString()
9+
@IsNotEmpty()
10+
challengeId!: string;
11+
}

src/reports/topcoder/topcoder-reports.controller.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
import { Controller, Get } from "@nestjs/common";
2-
import { ApiOperation, ApiTags } from "@nestjs/swagger";
1+
import { Controller, Get, Query, Res, UseGuards } from "@nestjs/common";
2+
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
3+
import { Response } from "express";
34
import { TopcoderReportsService } from "./topcoder-reports.service";
5+
import { RegistrantCountriesQueryDto } from "./dto/registrant-countries.dto";
6+
import { TopcoderReportsGuard } from "../../auth/guards/topcoder-reports.guard";
47

58
@ApiTags("Topcoder Reports")
9+
@ApiBearerAuth()
10+
@UseGuards(TopcoderReportsGuard)
611
@Controller("/topcoder")
712
export class TopcoderReportsController {
813
constructor(private readonly reports: TopcoderReportsService) {}
@@ -13,6 +18,25 @@ export class TopcoderReportsController {
1318
return this.reports.getMemberCount();
1419
}
1520

21+
@Get("/registrant-countries")
22+
@ApiOperation({
23+
summary: "Countries of all registrants for the specified challenge",
24+
})
25+
async getRegistrantCountries(
26+
@Query() query: RegistrantCountriesQueryDto,
27+
@Res() res: Response,
28+
) {
29+
const { challengeId } = query;
30+
const csv = await this.reports.getRegistrantCountriesCsv(challengeId);
31+
const filename =
32+
challengeId.length > 0
33+
? `registrant-countries-${challengeId}.csv`
34+
: "registrant-countries.csv";
35+
res.setHeader("Content-Type", "text/csv");
36+
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
37+
res.send(csv);
38+
}
39+
1640
@Get("/total-copilots")
1741
@ApiOperation({ summary: "Total number of Copilots" })
1842
getTotalCopilots() {
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { Module } from "@nestjs/common";
2-
import { TopcoderReportsController } from "./topcoder-reports.controller";
32
import { TopcoderReportsService } from "./topcoder-reports.service";
43
import { SqlLoaderService } from "../../common/sql-loader.service";
4+
import { TopcoderReportsController } from "./topcoder-reports.controller";
5+
import { TopcoderReportsGuard } from "../../auth/guards/topcoder-reports.guard";
56

67
@Module({
78
controllers: [TopcoderReportsController],
8-
providers: [TopcoderReportsService, SqlLoaderService],
9+
providers: [TopcoderReportsService, SqlLoaderService, TopcoderReportsGuard],
910
})
1011
export class TopcoderReportsModule {}

src/reports/topcoder/topcoder-reports.service.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ import { Injectable } from "@nestjs/common";
22
import { DbService } from "../../db/db.service";
33
import { SqlLoaderService } from "../../common/sql-loader.service";
44

5+
type RegistrantCountriesRow = {
6+
handle: string | null;
7+
email: string | null;
8+
home_country: string | null;
9+
competition_country: string | null;
10+
};
11+
512
@Injectable()
613
export class TopcoderReportsService {
714
constructor(
@@ -387,4 +394,49 @@ export class TopcoderReportsService {
387394
),
388395
}));
389396
}
397+
398+
async getRegistrantCountriesCsv(challengeId: string) {
399+
const query = this.sql.load("reports/topcoder/registrant-countries.sql");
400+
const rows = await this.db.query<RegistrantCountriesRow>(query, [
401+
challengeId,
402+
]);
403+
return this.rowsToCsv(rows);
404+
}
405+
406+
private rowsToCsv(rows: RegistrantCountriesRow[]) {
407+
const header = [
408+
"Handle",
409+
"Email",
410+
"Home country",
411+
"Competition country",
412+
];
413+
414+
const lines = [
415+
header.map((value) => this.toCsvCell(value)).join(","),
416+
...rows.map((row) =>
417+
[
418+
row.handle,
419+
row.email,
420+
row.home_country,
421+
row.competition_country,
422+
]
423+
.map((value) => this.toCsvCell(value))
424+
.join(","),
425+
),
426+
];
427+
428+
return lines.join("\n");
429+
}
430+
431+
private toCsvCell(value: string | null | undefined) {
432+
if (value === null || value === undefined) {
433+
return "";
434+
}
435+
const text = String(value);
436+
if (!/[",\r\n]/.test(text)) {
437+
return text;
438+
}
439+
const escaped = text.replace(/"/g, '""');
440+
return `"${escaped}"`;
441+
}
390442
}

0 commit comments

Comments
 (0)