From 0ca86c73cc5f869527cdfea7eea14b17a287525a Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 25 Aug 2026 10:35:22 +0300 Subject: [PATCH 1/2] PM-5370 - campus leaderboard - reviewed status --- sql/reports/topcoder/campus-leaderboard.sql | 19 ++++++++++++- .../campus-leaderboard.service.spec.ts | 27 +++++++++++++++++++ .../topcoder/dto/leaderboard-generic.dto.ts | 9 ++++++- .../topcoder/dto/leaderboard-mm.dto.ts | 8 +++++- .../topcoder/topcoder-reports.service.spec.ts | 10 ++++--- .../topcoder/topcoder-reports.service.ts | 3 +++ 6 files changed, 69 insertions(+), 7 deletions(-) diff --git a/sql/reports/topcoder/campus-leaderboard.sql b/sql/reports/topcoder/campus-leaderboard.sql index 0c6b30d..b815d5e 100644 --- a/sql/reports/topcoder/campus-leaderboard.sql +++ b/sql/reports/topcoder/campus-leaderboard.sql @@ -100,7 +100,21 @@ scored_submissions AS ( ) >= COALESCE(sc."minimumPassingScore", sc."minScore", 0) END ) - END AS is_passing + END AS is_passing, + -- Whether the submission has a review outcome at all. Without this a review + -- that is still running is indistinguishable from one that failed. + CASE + WHEN challenge_reviewers.is_ai_only_challenge + THEN ai_decision.status IS NOT NULL + ELSE ( + final_review."isPassing" IS NOT NULL + OR COALESCE( + final_review."aggregateScore", + s."finalScore"::double precision, + s."initialScore"::double precision + ) IS NOT NULL + ) + END AS is_reviewed FROM reviews.submission AS s JOIN group_members AS gm ON gm.member_id = s."memberId" @@ -141,6 +155,7 @@ member_submissions AS ( ss.challenge_id, MIN(ss.submitted_date) AS first_submitted_date, BOOL_OR(COALESCE(ss.is_passing, FALSE)) AS has_passing_submission, + BOOL_OR(COALESCE(ss.is_reviewed, FALSE)) AS has_reviewed_submission, MAX(ss.score) FILTER (WHERE ss.score IS NOT NULL) AS best_score FROM scored_submissions AS ss GROUP BY ss.member_id, ss.challenge_id @@ -178,6 +193,7 @@ member_participation AS ( (reg.member_id IS NOT NULL) AS registered, (sub.member_id IS NOT NULL) AS submitted, COALESCE(sub.has_passing_submission, FALSE) AS passed_review, + COALESCE(sub.has_reviewed_submission, FALSE) AS reviewed, sub.first_submitted_date, sub.best_score, (win.member_id IS NOT NULL AND win.placement = 1) AS won, @@ -238,6 +254,7 @@ SELECT mp.registered AS registered, mp.submitted AS submitted, mp.passed_review AS "passedReview", + mp.reviewed AS reviewed, mp.first_submitted_date AS "submittedDate", mp.best_score AS score, mp.won AS won, diff --git a/src/reports/topcoder/campus-leaderboard.service.spec.ts b/src/reports/topcoder/campus-leaderboard.service.spec.ts index d57b299..dfdb7a0 100644 --- a/src/reports/topcoder/campus-leaderboard.service.spec.ts +++ b/src/reports/topcoder/campus-leaderboard.service.spec.ts @@ -32,6 +32,7 @@ const participationRow = (overrides: Row = {}): Row => ({ registered: true, submitted: false, passedReview: false, + reviewed: false, submittedDate: null, score: null, won: false, @@ -181,6 +182,32 @@ describe("TopcoderReportsService.getCampusLeaderboard", () => { }); }); + it("reports whether a submission has been reviewed yet", async () => { + leaderboardRows = [ + participationRow({ + challengeId: "c1", + submitted: true, + passedReview: false, + reviewed: false, + }), + participationRow({ + challengeId: "c2", + submitted: true, + passedReview: false, + reviewed: true, + }), + ]; + + const result = await service.getCampusLeaderboard({ groupName: "mecw" }); + const [pending, failed] = result.members[0].challenges.sort((left, right) => + left.challengeId.localeCompare(right.challengeId), + ); + + expect(pending).toMatchObject({ challengeId: "c1", reviewed: false }); + expect(failed).toMatchObject({ challengeId: "c2", reviewed: true }); + expect(result.members[0].passingSubmissions).toBe(0); + }); + it("returns non-winning placements such as 2nd and 3rd place", async () => { leaderboardRows = [ participationRow({ diff --git a/src/reports/topcoder/dto/leaderboard-generic.dto.ts b/src/reports/topcoder/dto/leaderboard-generic.dto.ts index 69e9a46..0770ef1 100644 --- a/src/reports/topcoder/dto/leaderboard-generic.dto.ts +++ b/src/reports/topcoder/dto/leaderboard-generic.dto.ts @@ -1,5 +1,12 @@ import { Transform } from "class-transformer"; -import { IsArray, IsBoolean, IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator"; +import { + IsArray, + IsBoolean, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, +} from "class-validator"; export class LeaderboardGenericQueryDto { @Transform(({ value }) => { diff --git a/src/reports/topcoder/dto/leaderboard-mm.dto.ts b/src/reports/topcoder/dto/leaderboard-mm.dto.ts index 5543bbc..687d15a 100644 --- a/src/reports/topcoder/dto/leaderboard-mm.dto.ts +++ b/src/reports/topcoder/dto/leaderboard-mm.dto.ts @@ -1,5 +1,11 @@ import { Transform } from "class-transformer"; -import { IsArray, IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator"; +import { + IsArray, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, +} from "class-validator"; export class LeaderboardMmQueryDto { @Transform(({ value }) => { diff --git a/src/reports/topcoder/topcoder-reports.service.spec.ts b/src/reports/topcoder/topcoder-reports.service.spec.ts index 24a4a36..77a7999 100644 --- a/src/reports/topcoder/topcoder-reports.service.spec.ts +++ b/src/reports/topcoder/topcoder-reports.service.spec.ts @@ -239,9 +239,11 @@ describe("TopcoderReportsService", () => { "reports/topcoder/leaderboard-generic.sql", [["ch1"], false], ); - expect(resultWithoutRealtime.placementData[0].challengeScores?.ch1).toEqual({ - score: 123, - isProvisional: false, - }); + expect(resultWithoutRealtime.placementData[0].challengeScores?.ch1).toEqual( + { + score: 123, + isProvisional: false, + }, + ); }); }); diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 6866a86..150a433 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -160,6 +160,7 @@ type CampusLeaderboardRow = { registered: boolean | null; submitted: boolean | null; passedReview: boolean | null; + reviewed: boolean | null; submittedDate: Date | string | null; score: string | number | null; won: boolean | null; @@ -180,6 +181,7 @@ type CampusParticipationEntry = { submitted: boolean; submittedDate: string | null; passedReview: boolean; + reviewed: boolean; score: number | null; won: boolean; placement: number | null; @@ -1359,6 +1361,7 @@ export class TopcoderReportsService implements OnModuleDestroy { submitted: row.submitted === true, submittedDate: this.normalizeDate(row.submittedDate), passedReview: row.passedReview === true, + reviewed: row.reviewed === true, score: this.toNullableNumber(row.score), won: row.won === true, placement: row.placement ?? null, From 5f31b53bf98c5076ecb28830d3d9379b8c17948a Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 26 Aug 2026 09:50:42 +0300 Subject: [PATCH 2/2] PM-5370 - campus leaderboard - remove group membership restriction --- .../topcoder/campus-leaderboard-group.sql | 33 +++--------------- .../campus-leaderboard.service.spec.ts | 27 ++------------- .../topcoder/topcoder-reports.controller.ts | 22 +++--------- .../topcoder/topcoder-reports.service.ts | 34 ++++--------------- 4 files changed, 18 insertions(+), 98 deletions(-) diff --git a/sql/reports/topcoder/campus-leaderboard-group.sql b/sql/reports/topcoder/campus-leaderboard-group.sql index 1e36c5c..219a2f6 100644 --- a/sql/reports/topcoder/campus-leaderboard-group.sql +++ b/sql/reports/topcoder/campus-leaderboard-group.sql @@ -1,10 +1,7 @@ --- Resolves a campus program group by name (or id / legacy id) and reports whether --- the caller ($2, an optional user id) belongs to it, directly or via a sub-group. --- $1 = group name (case insensitive), $2 = caller user id (nullable) -WITH RECURSIVE params AS ( - SELECT - LOWER(BTRIM($1)) AS group_key, - NULLIF(BTRIM(COALESCE($2, '')), '') AS caller_id +-- Resolves a campus program group by name (or id / legacy id). +-- $1 = group name (case insensitive) +WITH params AS ( + SELECT LOWER(BTRIM($1)) AS group_key ), root_group AS ( SELECT @@ -19,30 +16,10 @@ root_group AS ( OR LOWER(COALESCE(g."oldId", '')) = p.group_key ORDER BY (LOWER(g.name) = p.group_key) DESC, g."createdAt" ASC LIMIT 1 -), -group_tree AS ( - SELECT rg.id - FROM root_group AS rg - UNION - SELECT gm."memberId" - FROM groups."GroupMember" AS gm - JOIN group_tree AS gt - ON gt.id = gm."groupId" - WHERE LOWER(gm."membershipType") = 'group' ) SELECT rg.id AS "groupId", rg.name AS "groupName", rg."oldId" AS "groupOldId", - rg."privateGroup" AS "privateGroup", - EXISTS ( - SELECT 1 - FROM groups."GroupMember" AS gm - JOIN group_tree AS gt - ON gt.id = gm."groupId" - CROSS JOIN params AS p - WHERE LOWER(gm."membershipType") = 'user' - AND p.caller_id IS NOT NULL - AND gm."memberId" = p.caller_id - ) AS "callerIsMember" + rg."privateGroup" AS "privateGroup" FROM root_group AS rg; diff --git a/src/reports/topcoder/campus-leaderboard.service.spec.ts b/src/reports/topcoder/campus-leaderboard.service.spec.ts index dfdb7a0..92ffad5 100644 --- a/src/reports/topcoder/campus-leaderboard.service.spec.ts +++ b/src/reports/topcoder/campus-leaderboard.service.spec.ts @@ -1,4 +1,4 @@ -import { ForbiddenException, NotFoundException } from "@nestjs/common"; +import { NotFoundException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { DbService } from "../../db/db.service"; import { SqlLoaderService } from "../../common/sql-loader.service"; @@ -52,7 +52,6 @@ describe("TopcoderReportsService.getCampusLeaderboard", () => { groupName: "MECW", groupOldId: null, privateGroup: false, - callerIsMember: false, }, ]; leaderboardRows = []; @@ -90,39 +89,19 @@ describe("TopcoderReportsService.getCampusLeaderboard", () => { ).rejects.toBeInstanceOf(NotFoundException); }); - it("rejects non-members without report access for private groups", async () => { + it("allows any caller to read private group leaderboards", async () => { groupRows = [ { groupId: "group-1", groupName: "MECW", groupOldId: null, privateGroup: true, - callerIsMember: false, - }, - ]; - - await expect( - service.getCampusLeaderboard( - { groupName: "mecw" }, - { userId: 999, hasReportAccess: false }, - ), - ).rejects.toBeInstanceOf(ForbiddenException); - }); - - it("allows group members and report readers to read private group leaderboards", async () => { - groupRows = [ - { - groupId: "group-1", - groupName: "MECW", - groupOldId: null, - privateGroup: true, - callerIsMember: true, }, ]; leaderboardRows = [participationRow()]; await expect( - service.getCampusLeaderboard({ groupName: "mecw" }, { userId: 1 }), + service.getCampusLeaderboard({ groupName: "mecw" }), ).resolves.toMatchObject({ group: { id: "group-1", name: "MECW" } }); }); diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index 3d2d8cd..fc375f5 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -3,7 +3,6 @@ import { Get, Param, Query, - Req, UseGuards, UseInterceptors, } from "@nestjs/common"; @@ -21,7 +20,6 @@ import { TopcoderReportsGuard } from "../../auth/guards/topcoder-reports.guard"; import { CsvResponseInterceptor } from "../../common/interceptors/csv-response.interceptor"; import { Scopes as RequiredScopes } from "../../auth/decorators/scopes.decorator"; import { Scopes as AppScopes } from "../../app-constants"; -import { AuthUserLike, hasAccessToScopes } from "../../auth/permissions.util"; @ApiTags("Topcoder Reports") @ApiBearerAuth() @@ -69,27 +67,15 @@ export class TopcoderReportsController { } @Get("/topcoder/leaderboard/campus") - // Campus members read their own program leaderboard, so no report scope is - // required; access to private groups is enforced in the service instead. + // Campus program leaderboards are readable by any authenticated caller, so no + // report scope is required. @RequiredScopes() @ApiOperation({ summary: "Campus program leaderboard for every member of the requested group, including members with no challenge activity", }) - getCampusLeaderboard( - @Query() query: CampusLeaderboardQueryDto, - @Req() request: { authUser?: AuthUserLike & { userId?: string | number } }, - ) { - const authUser = request.authUser; - - return this.reports.getCampusLeaderboard(query, { - userId: authUser?.userId, - hasReportAccess: hasAccessToScopes(authUser, [ - AppScopes.AllReports, - AppScopes.TopcoderReports, - AppScopes.TopcoderLeaderboardReports, - ]), - }); + getCampusLeaderboard(@Query() query: CampusLeaderboardQueryDto) { + return this.reports.getCampusLeaderboard(query); } @Get("/topcoder/leaderboard/mm") diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 150a433..c35643d 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -1,5 +1,4 @@ import { - ForbiddenException, Injectable, NotFoundException, OnModuleDestroy, @@ -135,7 +134,6 @@ type CampusGroupRow = { groupName: string; groupOldId: string | null; privateGroup: boolean; - callerIsMember: boolean; }; type CampusLeaderboardRow = { @@ -1263,47 +1261,27 @@ export class TopcoderReportsService implements OnModuleDestroy { * passing submissions are counted at most once per member per challenge. Tasks * and First2Finish challenges are excluded. * + * Readable by any authenticated caller. + * * @param filters Group name and challenge visibility filter. - * @param caller Caller identity used to authorize access to private groups. */ - async getCampusLeaderboard( - filters: { - groupName: string; - challengeFilter?: CampusChallengeFilter; - }, - caller: { - userId?: string | number | null; - hasReportAccess?: boolean; - } = {}, - ) { + async getCampusLeaderboard(filters: { + groupName: string; + challengeFilter?: CampusChallengeFilter; + }) { const groupName = filters.groupName?.trim() ?? ""; - const callerId = - caller.userId === null || caller.userId === undefined - ? null - : String(caller.userId).trim() || null; const groupQuery = this.sql.load( "reports/topcoder/campus-leaderboard-group.sql", ); const [group] = await this.db.query(groupQuery, [ groupName, - callerId, ]); if (!group) { throw new NotFoundException(`Group "${groupName}" was not found.`); } - if ( - group.privateGroup && - !caller.hasReportAccess && - !group.callerIsMember - ) { - throw new ForbiddenException( - "You do not have the required permissions to access this leaderboard.", - ); - } - const challengeFilter = filters.challengeFilter ?? CampusChallengeFilter.All; const query = this.sql.load("reports/topcoder/campus-leaderboard.sql");