diff --git a/sql/reports/challenges/submitters.sql b/sql/reports/challenges/submitters.sql index 3a19f08..2007671 100644 --- a/sql/reports/challenges/submitters.sql +++ b/sql/reports/challenges/submitters.sql @@ -1,7 +1,8 @@ WITH challenge_context AS ( SELECT c.id, - (ct.name = 'Marathon Match') AS is_marathon_match + (ct.name = 'Marathon Match') AS is_marathon_match, + (c.status = 'COMPLETED') AS is_completed FROM challenges."Challenge" AS c JOIN challenges."ChallengeType" AS ct ON ct.id = c."typeId" @@ -17,17 +18,46 @@ submission_metrics AS ( s."finalScore"::double precision, s."initialScore"::double precision ) AS standard_score, - provisional_review.provisional_score, - COALESCE( - final_review."aggregateScore", - s."finalScore"::double precision - ) AS final_score_raw + CASE + WHEN s.status IN ( + 'FAILED_SCREENING', + 'FAILED_REVIEW', + 'FAILED_CHECKPOINT_SCREENING', + 'FAILED_CHECKPOINT_REVIEW', + 'DELETED' + ) THEN NULL + WHEN provisional_review.has_provisional_review THEN CASE + WHEN provisional_review.provisional_score >= 0 THEN provisional_review.provisional_score + ELSE NULL + END + WHEN s."initialScore"::double precision >= 0 THEN s."initialScore"::double precision + ELSE NULL + END AS provisional_score, + CASE + WHEN NOT cc.is_completed THEN NULL + WHEN s.status IN ( + 'FAILED_SCREENING', + 'FAILED_REVIEW', + 'FAILED_CHECKPOINT_SCREENING', + 'FAILED_CHECKPOINT_REVIEW', + 'DELETED' + ) THEN NULL + WHEN final_review.has_final_review THEN CASE + WHEN final_review."aggregateScore" >= 0 THEN final_review."aggregateScore" + ELSE NULL + END + WHEN s."finalScore"::double precision >= 0 THEN s."finalScore"::double precision + ELSE NULL + END AS final_score_raw, + cc.is_completed FROM challenge_context AS cc JOIN reviews."submission" AS s ON s."challengeId" = cc.id AND s."memberId" IS NOT NULL LEFT JOIN LATERAL ( - SELECT rs."aggregateScore" + SELECT + TRUE AS has_final_review, + rs."aggregateScore" FROM reviews."reviewSummation" AS rs WHERE rs."submissionId" = s.id AND COALESCE(rs."isFinal", TRUE) = TRUE @@ -36,7 +66,9 @@ submission_metrics AS ( LIMIT 1 ) AS final_review ON TRUE LEFT JOIN LATERAL ( - SELECT rs."aggregateScore" AS provisional_score + SELECT + TRUE AS has_provisional_review, + rs."aggregateScore" AS provisional_score FROM reviews."reviewSummation" AS rs WHERE rs."submissionId" = s.id AND rs."isProvisional" IS TRUE @@ -73,9 +105,12 @@ mm_latest_submission_scores AS ( sm."memberId", sm.provisional_score AS provisional_score_raw, sm.final_score_raw, - COALESCE(sm.final_score_raw, sm.provisional_score) AS effective_score_raw, + sm.is_completed, sm.submission_timestamp FROM submission_metrics AS sm + WHERE + sm.provisional_score IS NOT NULL + OR sm.final_score_raw IS NOT NULL ORDER BY sm."memberId", sm.submission_timestamp DESC NULLS LAST, @@ -93,13 +128,13 @@ mm_ranked_scores AS ( 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 ( + WHEN mlss.is_completed AND mlss.final_score_raw IS NOT NULL THEN ROW_NUMBER() OVER ( ORDER BY - mlss.effective_score_raw DESC NULLS LAST, + mlss.final_score_raw DESC NULLS LAST, mlss.submission_timestamp ASC NULLS LAST, mlss."memberId" ASC ) + ELSE NULL END AS "finalRank" FROM mm_latest_submission_scores AS mlss ) @@ -177,6 +212,10 @@ ORDER BY WHEN sm.is_marathon_match THEN mrs."finalRank" ELSE NULL END ASC NULLS LAST, + CASE + WHEN sm.is_marathon_match AND mrs."finalRank" IS NULL THEN mrs."provisionalScore" + ELSE NULL + END DESC NULLS LAST, CASE WHEN sm.is_marathon_match THEN NULL ELSE sms."submissionScore" diff --git a/sql/reports/challenges/valid-submitters.sql b/sql/reports/challenges/valid-submitters.sql index 1f4ee22..3c6a252 100644 --- a/sql/reports/challenges/valid-submitters.sql +++ b/sql/reports/challenges/valid-submitters.sql @@ -1,7 +1,8 @@ WITH challenge_context AS ( SELECT c.id, - (ct.name = 'Marathon Match') AS is_marathon_match + (ct.name = 'Marathon Match') AS is_marathon_match, + (c.status = 'COMPLETED') AS is_completed FROM challenges."Challenge" AS c JOIN challenges."ChallengeType" AS ct ON ct.id = c."typeId" @@ -17,11 +18,38 @@ submission_metrics AS ( s."finalScore"::double precision, s."initialScore"::double precision ) AS standard_score, - provisional_review.provisional_score, - COALESCE( - final_review."aggregateScore", - s."finalScore"::double precision - ) AS final_score_raw, + CASE + WHEN s.status IN ( + 'FAILED_SCREENING', + 'FAILED_REVIEW', + 'FAILED_CHECKPOINT_SCREENING', + 'FAILED_CHECKPOINT_REVIEW', + 'DELETED' + ) THEN NULL + WHEN provisional_review.has_provisional_review THEN CASE + WHEN provisional_review.provisional_score >= 0 THEN provisional_review.provisional_score + ELSE NULL + END + WHEN s."initialScore"::double precision >= 0 THEN s."initialScore"::double precision + ELSE NULL + END AS provisional_score, + CASE + WHEN NOT cc.is_completed THEN NULL + WHEN s.status IN ( + 'FAILED_SCREENING', + 'FAILED_REVIEW', + 'FAILED_CHECKPOINT_SCREENING', + 'FAILED_CHECKPOINT_REVIEW', + 'DELETED' + ) THEN NULL + WHEN final_review.has_final_review THEN CASE + WHEN final_review."aggregateScore" >= 0 THEN final_review."aggregateScore" + ELSE NULL + END + WHEN s."finalScore"::double precision >= 0 THEN s."finalScore"::double precision + ELSE NULL + END AS final_score_raw, + cc.is_completed, ( passing_review.is_passing IS TRUE OR COALESCE(s."finalScore"::double precision, 0) > 98 @@ -31,7 +59,9 @@ submission_metrics AS ( ON s."challengeId" = cc.id AND s."memberId" IS NOT NULL LEFT JOIN LATERAL ( - SELECT rs."aggregateScore" + SELECT + TRUE AS has_final_review, + rs."aggregateScore" FROM reviews."reviewSummation" AS rs WHERE rs."submissionId" = s.id AND COALESCE(rs."isFinal", TRUE) = TRUE @@ -40,7 +70,9 @@ submission_metrics AS ( LIMIT 1 ) AS final_review ON TRUE LEFT JOIN LATERAL ( - SELECT rs."aggregateScore" AS provisional_score + SELECT + TRUE AS has_provisional_review, + rs."aggregateScore" AS provisional_score FROM reviews."reviewSummation" AS rs WHERE rs."submissionId" = s.id AND rs."isProvisional" IS TRUE @@ -90,9 +122,12 @@ mm_latest_submission_scores AS ( vsm."memberId", vsm.provisional_score AS provisional_score_raw, vsm.final_score_raw, - COALESCE(vsm.final_score_raw, vsm.provisional_score) AS effective_score_raw, + vsm.is_completed, vsm.submission_timestamp FROM valid_submission_metrics AS vsm + WHERE + vsm.provisional_score IS NOT NULL + OR vsm.final_score_raw IS NOT NULL ORDER BY vsm."memberId", vsm.submission_timestamp DESC NULLS LAST, @@ -110,13 +145,13 @@ mm_ranked_scores AS ( 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 ( + WHEN mlss.is_completed AND mlss.final_score_raw IS NOT NULL THEN ROW_NUMBER() OVER ( ORDER BY - mlss.effective_score_raw DESC NULLS LAST, + mlss.final_score_raw DESC NULLS LAST, mlss.submission_timestamp ASC NULLS LAST, mlss."memberId" ASC ) + ELSE NULL END AS "finalRank" FROM mm_latest_submission_scores AS mlss ) @@ -194,6 +229,10 @@ ORDER BY WHEN vsm.is_marathon_match THEN mrs."finalRank" ELSE NULL END ASC NULLS LAST, + CASE + WHEN vsm.is_marathon_match AND mrs."finalRank" IS NULL THEN mrs."provisionalScore" + ELSE NULL + END DESC NULLS LAST, CASE WHEN vsm.is_marathon_match THEN NULL ELSE sms."submissionScore" diff --git a/sql/reports/challenges/winners.sql b/sql/reports/challenges/winners.sql index 8dc628a..b45e236 100644 --- a/sql/reports/challenges/winners.sql +++ b/sql/reports/challenges/winners.sql @@ -1,7 +1,8 @@ WITH challenge_context AS ( SELECT c.id, - (ct.name = 'Marathon Match') AS is_marathon_match + (ct.name = 'Marathon Match') AS is_marathon_match, + (c.status = 'COMPLETED') AS is_completed FROM challenges."Challenge" AS c JOIN challenges."ChallengeType" AS ct ON ct.id = c."typeId" @@ -15,17 +16,45 @@ submission_metrics AS ( s."finalScore"::double precision, s."initialScore"::double precision ) AS standard_score, - provisional_review.provisional_score, - COALESCE( - final_review."aggregateScore", - s."finalScore"::double precision - ) AS final_score_raw + CASE + WHEN s.status IN ( + 'FAILED_SCREENING', + 'FAILED_REVIEW', + 'FAILED_CHECKPOINT_SCREENING', + 'FAILED_CHECKPOINT_REVIEW', + 'DELETED' + ) THEN NULL + WHEN provisional_review.has_provisional_review THEN CASE + WHEN provisional_review.provisional_score >= 0 THEN provisional_review.provisional_score + ELSE NULL + END + WHEN s."initialScore"::double precision >= 0 THEN s."initialScore"::double precision + ELSE NULL + END AS provisional_score, + CASE + WHEN NOT cc.is_completed THEN NULL + WHEN s.status IN ( + 'FAILED_SCREENING', + 'FAILED_REVIEW', + 'FAILED_CHECKPOINT_SCREENING', + 'FAILED_CHECKPOINT_REVIEW', + 'DELETED' + ) THEN NULL + WHEN final_review.has_final_review THEN CASE + WHEN final_review."aggregateScore" >= 0 THEN final_review."aggregateScore" + ELSE NULL + END + WHEN s."finalScore"::double precision >= 0 THEN s."finalScore"::double precision + ELSE NULL + END AS final_score_raw FROM challenge_context AS cc JOIN reviews."submission" AS s ON s."challengeId" = cc.id AND s."memberId" IS NOT NULL LEFT JOIN LATERAL ( - SELECT rs."aggregateScore" + SELECT + TRUE AS has_final_review, + rs."aggregateScore" FROM reviews."reviewSummation" AS rs WHERE rs."submissionId" = s.id AND COALESCE(rs."isFinal", TRUE) = TRUE @@ -34,15 +63,20 @@ submission_metrics AS ( LIMIT 1 ) AS final_review ON TRUE LEFT JOIN LATERAL ( - SELECT MAX(rs."aggregateScore") AS provisional_score + SELECT + TRUE AS has_provisional_review, + rs."aggregateScore" AS provisional_score FROM reviews."reviewSummation" AS rs WHERE rs."submissionId" = s.id AND rs."isProvisional" IS TRUE + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC + LIMIT 1 ) AS provisional_review ON TRUE ), winner_members AS MATERIALIZED ( SELECT cc.is_marathon_match, + cc.is_completed, cw."userId"::text AS "memberId", MAX(cw.handle) AS "winnerHandle", MIN(cw.placement) AS placement @@ -52,6 +86,7 @@ winner_members AS MATERIALIZED ( AND cw.type = 'PLACEMENT' GROUP BY cc.is_marathon_match, + cc.is_completed, cw."userId" ), standard_member_scores AS ( @@ -126,7 +161,7 @@ SELECT ELSE NULL END AS "finalScore", CASE - WHEN wm.is_marathon_match THEN wm.placement + WHEN wm.is_marathon_match AND wm.is_completed THEN wm.placement ELSE NULL END AS "finalRank" FROM winner_members AS wm diff --git a/src/reports/challenges/challenge-export-sql.spec.ts b/src/reports/challenges/challenge-export-sql.spec.ts index 15b92b1..ebc7157 100644 --- a/src/reports/challenges/challenge-export-sql.spec.ts +++ b/src/reports/challenges/challenge-export-sql.spec.ts @@ -3,18 +3,82 @@ import { SqlLoaderService } from "src/common/sql-loader.service"; describe("Challenge export SQL", () => { const sqlLoader = new SqlLoaderService(); - it.each([ + const challengeUserSqlPaths = [ "reports/challenges/submitters.sql", "reports/challenges/valid-submitters.sql", "reports/challenges/winners.sql", - ])( - "falls back to submission.finalScore in %s when no final review summary exists", + ]; + + it.each(challengeUserSqlPaths)( + "uses completed, non-failed Marathon Match final scores in %s", (sqlPath) => { const sql = sqlLoader.load(sqlPath); - expect(sql).toMatch( - /COALESCE\(\s*final_review\."aggregateScore",\s*s\."finalScore"::double precision\s*\)\s+AS final_score_raw/, + expect(sql).toContain(`(c.status = 'COMPLETED') AS is_completed`); + expect(sql).toContain("WHEN NOT cc.is_completed THEN NULL"); + expect(sql).toContain("TRUE AS has_final_review"); + expect(sql).toContain(`WHEN s.status IN (`); + expect(sql).toContain("WHEN final_review.has_final_review THEN CASE"); + expect(sql).toContain( + `WHEN final_review."aggregateScore" >= 0 THEN final_review."aggregateScore"`, + ); + expect(sql).toContain( + `WHEN s."finalScore"::double precision >= 0 THEN s."finalScore"::double precision`, ); }, ); + + it.each(challengeUserSqlPaths)( + "uses only non-failed Marathon Match provisional score fallbacks in %s", + (sqlPath) => { + const sql = sqlLoader.load(sqlPath); + + expect(sql).toContain( + "WHEN provisional_review.has_provisional_review THEN CASE", + ); + expect(sql).toContain( + "WHEN provisional_review.provisional_score >= 0 THEN provisional_review.provisional_score", + ); + expect(sql).toContain( + `WHEN s."initialScore"::double precision >= 0 THEN s."initialScore"::double precision`, + ); + expect(sql).toContain("TRUE AS has_provisional_review"); + expect(sql).toContain(`'FAILED_REVIEW'`); + expect(sql).toContain(`'DELETED'`); + }, + ); + + it.each(challengeUserSqlPaths)( + "guards failed Marathon Match provisional reviews before falling back in %s", + (sqlPath) => { + const sql = sqlLoader.load(sqlPath); + + expect(sql).not.toContain(`AND rs."aggregateScore" >= 0`); + }, + ); + + it.each([ + "reports/challenges/submitters.sql", + "reports/challenges/valid-submitters.sql", + ])("only ranks completed Marathon Match submissions in %s", (sqlPath) => { + const sql = sqlLoader.load(sqlPath); + + expect(sql).toContain( + "WHEN mlss.is_completed AND mlss.final_score_raw IS NOT NULL THEN ROW_NUMBER() OVER", + ); + expect(sql).toMatch( + /WHERE\s+\w+\.provisional_score IS NOT NULL\s+OR \w+\.final_score_raw IS NOT NULL/, + ); + expect(sql).toMatch( + /WHEN \w+\.is_marathon_match AND mrs\."finalRank" IS NULL THEN mrs\."provisionalScore"/, + ); + }); + + it("only returns Marathon Match winner finalRank for completed challenges", () => { + const sql = sqlLoader.load("reports/challenges/winners.sql"); + + expect(sql).toContain( + "WHEN wm.is_marathon_match AND wm.is_completed THEN wm.placement", + ); + }); }); diff --git a/src/reports/challenges/challenges-reports.service.spec.ts b/src/reports/challenges/challenges-reports.service.spec.ts index 999ac5d..91e5c17 100644 --- a/src/reports/challenges/challenges-reports.service.spec.ts +++ b/src/reports/challenges/challenges-reports.service.spec.ts @@ -87,6 +87,71 @@ describe("ChallengesReportsService", () => { ]); }); + it("omits Marathon Match final columns when final scoring is unavailable", async () => { + db.query.mockResolvedValue([ + { + userId: 88779578, + handle: "adipowfamo", + email: "topcodergh+adipowfamo@gmail.com", + firstName: "Adipo", + lastName: "Wfamo", + country: "Australia", + isMarathonMatch: true, + provisionalScore: 87.29, + finalScore: null, + finalRank: null, + }, + { + userId: 10000039, + handle: "testaws1", + email: "topcodergh+testaws1@gmail.com", + firstName: "Testaws", + lastName: "One", + country: "Japan", + isMarathonMatch: true, + provisionalScore: 90.83, + finalScore: null, + finalRank: null, + }, + ]); + + const result = await service.getSubmitters({ + challengeId: "1bb94965-32e3-40a6-9933-2c6bd9dcdca8", + }); + + expect(result).toEqual([ + { + userId: 88779578, + handle: "adipowfamo", + email: "topcodergh+adipowfamo@gmail.com", + firstName: "Adipo", + lastName: "Wfamo", + country: "Australia", + provisionalScore: 87.29, + }, + { + userId: 10000039, + handle: "testaws1", + email: "topcodergh+testaws1@gmail.com", + firstName: "Testaws", + lastName: "One", + country: "Japan", + provisionalScore: 90.83, + }, + ]); + expect(Object.keys(result[0])).toEqual([ + "userId", + "handle", + "email", + "firstName", + "lastName", + "country", + "provisionalScore", + ]); + expect(result[0]).not.toHaveProperty("finalScore"); + expect(result[0]).not.toHaveProperty("finalRank"); + }); + it("returns Marathon Match winners with final scores when available", async () => { db.query.mockResolvedValue([ { diff --git a/src/reports/challenges/challenges-reports.service.ts b/src/reports/challenges/challenges-reports.service.ts index a94d39a..df29539 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 the Marathon Match-specific score and ranking columns. + * @returns Export-ready records with either submissionScore or the available Marathon Match score and ranking columns. * @throws Does not throw. It is used as a pure formatter inside the challenge report service methods. */ private formatChallengeUserReport( @@ -197,6 +197,12 @@ export class ChallengesReportsService { const isMarathonMatch = records.some( (record) => record.isMarathonMatch === true, ); + const hasFinalScore = records.some( + (record) => record.finalScore !== null && record.finalScore !== undefined, + ); + const hasFinalRank = records.some( + (record) => record.finalRank !== null && record.finalRank !== undefined, + ); return records.map((record) => { const normalized: ChallengeUserRecordDto = { @@ -210,8 +216,12 @@ export class ChallengesReportsService { if (isMarathonMatch) { normalized.provisionalScore = record.provisionalScore ?? null; - normalized.finalScore = record.finalScore ?? null; - normalized.finalRank = record.finalRank ?? null; + if (hasFinalScore) { + normalized.finalScore = record.finalScore ?? null; + } + if (hasFinalRank) { + 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 8e5dcfb..be29742 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 and - * finalScore from the latest submission, plus finalRank by current effective - * score, breaking ties by earlier submission time. + * Marathon Match submission-based reports expose provisionalScore from the + * latest non-failed scored submission. Completed Marathon Match reports also + * expose finalScore and finalRank when final scoring data is available. */ export interface ChallengeUserRecordDto { userId: number; diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index eab8d1d..cb470c4 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -414,21 +414,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 finalScore when available, plus the current effective rank, with earlier submission times winning score ties.", + "Return the challenge submitters report. Marathon Match exports use the latest non-failed provisionalScore and include finalScore/finalRank only after final scoring is available for completed challenges.", 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 finalScore when available, plus the current effective rank, with earlier submission times winning score ties.", + "Return the challenge valid submitters report. Marathon Match exports use the latest non-failed provisionalScore and include finalScore/finalRank only after final scoring is available for completed challenges.", AppScopes.Challenge.ValidSubmitters, [challengeIdParam], ), challengeReport( "Challenge Winners", "/challenges/:challengeId/winners", - "Return the challenge winners report with placement winners only. Marathon Match exports include provisionalScore, finalScore, and the challenge-result finalRank.", + "Return the challenge winners report with placement winners only. Marathon Match exports include non-failed provisionalScore and completed challenge finalScore/finalRank values.", AppScopes.Challenge.Winners, [challengeIdParam], ),