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
33 changes: 5 additions & 28 deletions sql/reports/topcoder/campus-leaderboard-group.sql
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
19 changes: 18 additions & 1 deletion sql/reports/topcoder/campus-leaderboard.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
54 changes: 30 additions & 24 deletions src/reports/topcoder/campus-leaderboard.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -32,6 +32,7 @@ const participationRow = (overrides: Row = {}): Row => ({
registered: true,
submitted: false,
passedReview: false,
reviewed: false,
submittedDate: null,
score: null,
won: false,
Expand All @@ -51,7 +52,6 @@ describe("TopcoderReportsService.getCampusLeaderboard", () => {
groupName: "MECW",
groupOldId: null,
privateGroup: false,
callerIsMember: false,
},
];
leaderboardRows = [];
Expand Down Expand Up @@ -89,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" } });
});

Expand Down Expand Up @@ -181,6 +161,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({
Expand Down
9 changes: 8 additions & 1 deletion src/reports/topcoder/dto/leaderboard-generic.dto.ts
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand Down
8 changes: 7 additions & 1 deletion src/reports/topcoder/dto/leaderboard-mm.dto.ts
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand Down
22 changes: 4 additions & 18 deletions src/reports/topcoder/topcoder-reports.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
Get,
Param,
Query,
Req,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
Expand All @@ -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()
Expand Down Expand Up @@ -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")
Expand Down
10 changes: 6 additions & 4 deletions src/reports/topcoder/topcoder-reports.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
);
});
});
37 changes: 9 additions & 28 deletions src/reports/topcoder/topcoder-reports.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
OnModuleDestroy,
Expand Down Expand Up @@ -135,7 +134,6 @@ type CampusGroupRow = {
groupName: string;
groupOldId: string | null;
privateGroup: boolean;
callerIsMember: boolean;
};

type CampusLeaderboardRow = {
Expand All @@ -160,6 +158,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;
Expand All @@ -180,6 +179,7 @@ type CampusParticipationEntry = {
submitted: boolean;
submittedDate: string | null;
passedReview: boolean;
reviewed: boolean;
score: number | null;
won: boolean;
placement: number | null;
Expand Down Expand Up @@ -1261,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<CampusGroupRow>(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");
Expand Down Expand Up @@ -1359,6 +1339,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,
Expand Down
Loading