From 9d04237964ca3cb0fcb98bdff106aaeac3fc95b2 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 3 Apr 2026 03:43:56 +1100 Subject: [PATCH] PM-4151: add MM final scores to challenge exports What was broken Marathon Match challenge exports in the new challenge reports only exposed provisionalScore and finalRank. When a challenge only had final review scores for some members, those rows exported with blank score cells and no finalScore column even though the ranking logic already had access to final scores. Root cause (if identifiable) The earlier PM-4151 follow-up on develop reshaped Marathon Match rows to publish only provisionalScore and finalRank. The SQL still loaded final review scores for effective ranking, but the report queries and formatter never projected finalScore into the exported record shape. What was changed Added finalScore to the Marathon Match challenge report DTO and formatter output. Updated the submitters, valid-submitters, and winners SQL to emit rounded finalScore values while preserving the existing effective-rank ordering and tie-break behavior. Updated the challenge report catalog descriptions to document provisionalScore and finalScore availability. Included the repo lint auto-format on one existing conditional in topcoder-reports.service.ts. Any added/updated tests Added ChallengesReportsService regression tests covering Marathon Match submitter and winner exports plus the unchanged standard challenge submissionScore shape. The new challenge report spec passes. The repo-wide pnpm test command still fails in unrelated pre-existing SFDC specs on this branch. --- sql/reports/challenges/submitters.sql | 8 + sql/reports/challenges/valid-submitters.sql | 8 + sql/reports/challenges/winners.sql | 10 +- .../challenges-reports.service.spec.ts | 144 ++++++++++++++++++ .../challenges/challenges-reports.service.ts | 3 +- .../challenges/dtos/challenge-users.dto.ts | 7 +- src/reports/report-directory.data.ts | 6 +- .../topcoder/topcoder-reports.service.ts | 4 +- 8 files changed, 179 insertions(+), 11 deletions(-) create mode 100644 src/reports/challenges/challenges-reports.service.spec.ts diff --git a/sql/reports/challenges/submitters.sql b/sql/reports/challenges/submitters.sql index 9231bc8..959cd9a 100644 --- a/sql/reports/challenges/submitters.sql +++ b/sql/reports/challenges/submitters.sql @@ -85,6 +85,10 @@ mm_ranked_scores AS ( WHEN mlss.provisional_score_raw IS NULL THEN NULL ELSE ROUND(mlss.provisional_score_raw::numeric, 2) END AS "provisionalScore", + CASE + WHEN mlss.final_score_raw IS NULL THEN NULL + ELSE ROUND(mlss.final_score_raw::numeric, 2) + END AS "finalScore", CASE WHEN mlss.effective_score_raw IS NULL THEN NULL ELSE ROW_NUMBER() OVER ( @@ -127,6 +131,10 @@ SELECT WHEN sm.is_marathon_match THEN mrs."provisionalScore" ELSE NULL END AS "provisionalScore", + CASE + WHEN sm.is_marathon_match THEN mrs."finalScore" + ELSE NULL + END AS "finalScore", CASE WHEN sm.is_marathon_match THEN mrs."finalRank" ELSE NULL diff --git a/sql/reports/challenges/valid-submitters.sql b/sql/reports/challenges/valid-submitters.sql index c223e44..85bb671 100644 --- a/sql/reports/challenges/valid-submitters.sql +++ b/sql/reports/challenges/valid-submitters.sql @@ -102,6 +102,10 @@ mm_ranked_scores AS ( WHEN mlss.provisional_score_raw IS NULL THEN NULL ELSE ROUND(mlss.provisional_score_raw::numeric, 2) END AS "provisionalScore", + CASE + WHEN mlss.final_score_raw IS NULL THEN NULL + ELSE ROUND(mlss.final_score_raw::numeric, 2) + END AS "finalScore", CASE WHEN mlss.effective_score_raw IS NULL THEN NULL ELSE ROW_NUMBER() OVER ( @@ -144,6 +148,10 @@ SELECT WHEN vsm.is_marathon_match THEN mrs."provisionalScore" ELSE NULL END AS "provisionalScore", + CASE + WHEN vsm.is_marathon_match THEN mrs."finalScore" + ELSE NULL + END AS "finalScore", CASE WHEN vsm.is_marathon_match THEN mrs."finalRank" ELSE NULL diff --git a/sql/reports/challenges/winners.sql b/sql/reports/challenges/winners.sql index a9ae4df..68a1f3c 100644 --- a/sql/reports/challenges/winners.sql +++ b/sql/reports/challenges/winners.sql @@ -72,7 +72,11 @@ mm_winner_scores AS ( CASE WHEN mms.provisional_score_raw IS NULL THEN NULL ELSE ROUND(mms.provisional_score_raw::numeric, 2) - END AS "provisionalScore" + END AS "provisionalScore", + CASE + WHEN mms.final_score_raw IS NULL THEN NULL + ELSE ROUND(mms.final_score_raw::numeric, 2) + END AS "finalScore" FROM mm_member_scores AS mms ) SELECT @@ -106,6 +110,10 @@ SELECT WHEN wm.is_marathon_match THEN mrs."provisionalScore" ELSE NULL END AS "provisionalScore", + CASE + WHEN wm.is_marathon_match THEN mrs."finalScore" + ELSE NULL + END AS "finalScore", CASE WHEN wm.is_marathon_match THEN wm.placement ELSE NULL diff --git a/src/reports/challenges/challenges-reports.service.spec.ts b/src/reports/challenges/challenges-reports.service.spec.ts new file mode 100644 index 0000000..ae69299 --- /dev/null +++ b/src/reports/challenges/challenges-reports.service.spec.ts @@ -0,0 +1,144 @@ +import { ChallengesReportsService } from "./challenges-reports.service"; + +describe("ChallengesReportsService", () => { + const db = { + query: jest.fn(), + }; + const sql = { + load: jest.fn(), + }; + + let service: ChallengesReportsService; + + beforeEach(() => { + db.query.mockReset(); + sql.load.mockReset(); + sql.load.mockReturnValue("SELECT 1"); + service = new ChallengesReportsService(db as any, sql as any); + }); + + it("returns Marathon Match submitters with provisional and final score columns", async () => { + db.query.mockResolvedValue([ + { + userId: 88770025, + handle: "devtest1400", + email: "jmgasper+devtest140@gmail.com", + country: "Australia", + isMarathonMatch: true, + provisionalScore: null, + finalScore: 96.42, + finalRank: 1, + }, + { + userId: 22655076, + handle: "liuliquan", + email: "sathya+1@crowdfirst.org", + country: "China", + isMarathonMatch: true, + provisionalScore: 89.18, + finalScore: null, + finalRank: 2, + }, + ]); + + const result = await service.getSubmitters({ + challengeId: "be34aea8-325f-4685-902d-0f356d5e76d0", + }); + + expect(sql.load).toHaveBeenCalledWith("reports/challenges/submitters.sql"); + expect(Object.keys(result[0])).toEqual([ + "userId", + "handle", + "email", + "country", + "provisionalScore", + "finalScore", + "finalRank", + ]); + expect(result).toEqual([ + { + userId: 88770025, + handle: "devtest1400", + email: "jmgasper+devtest140@gmail.com", + country: "Australia", + provisionalScore: null, + finalScore: 96.42, + finalRank: 1, + }, + { + userId: 22655076, + handle: "liuliquan", + email: "sathya+1@crowdfirst.org", + country: "China", + provisionalScore: 89.18, + finalScore: null, + finalRank: 2, + }, + ]); + }); + + it("returns Marathon Match winners with final scores when available", async () => { + db.query.mockResolvedValue([ + { + userId: 40158994, + handle: "TCConnCopilot", + email: "topcoderconnect+copilot@gmail.com", + country: "Afghanistan", + isMarathonMatch: true, + provisionalScore: null, + finalScore: 100, + finalRank: 1, + }, + ]); + + const result = await service.getWinners({ + challengeId: "be34aea8-325f-4685-902d-0f356d5e76d0", + }); + + expect(sql.load).toHaveBeenCalledWith("reports/challenges/winners.sql"); + expect(result).toEqual([ + { + userId: 40158994, + handle: "TCConnCopilot", + email: "topcoderconnect+copilot@gmail.com", + country: "Afghanistan", + provisionalScore: null, + finalScore: 100, + finalRank: 1, + }, + ]); + }); + + it("keeps standard challenge exports on the submissionScore shape", async () => { + db.query.mockResolvedValue([ + { + userId: 88778748, + handle: "disnadiji", + email: "disnadiji+4@gmail.com", + country: "Japan", + isMarathonMatch: false, + submissionScore: 34.34, + }, + ]); + + const result = await service.getValidSubmitters({ + challengeId: "3bb4d076-d2e7-4bd5-82ac-7eb4dd2d14a8", + }); + + expect(sql.load).toHaveBeenCalledWith( + "reports/challenges/valid-submitters.sql", + ); + expect(result).toEqual([ + { + userId: 88778748, + handle: "disnadiji", + email: "disnadiji+4@gmail.com", + country: "Japan", + submissionScore: 34.34, + }, + ]); + expect(result[0]).not.toHaveProperty("provisionalScore"); + expect(result[0]).not.toHaveProperty("finalScore"); + expect(result[0]).not.toHaveProperty("finalRank"); + }); +}); diff --git a/src/reports/challenges/challenges-reports.service.ts b/src/reports/challenges/challenges-reports.service.ts index aeefc48..13d69bf 100644 --- a/src/reports/challenges/challenges-reports.service.ts +++ b/src/reports/challenges/challenges-reports.service.ts @@ -184,7 +184,7 @@ export class ChallengesReportsService { /** * Normalizes raw challenge user report rows into the exported column shape. * @param records SQL rows for one challenge report, including the internal Marathon Match flag. - * @returns Export-ready records with either submissionScore or Marathon Match-specific columns. + * @returns Export-ready records with either submissionScore or the Marathon Match-specific score and ranking columns. * @throws Does not throw. It is used as a pure formatter inside the challenge report service methods. */ private formatChallengeUserReport( @@ -208,6 +208,7 @@ export class ChallengesReportsService { if (isMarathonMatch) { normalized.provisionalScore = record.provisionalScore ?? null; + normalized.finalScore = record.finalScore ?? null; normalized.finalRank = record.finalRank ?? null; return normalized; } diff --git a/src/reports/challenges/dtos/challenge-users.dto.ts b/src/reports/challenges/dtos/challenge-users.dto.ts index 33bf7b3..8845e5f 100644 --- a/src/reports/challenges/dtos/challenge-users.dto.ts +++ b/src/reports/challenges/dtos/challenge-users.dto.ts @@ -17,9 +17,9 @@ export class ChallengeUsersPathParamDto { /** * User record returned by challenge user reports including resolved country. * Standard challenge submission-based reports expose submissionScore. - * Marathon Match submission-based reports expose provisionalScore from the - * latest submission and finalRank by current effective score, breaking ties by - * earlier submission time. + * Marathon Match submission-based reports expose provisionalScore and + * finalScore from the latest submission, plus finalRank by current effective + * score, breaking ties by earlier submission time. */ export interface ChallengeUserRecordDto { userId: number; @@ -28,5 +28,6 @@ export interface ChallengeUserRecordDto { country: string | null; submissionScore?: number | null; provisionalScore?: number | null; + finalScore?: number | null; finalRank?: number | null; } diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index 5e91355..0c1eaee 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -390,21 +390,21 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { challengeReport( "Challenge Submitters", "/challenges/:challengeId/submitters", - "Return the challenge submitters report. Marathon Match exports use the latest submission provisionalScore and current effective rank, with earlier submission times winning score ties.", + "Return the challenge submitters report. Marathon Match exports use the latest submission provisionalScore and finalScore when available, plus the current effective rank, with earlier submission times winning score ties.", AppScopes.Challenge.Submitters, [challengeIdParam], ), challengeReport( "Challenge Valid Submitters", "/challenges/:challengeId/valid-submitters", - "Return the challenge valid submitters report. Marathon Match exports use the latest submission provisionalScore and current effective rank, with earlier submission times winning score ties.", + "Return the challenge valid submitters report. Marathon Match exports use the latest submission provisionalScore and finalScore when available, plus the current effective rank, with earlier submission times winning score ties.", AppScopes.Challenge.ValidSubmitters, [challengeIdParam], ), challengeReport( "Challenge Winners", "/challenges/:challengeId/winners", - "Return the challenge winners report with placement winners only. Marathon Match exports include provisionalScore and the challenge-result finalRank.", + "Return the challenge winners report with placement winners only. Marathon Match exports include provisionalScore, finalScore, and the challenge-result finalRank.", AppScopes.Challenge.Winners, [challengeIdParam], ), diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 6342939..2f07a16 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -703,9 +703,7 @@ export class TopcoderReportsService { principalSkills: row.principalSkills || undefined, openToWork: row.openToWork ?? null, isOpenToWork: - typeof row.isOpenToWork === "boolean" - ? row.isOpenToWork - : false, + typeof row.isOpenToWork === "boolean" ? row.isOpenToWork : false, })); return {