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 {