From c367f65531dc7ee9f00ace230dfc17394989a287 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Fri, 19 Jun 2026 10:26:34 +1000 Subject: [PATCH 1/9] PM-4151: suppress incomplete MM final export data What was broken Marathon Match submitter exports could show negative failed-run scores, finalScore values while a challenge was still in submission, and a finalRank column before the challenge was completed. Root cause The challenge export SQL treated any final score field as exportable regardless of challenge status, used negative review summation scores as normal scores, and always emitted Marathon Match final columns from the formatter. What was changed Changed the submitter, valid submitter, and winner SQL to ignore negative Marathon Match scores, only expose finalScore/finalRank data for completed challenges, and avoid falling back to submission.finalScore when a final review row explicitly failed. Updated the challenge report formatter so Marathon Match finalScore and finalRank columns are only emitted when at least one row has those values. Updated report descriptions and DTO documentation to match the completed-only final data behavior. Any added/updated tests Updated the challenge SQL regression spec for completed-only final scores, negative score filtering, and completed-only final ranks. Added a service regression test that verifies active Marathon Match exports omit finalScore and finalRank columns when final scoring is unavailable. --- sql/reports/challenges/submitters.sql | 32 ++++++--- sql/reports/challenges/valid-submitters.sql | 32 ++++++--- sql/reports/challenges/winners.sql | 23 +++++-- .../challenges/challenge-export-sql.spec.ts | 51 +++++++++++++-- .../challenges-reports.service.spec.ts | 65 +++++++++++++++++++ .../challenges/challenges-reports.service.ts | 16 ++++- .../challenges/dtos/challenge-users.dto.ts | 6 +- src/reports/report-directory.data.ts | 6 +- 8 files changed, 193 insertions(+), 38 deletions(-) diff --git a/sql/reports/challenges/submitters.sql b/sql/reports/challenges/submitters.sql index 3a19f08..1949c22 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" @@ -18,10 +19,18 @@ submission_metrics AS ( 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 cc.is_completed THEN CASE + WHEN final_review."aggregateScore" IS NOT NULL 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 + 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 @@ -40,6 +49,7 @@ submission_metrics AS ( FROM reviews."reviewSummation" AS rs WHERE rs."submissionId" = s.id AND rs."isProvisional" IS TRUE + AND rs."aggregateScore" >= 0 ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC LIMIT 1 ) AS provisional_review ON TRUE @@ -73,7 +83,7 @@ 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 ORDER BY @@ -93,13 +103,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 +187,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..77c4659 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" @@ -18,10 +19,18 @@ submission_metrics AS ( 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 cc.is_completed THEN CASE + WHEN final_review."aggregateScore" IS NOT NULL 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 + ELSE NULL + END AS final_score_raw, + cc.is_completed, ( passing_review.is_passing IS TRUE OR COALESCE(s."finalScore"::double precision, 0) > 98 @@ -44,6 +53,7 @@ submission_metrics AS ( FROM reviews."reviewSummation" AS rs WHERE rs."submissionId" = s.id AND rs."isProvisional" IS TRUE + AND rs."aggregateScore" >= 0 ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC LIMIT 1 ) AS provisional_review ON TRUE @@ -90,7 +100,7 @@ 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 ORDER BY @@ -110,13 +120,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 +204,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..7d338d2 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" @@ -16,10 +17,17 @@ submission_metrics AS ( 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 cc.is_completed THEN CASE + WHEN final_review."aggregateScore" IS NOT NULL 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 + ELSE NULL + END AS final_score_raw FROM challenge_context AS cc JOIN reviews."submission" AS s ON s."challengeId" = cc.id @@ -38,11 +46,13 @@ submission_metrics AS ( FROM reviews."reviewSummation" AS rs WHERE rs."submissionId" = s.id AND rs."isProvisional" IS TRUE + AND rs."aggregateScore" >= 0 ) 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 +62,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 +137,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..d752f55 100644 --- a/src/reports/challenges/challenge-export-sql.spec.ts +++ b/src/reports/challenges/challenge-export-sql.spec.ts @@ -3,18 +3,59 @@ 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 cc.is_completed THEN CASE"); + expect(sql).toContain( + `WHEN final_review."aggregateScore" IS NOT NULL 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)( + "filters failed negative Marathon Match provisional scores in %s", + (sqlPath) => { + const sql = sqlLoader.load(sqlPath); + + expect(sql).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( + /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..9204f70 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 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], ), From f9caad42b5f7c61ca1d221e31d1c5c545223dde9 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Fri, 19 Jun 2026 11:32:06 +1000 Subject: [PATCH 2/9] PM-4151: guard MM final score fallback What was broken The previous PM-4151 follow-up suppressed active Marathon Match final columns and negative scores, but the final score fallback still depended on final_review.aggregateScore being non-null. If a final review row exists without a valid non-negative score, the query could still fall back to stale submission.finalScore. Root cause The final review lateral subquery only projected aggregateScore, so the scoring CASE could not distinguish no final review row from a final review row that exists but is not usable for export. What was changed Projected a has_final_review flag from the final review lateral subqueries for submitter, valid submitter, and winner exports. The Marathon Match final score CASE now falls back to submission.finalScore only when no final review row exists, while existing final review rows with negative or otherwise unusable scores export null. Any added/updated tests Updated the challenge export SQL regression spec to require the final review presence guard across submitter, valid submitter, and winner SQL. --- sql/reports/challenges/submitters.sql | 6 ++++-- sql/reports/challenges/valid-submitters.sql | 6 ++++-- sql/reports/challenges/winners.sql | 6 ++++-- src/reports/challenges/challenge-export-sql.spec.ts | 5 ++--- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/sql/reports/challenges/submitters.sql b/sql/reports/challenges/submitters.sql index 1949c22..5e1c02a 100644 --- a/sql/reports/challenges/submitters.sql +++ b/sql/reports/challenges/submitters.sql @@ -21,7 +21,7 @@ submission_metrics AS ( provisional_review.provisional_score, CASE WHEN cc.is_completed THEN CASE - WHEN final_review."aggregateScore" IS NOT NULL THEN CASE + WHEN final_review.has_final_review THEN CASE WHEN final_review."aggregateScore" >= 0 THEN final_review."aggregateScore" ELSE NULL END @@ -36,7 +36,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 diff --git a/sql/reports/challenges/valid-submitters.sql b/sql/reports/challenges/valid-submitters.sql index 77c4659..1212929 100644 --- a/sql/reports/challenges/valid-submitters.sql +++ b/sql/reports/challenges/valid-submitters.sql @@ -21,7 +21,7 @@ submission_metrics AS ( provisional_review.provisional_score, CASE WHEN cc.is_completed THEN CASE - WHEN final_review."aggregateScore" IS NOT NULL THEN CASE + WHEN final_review.has_final_review THEN CASE WHEN final_review."aggregateScore" >= 0 THEN final_review."aggregateScore" ELSE NULL END @@ -40,7 +40,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 diff --git a/sql/reports/challenges/winners.sql b/sql/reports/challenges/winners.sql index 7d338d2..55fe21d 100644 --- a/sql/reports/challenges/winners.sql +++ b/sql/reports/challenges/winners.sql @@ -19,7 +19,7 @@ submission_metrics AS ( provisional_review.provisional_score, CASE WHEN cc.is_completed THEN CASE - WHEN final_review."aggregateScore" IS NOT NULL THEN CASE + WHEN final_review.has_final_review THEN CASE WHEN final_review."aggregateScore" >= 0 THEN final_review."aggregateScore" ELSE NULL END @@ -33,7 +33,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 diff --git a/src/reports/challenges/challenge-export-sql.spec.ts b/src/reports/challenges/challenge-export-sql.spec.ts index d752f55..2c40035 100644 --- a/src/reports/challenges/challenge-export-sql.spec.ts +++ b/src/reports/challenges/challenge-export-sql.spec.ts @@ -16,9 +16,8 @@ describe("Challenge export SQL", () => { expect(sql).toContain(`(c.status = 'COMPLETED') AS is_completed`); expect(sql).toContain("WHEN cc.is_completed THEN CASE"); - expect(sql).toContain( - `WHEN final_review."aggregateScore" IS NOT NULL THEN CASE`, - ); + expect(sql).toContain("TRUE AS has_final_review"); + expect(sql).toContain("WHEN final_review.has_final_review THEN CASE"); expect(sql).toContain( `WHEN final_review."aggregateScore" >= 0 THEN final_review."aggregateScore"`, ); From e3be399b22081586b8fdeecbf03e9f2c913728ac Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 22 Jun 2026 16:47:05 +1000 Subject: [PATCH 3/9] PM-4151: ignore failed MM score runs What was broken The previous PM-4151 follow-up suppressed active Marathon Match final columns and guarded final review fallbacks, but failed or deleted Marathon Match submissions could still influence score output. Positive initialScore/finalScore fallbacks from failed submissions could leak stale scores, and a newest failed submission with no usable score could mask an earlier valid scored run. Root cause The challenge export SQL filtered negative review summation scores, but the submission.initialScore and submission.finalScore fallback paths did not check reviews.submission.status. The submitter and valid submitter ranking CTEs also selected the newest submission before checking whether it had any usable Marathon Match score. What was changed Added failed/deleted submission status guards before using Marathon Match provisional and final score fallbacks in submitter, valid submitter, and winner exports. Changed Marathon Match submitter and valid submitter score selection to skip rows where both provisionalScore and finalScore are unavailable, so the latest usable scored run is used instead of a failed no-score run. Updated the challenge user DTO documentation to describe the latest non-failed scored submission behavior. Any added/updated tests Updated src/reports/challenges/challenge-export-sql.spec.ts to cover failed/deleted status guards, provisional initialScore fallback, and latest usable score selection for Marathon Match submitter exports. --- sql/reports/challenges/submitters.sql | 33 +++++++++++++++---- sql/reports/challenges/valid-submitters.sql | 33 +++++++++++++++---- sql/reports/challenges/winners.sql | 30 +++++++++++++---- .../challenges/challenge-export-sql.spec.ts | 22 ++++++++++++- .../challenges/dtos/challenge-users.dto.ts | 4 +-- 5 files changed, 98 insertions(+), 24 deletions(-) diff --git a/sql/reports/challenges/submitters.sql b/sql/reports/challenges/submitters.sql index 5e1c02a..6c6bfe6 100644 --- a/sql/reports/challenges/submitters.sql +++ b/sql/reports/challenges/submitters.sql @@ -18,16 +18,32 @@ submission_metrics AS ( s."finalScore"::double precision, s."initialScore"::double precision ) AS standard_score, - provisional_review.provisional_score, CASE - WHEN cc.is_completed THEN CASE - 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 + WHEN s.status IN ( + 'FAILED_SCREENING', + 'FAILED_REVIEW', + 'FAILED_CHECKPOINT_SCREENING', + 'FAILED_CHECKPOINT_REVIEW', + 'DELETED' + ) THEN NULL + WHEN provisional_review.provisional_score IS NOT NULL THEN provisional_review.provisional_score + 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 @@ -88,6 +104,9 @@ mm_latest_submission_scores AS ( 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, diff --git a/sql/reports/challenges/valid-submitters.sql b/sql/reports/challenges/valid-submitters.sql index 1212929..434c0f2 100644 --- a/sql/reports/challenges/valid-submitters.sql +++ b/sql/reports/challenges/valid-submitters.sql @@ -18,16 +18,32 @@ submission_metrics AS ( s."finalScore"::double precision, s."initialScore"::double precision ) AS standard_score, - provisional_review.provisional_score, CASE - WHEN cc.is_completed THEN CASE - 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 + WHEN s.status IN ( + 'FAILED_SCREENING', + 'FAILED_REVIEW', + 'FAILED_CHECKPOINT_SCREENING', + 'FAILED_CHECKPOINT_REVIEW', + 'DELETED' + ) THEN NULL + WHEN provisional_review.provisional_score IS NOT NULL THEN provisional_review.provisional_score + 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, @@ -105,6 +121,9 @@ mm_latest_submission_scores AS ( 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, diff --git a/sql/reports/challenges/winners.sql b/sql/reports/challenges/winners.sql index 55fe21d..0301be2 100644 --- a/sql/reports/challenges/winners.sql +++ b/sql/reports/challenges/winners.sql @@ -16,16 +16,32 @@ submission_metrics AS ( s."finalScore"::double precision, s."initialScore"::double precision ) AS standard_score, - provisional_review.provisional_score, CASE - WHEN cc.is_completed THEN CASE - 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 + WHEN s.status IN ( + 'FAILED_SCREENING', + 'FAILED_REVIEW', + 'FAILED_CHECKPOINT_SCREENING', + 'FAILED_CHECKPOINT_REVIEW', + 'DELETED' + ) THEN NULL + WHEN provisional_review.provisional_score IS NOT NULL THEN provisional_review.provisional_score + 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 diff --git a/src/reports/challenges/challenge-export-sql.spec.ts b/src/reports/challenges/challenge-export-sql.spec.ts index 2c40035..66dc31f 100644 --- a/src/reports/challenges/challenge-export-sql.spec.ts +++ b/src/reports/challenges/challenge-export-sql.spec.ts @@ -15,8 +15,9 @@ describe("Challenge export SQL", () => { const sql = sqlLoader.load(sqlPath); expect(sql).toContain(`(c.status = 'COMPLETED') AS is_completed`); - expect(sql).toContain("WHEN cc.is_completed THEN CASE"); + 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"`, @@ -27,6 +28,22 @@ describe("Challenge export SQL", () => { }, ); + 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.provisional_score IS NOT NULL THEN provisional_review.provisional_score", + ); + expect(sql).toContain( + `WHEN s."initialScore"::double precision >= 0 THEN s."initialScore"::double precision`, + ); + expect(sql).toContain(`'FAILED_REVIEW'`); + expect(sql).toContain(`'DELETED'`); + }, + ); + it.each(challengeUserSqlPaths)( "filters failed negative Marathon Match provisional scores in %s", (sqlPath) => { @@ -45,6 +62,9 @@ describe("Challenge export SQL", () => { 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"/, ); diff --git a/src/reports/challenges/dtos/challenge-users.dto.ts b/src/reports/challenges/dtos/challenge-users.dto.ts index 9204f70..be29742 100644 --- a/src/reports/challenges/dtos/challenge-users.dto.ts +++ b/src/reports/challenges/dtos/challenge-users.dto.ts @@ -18,8 +18,8 @@ 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. Completed Marathon Match reports also expose finalScore - * and finalRank when final scoring data is available. + * 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; From f7b045905c07a830b0b3a6399df5018a3cf9843e Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 23 Jun 2026 06:41:17 +1000 Subject: [PATCH 4/9] PM-4151: guard MM provisional review fallback What was broken The previous PM-4151 follow-up blocked failed/deleted Marathon Match submissions and guarded final score fallback, but a failed provisional review row could still leak stale score data. If the latest provisional review summation had a negative score, the SQL filtered that row out before fallback logic and then used submission.initialScore or an older positive provisional row. Root cause The provisional review lateral queries only selected non-negative reviewSummation rows, so the CASE expression could not distinguish no provisional review from a latest failed provisional review. The final score path already had a has_final_review guard, but the provisional score path did not have the same guard. What was changed Added a has_provisional_review guard to submitter, valid submitter, and winner exports. The reports now use the latest provisional review row, return the score only when that row is non-negative, and only fall back to submission.initialScore when no provisional review row exists. Any added/updated tests Updated src/reports/challenges/challenge-export-sql.spec.ts to assert the provisional review guard and prevent pre-filtering negative provisional review rows before fallback logic runs. --- sql/reports/challenges/submitters.sql | 10 +++++++--- sql/reports/challenges/valid-submitters.sql | 10 +++++++--- sql/reports/challenges/winners.sql | 12 +++++++++--- src/reports/challenges/challenge-export-sql.spec.ts | 10 +++++++--- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/sql/reports/challenges/submitters.sql b/sql/reports/challenges/submitters.sql index 6c6bfe6..2007671 100644 --- a/sql/reports/challenges/submitters.sql +++ b/sql/reports/challenges/submitters.sql @@ -26,7 +26,10 @@ submission_metrics AS ( 'FAILED_CHECKPOINT_REVIEW', 'DELETED' ) THEN NULL - WHEN provisional_review.provisional_score IS NOT NULL THEN provisional_review.provisional_score + 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, @@ -63,11 +66,12 @@ 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 - AND rs."aggregateScore" >= 0 ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC LIMIT 1 ) AS provisional_review ON TRUE diff --git a/sql/reports/challenges/valid-submitters.sql b/sql/reports/challenges/valid-submitters.sql index 434c0f2..3c6a252 100644 --- a/sql/reports/challenges/valid-submitters.sql +++ b/sql/reports/challenges/valid-submitters.sql @@ -26,7 +26,10 @@ submission_metrics AS ( 'FAILED_CHECKPOINT_REVIEW', 'DELETED' ) THEN NULL - WHEN provisional_review.provisional_score IS NOT NULL THEN provisional_review.provisional_score + 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, @@ -67,11 +70,12 @@ 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 - AND rs."aggregateScore" >= 0 ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC LIMIT 1 ) AS provisional_review ON TRUE diff --git a/sql/reports/challenges/winners.sql b/sql/reports/challenges/winners.sql index 0301be2..b45e236 100644 --- a/sql/reports/challenges/winners.sql +++ b/sql/reports/challenges/winners.sql @@ -24,7 +24,10 @@ submission_metrics AS ( 'FAILED_CHECKPOINT_REVIEW', 'DELETED' ) THEN NULL - WHEN provisional_review.provisional_score IS NOT NULL THEN provisional_review.provisional_score + 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, @@ -60,11 +63,14 @@ 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 - AND rs."aggregateScore" >= 0 + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC + LIMIT 1 ) AS provisional_review ON TRUE ), winner_members AS MATERIALIZED ( diff --git a/src/reports/challenges/challenge-export-sql.spec.ts b/src/reports/challenges/challenge-export-sql.spec.ts index 66dc31f..ebc7157 100644 --- a/src/reports/challenges/challenge-export-sql.spec.ts +++ b/src/reports/challenges/challenge-export-sql.spec.ts @@ -34,22 +34,26 @@ describe("Challenge export SQL", () => { const sql = sqlLoader.load(sqlPath); expect(sql).toContain( - "WHEN provisional_review.provisional_score IS NOT NULL THEN provisional_review.provisional_score", + "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)( - "filters failed negative Marathon Match provisional scores in %s", + "guards failed Marathon Match provisional reviews before falling back in %s", (sqlPath) => { const sql = sqlLoader.load(sqlPath); - expect(sql).toContain(`AND rs."aggregateScore" >= 0`); + expect(sql).not.toContain(`AND rs."aggregateScore" >= 0`); }, ); From a8d066616d71c8d442a255602849927d2fc9e6c7 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 23 Jun 2026 18:25:01 +1000 Subject: [PATCH 5/9] PM-4151: use latest MM winner scores What was broken The previous PM-4151 fixes made submitter and valid submitter exports choose the latest usable Marathon Match scored submission, but the winners export still aggregated all historical submission scores with MAX(). That could expose an older high provisional or final score for a winner after a later scored run existed. Root cause The winners SQL kept a separate Marathon Match score CTE that grouped by member and selected MAX(provisional_score) and MAX(final_score_raw). That bypassed the latest-submission selection pattern added to the other challenge export queries. What was changed Updated the winners export to select the latest usable Marathon Match scored submission by submission timestamp and submission id before rounding/exporting provisionalScore and finalScore. Updated the Reports Portal description for Challenge Winners to document the latest non-failed provisionalScore behavior. Any added/updated tests Updated src/reports/challenges/challenge-export-sql.spec.ts to assert all challenge user exports use DISTINCT ON latest scored submissions and do not aggregate Marathon Match provisional/final scores with MAX(). --- sql/reports/challenges/winners.sql | 30 ++++++++++++------- .../challenges/challenge-export-sql.spec.ts | 16 ++++++++++ src/reports/report-directory.data.ts | 2 +- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/sql/reports/challenges/winners.sql b/sql/reports/challenges/winners.sql index b45e236..85ac684 100644 --- a/sql/reports/challenges/winners.sql +++ b/sql/reports/challenges/winners.sql @@ -10,7 +10,9 @@ WITH challenge_context AS ( ), submission_metrics AS ( SELECT + s.id AS submission_id, s."memberId", + COALESCE(s."submittedDate", s."createdAt") AS submission_timestamp, COALESCE( final_review."aggregateScore", s."finalScore"::double precision, @@ -96,26 +98,32 @@ standard_member_scores AS ( FROM submission_metrics AS sm GROUP BY sm."memberId" ), -mm_member_scores AS ( - SELECT +mm_latest_submission_scores AS ( + SELECT DISTINCT ON (sm."memberId") sm."memberId", - MAX(sm.provisional_score) AS provisional_score_raw, - MAX(sm.final_score_raw) AS final_score_raw + sm.provisional_score AS provisional_score_raw, + sm.final_score_raw FROM submission_metrics AS sm - GROUP BY sm."memberId" + 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, + sm.submission_id DESC ), mm_winner_scores AS ( SELECT - mms."memberId", + mlss."memberId", CASE - WHEN mms.provisional_score_raw IS NULL THEN NULL - ELSE ROUND(mms.provisional_score_raw::numeric, 2) + WHEN mlss.provisional_score_raw IS NULL THEN NULL + ELSE ROUND(mlss.provisional_score_raw::numeric, 2) END AS "provisionalScore", CASE - WHEN mms.final_score_raw IS NULL THEN NULL - ELSE ROUND(mms.final_score_raw::numeric, 2) + WHEN mlss.final_score_raw IS NULL THEN NULL + ELSE ROUND(mlss.final_score_raw::numeric, 2) END AS "finalScore" - FROM mm_member_scores AS mms + FROM mm_latest_submission_scores AS mlss ) SELECT COALESCE( diff --git a/src/reports/challenges/challenge-export-sql.spec.ts b/src/reports/challenges/challenge-export-sql.spec.ts index ebc7157..2023771 100644 --- a/src/reports/challenges/challenge-export-sql.spec.ts +++ b/src/reports/challenges/challenge-export-sql.spec.ts @@ -74,6 +74,22 @@ describe("Challenge export SQL", () => { ); }); + it.each(challengeUserSqlPaths)( + "uses the latest usable Marathon Match scored submission in %s", + (sqlPath) => { + const sql = sqlLoader.load(sqlPath); + + expect(sql).toMatch(/SELECT DISTINCT ON \(\w+\."memberId"\)/); + expect(sql).toMatch( + /ORDER BY\s+\w+\."memberId",\s+\w+\.submission_timestamp DESC NULLS LAST,\s+\w+\.submission_id DESC/, + ); + expect(sql).not.toMatch( + /MAX\(\w+\.provisional_score\) AS provisional_score_raw/, + ); + expect(sql).not.toMatch(/MAX\(\w+\.final_score_raw\) AS final_score_raw/); + }, + ); + it("only returns Marathon Match winner finalRank for completed challenges", () => { const sql = sqlLoader.load("reports/challenges/winners.sql"); diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index cb470c4..3c110a2 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -428,7 +428,7 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { challengeReport( "Challenge Winners", "/challenges/:challengeId/winners", - "Return the challenge winners report with placement winners only. Marathon Match exports include non-failed provisionalScore and completed challenge finalScore/finalRank values.", + "Return the challenge winners report with placement winners only. Marathon Match exports use the latest non-failed provisionalScore and include completed challenge finalScore/finalRank values.", AppScopes.Challenge.Winners, [challengeIdParam], ), From 10703c6b7c9fd64939076c3a150433c961d766da Mon Sep 17 00:00:00 2001 From: jmgasper Date: Sun, 19 Jul 2026 13:56:28 +1000 Subject: [PATCH 6/9] Update deployment node version --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1993c1e..8550873 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # ---- Base Stage ---- -FROM node:20-alpine AS base +FROM node:22-alpine AS base WORKDIR /usr/src/app # ---- Dependencies Stage ---- From 8f7c88cbeb5f474e963bfb34c99c717f03b5c833 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Sun, 19 Jul 2026 14:24:35 +1000 Subject: [PATCH 7/9] Deployment fixes --- .dockerignore | 9 +++++++++ Dockerfile | 16 +++++++++------- package.json | 1 + pnpm-workspace.yaml | 9 +++++++++ 4 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 .dockerignore create mode 100644 pnpm-workspace.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9db5ab8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.github +.circleci +.env +.env.* +node_modules +dist +coverage +*.log diff --git a/Dockerfile b/Dockerfile index 8550873..905bed2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,18 +2,20 @@ FROM node:22-alpine AS base WORKDIR /usr/src/app +# ---- Package Manager Stage ---- +FROM base AS package-manager +# Pin pnpm so clean builds do not change behaviour when a new major is released. +RUN npm install --global pnpm@11.15.0 + # ---- Dependencies Stage ---- -FROM base AS deps -# Install pnpm -RUN npm install -g pnpm +FROM package-manager AS deps # Copy dependency-defining files -COPY package.json pnpm-lock.yaml ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ # Install dependencies RUN pnpm install --frozen-lockfile --prod # ---- Build Stage ---- -FROM base AS build -RUN npm install -g pnpm +FROM package-manager AS build COPY --from=deps /usr/src/app/node_modules ./node_modules COPY . . # Build the application @@ -21,7 +23,7 @@ RUN pnpm build # ---- Production Stage ---- FROM base AS production -ENV NODE_ENV production +ENV NODE_ENV=production # Copy built application from the build stage COPY --from=build /usr/src/app/dist ./dist COPY --from=build /usr/src/app/sql ./sql diff --git a/package.json b/package.json index f3ee922..c27abc0 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "reports-api-v6", "version": "0.0.1", "private": true, + "packageManager": "pnpm@11.15.0", "scripts": { "build": "nest build", "start": "node dist/main.js", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..b4b439e --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,9 @@ +packages: + - . + +allowBuilds: + '@nestjs/core@11.1.9': false + '@prisma/engines@7.0.1': true + '@scarf/scarf@1.4.0': false + dtrace-provider@0.8.8: false + prisma@7.0.1: true From 05fb91092a97e82604794de5d80e883f0bc21545 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Thu, 23 Jul 2026 15:58:03 +1000 Subject: [PATCH 8/9] Dashboard queries --- README.md | 37 ++ .../dashboard/challenge-participation.sql | 109 +++++ sql/reports/dashboard/members-paid.sql | 122 ++++++ sql/reports/dashboard/new-signups.sql | 79 ++++ src/app.module.ts | 2 + .../dashboard-reports.controller.spec.ts | 125 ++++++ .../dashboard/dashboard-reports.controller.ts | 170 +++++++ .../dashboard/dashboard-reports.dto.ts | 302 +++++++++++++ .../dashboard-reports.module.spec.ts | 63 +++ .../dashboard/dashboard-reports.module.ts | 21 + .../dashboard-reports.service.spec.ts | 351 +++++++++++++++ .../dashboard/dashboard-reports.service.ts | 413 ++++++++++++++++++ .../dashboard/dashboard-reports.sql.spec.ts | 64 +++ .../guards/dashboard-reports.guard.spec.ts | 79 ++++ .../guards/dashboard-reports.guard.ts | 67 +++ 15 files changed, 2004 insertions(+) create mode 100644 sql/reports/dashboard/challenge-participation.sql create mode 100644 sql/reports/dashboard/members-paid.sql create mode 100644 sql/reports/dashboard/new-signups.sql create mode 100644 src/reports/dashboard/dashboard-reports.controller.spec.ts create mode 100644 src/reports/dashboard/dashboard-reports.controller.ts create mode 100644 src/reports/dashboard/dashboard-reports.dto.ts create mode 100644 src/reports/dashboard/dashboard-reports.module.spec.ts create mode 100644 src/reports/dashboard/dashboard-reports.module.ts create mode 100644 src/reports/dashboard/dashboard-reports.service.spec.ts create mode 100644 src/reports/dashboard/dashboard-reports.service.ts create mode 100644 src/reports/dashboard/dashboard-reports.sql.spec.ts create mode 100644 src/reports/dashboard/guards/dashboard-reports.guard.spec.ts create mode 100644 src/reports/dashboard/guards/dashboard-reports.guard.ts diff --git a/README.md b/README.md index d53e594..60749df 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,43 @@ Reports return JSON data by default. Endpoints that support CSV can also return CSV when the request sets `Accept: text/csv` (including the Challenges, Topcoder, Member, and Admin report groups). +## Reports Portal Dashboards + +The Reports Portal dashboard API is available under +`/v6/reports/dashboard`: + +- `GET /v6/reports/dashboard` returns all dashboards, keyed as + `newSignups`, `membersPaid`, and `challengeParticipation`. +- `GET /v6/reports/dashboard/:dashboard` returns one dashboard. Supported + slugs are `new-signups`, `members-paid`, and `challenge-participation`. +- `GET /v6/reports/dashboard/export` downloads all monthly dashboard rows as + a flat CSV. +- `GET /v6/reports/dashboard/:dashboard/export` downloads one dashboard as a + flat CSV. + +All endpoints accept optional ISO-8601 `startDate` (inclusive) and `endDate` +(exclusive) query parameters. With neither bound, the monthly series covers +the latest six UTC calendar months, including the current month. With one +bound, the other is derived six calendar months away. The response always +includes the resolved timestamps, zero-filled calendar months, and an +all-time summary. + +Dashboard figures use these shared definitions: + +- Signups come from `identity.user.create_date`. `status = 'A'` is activated; + every other current status is not activated. +- Paid-member activity requires a `PAID` finance payment for a `PAYMENT` + winning. Its event timestamp is `date_paid`, falling back to `created_at`. + Members are deduplicated within each payment bucket and month. +- Registrations are Submitter resource creation events. Submissions are + non-deleted review submission events, using `submittedDate` and falling + back to `createdAt`. Each category is deduplicated independently by member + and month. +- Rates are percentages from 0 through 100. + +Human access is limited to Administrator and Talent Manager roles. Machine +tokens require the `reports:all` scope. + ## Security Currently, an M2M token is required to pull any report, and each report has its own scope associated with it that must be applied to the M2M token client ID diff --git a/sql/reports/dashboard/challenge-participation.sql b/sql/reports/dashboard/challenge-participation.sql new file mode 100644 index 0000000..b48a863 --- /dev/null +++ b/sql/reports/dashboard/challenge-participation.sql @@ -0,0 +1,109 @@ +-- Monthly challenge registration/submission activity and all-time summary. +-- +-- Parameters: +-- $1 timestamptz - inclusive reporting range start +-- $2 timestamptz - exclusive reporting range end +-- +-- Registration and submission are independent activity events. A member is +-- counted once in each category per month, regardless of challenge count. +WITH bounds AS ( + SELECT + $1::timestamptz AT TIME ZONE 'UTC' AS start_at, + $2::timestamptz AT TIME ZONE 'UTC' AS end_at +), +months AS ( + SELECT GENERATE_SERIES( + DATE_TRUNC('month', b.start_at), + DATE_TRUNC('month', b.end_at - INTERVAL '1 microsecond'), + INTERVAL '1 month' + ) AS month_start + FROM bounds b +), +registration_events AS MATERIALIZED ( + SELECT + NULLIF(TRIM(r."memberId"), '') AS member_id, + r."createdAt" AS activity_at + FROM resources."Resource" r + JOIN resources."ResourceRole" rr + ON rr.id = r."roleId" + WHERE COALESCE(NULLIF(TRIM(rr."nameLower"), ''), LOWER(rr.name)) = 'submitter' + AND NULLIF(TRIM(r."memberId"), '') IS NOT NULL +), +submission_events AS MATERIALIZED ( + SELECT + NULLIF(TRIM(s."memberId"), '') AS member_id, + COALESCE(s."submittedDate", s."createdAt") AS activity_at + FROM reviews.submission s + WHERE s.status <> 'DELETED' + AND NULLIF(TRIM(s."memberId"), '') IS NOT NULL +), +selected_registrations AS ( + SELECT + DATE_TRUNC('month', re.activity_at) AS month_start, + COUNT(DISTINCT re.member_id) AS registrants + FROM registration_events re + CROSS JOIN bounds b + WHERE re.activity_at >= b.start_at + AND re.activity_at < b.end_at + GROUP BY DATE_TRUNC('month', re.activity_at) +), +selected_submissions AS ( + SELECT + DATE_TRUNC('month', se.activity_at) AS month_start, + COUNT(DISTINCT se.member_id) AS submitters + FROM submission_events se + CROSS JOIN bounds b + WHERE se.activity_at >= b.start_at + AND se.activity_at < b.end_at + GROUP BY DATE_TRUNC('month', se.activity_at) +), +all_time_registration_months AS ( + SELECT + DATE_TRUNC('month', re.activity_at) AS month_start, + COUNT(DISTINCT re.member_id) AS registrants + FROM registration_events re + GROUP BY DATE_TRUNC('month', re.activity_at) +), +peak_month AS ( + SELECT + atrm.month_start, + atrm.registrants + FROM all_time_registration_months atrm + ORDER BY atrm.registrants DESC, atrm.month_start DESC + LIMIT 1 +), +all_time_summary AS ( + SELECT + (SELECT COUNT(DISTINCT re.member_id) FROM registration_events re) + AS total_unique_registrants, + (SELECT COUNT(DISTINCT se.member_id) FROM submission_events se) + AS total_unique_submitters +) +SELECT + TO_CHAR(m.month_start, 'YYYY-MM-01') AS month, + COALESCE(sr.registrants, 0) AS registrants, + COALESCE(ss.submitters, 0) AS submitters, + ats.total_unique_registrants, + ats.total_unique_submitters, + COALESCE( + ROUND( + LEAST( + 100, + ats.total_unique_submitters * 100.0 + / NULLIF(ats.total_unique_registrants, 0) + ), + 1 + ), + 0 + ) AS submission_rate, + TO_CHAR(pm.month_start, 'YYYY-MM-01') AS peak_month, + COALESCE(pm.registrants, 0) AS peak_month_registrants +FROM months m +LEFT JOIN selected_registrations sr + ON sr.month_start = m.month_start +LEFT JOIN selected_submissions ss + ON ss.month_start = m.month_start +CROSS JOIN all_time_summary ats +LEFT JOIN peak_month pm + ON TRUE +ORDER BY m.month_start; diff --git a/sql/reports/dashboard/members-paid.sql b/sql/reports/dashboard/members-paid.sql new file mode 100644 index 0000000..11246ff --- /dev/null +++ b/sql/reports/dashboard/members-paid.sql @@ -0,0 +1,122 @@ +-- Monthly unique paid members by canonical payment bucket and all-time summary. +-- +-- Parameters: +-- $1 timestamptz - inclusive reporting range start +-- $2 timestamptz - exclusive reporting range end +-- +-- A paid event is a PAYMENT winning whose current payment status is PAID. +-- date_paid is authoritative when present; created_at supports migrated paid rows +-- that do not have a paid timestamp. TOPGEAR_PAYMENT is intentionally excluded +-- because it is a separate canonical accrual bucket and is not one of the four +-- dashboard categories. +WITH bounds AS ( + SELECT + $1::timestamptz AT TIME ZONE 'UTC' AS start_at, + $2::timestamptz AT TIME ZONE 'UTC' AS end_at +), +months AS ( + SELECT GENERATE_SERIES( + DATE_TRUNC('month', b.start_at), + DATE_TRUNC('month', b.end_at - INTERVAL '1 microsecond'), + INTERVAL '1 month' + ) AS month_start + FROM bounds b +), +paid_events AS MATERIALIZED ( + SELECT + NULLIF(TRIM(w.winner_id), '') AS member_id, + COALESCE(p.date_paid, p.created_at) AS paid_at, + CASE + WHEN w.category::text = 'TAAS_PAYMENT' THEN 'taas' + WHEN w.category::text = 'ENGAGEMENT_PAYMENT' THEN 'engagement' + WHEN w.category::text IN ( + 'TASK_PAYMENT', + 'TASK_REVIEW_PAYMENT', + 'TASK_COPILOT_PAYMENT', + 'DEPLOYMENT_TASK_PAYMENT', + 'PROJECT_DEPLOYMENT_TASK_PAYMENT' + ) THEN 'task' + ELSE 'contest' + END AS payment_type + FROM finance.payment p + JOIN finance.winnings w + ON w.winning_id = p.winnings_id + WHERE p.payment_status = 'PAID' + AND w.type = 'PAYMENT' + AND COALESCE(p.date_paid, p.created_at) IS NOT NULL + AND NULLIF(TRIM(w.winner_id), '') IS NOT NULL + AND w.category::text IS DISTINCT FROM 'TOPGEAR_PAYMENT' +), +selected_months AS ( + SELECT + DATE_TRUNC('month', pe.paid_at) AS month_start, + COUNT(DISTINCT pe.member_id) FILTER ( + WHERE pe.payment_type = 'taas' + ) AS taas, + COUNT(DISTINCT pe.member_id) FILTER ( + WHERE pe.payment_type = 'task' + ) AS task, + COUNT(DISTINCT pe.member_id) FILTER ( + WHERE pe.payment_type = 'contest' + ) AS contest, + COUNT(DISTINCT pe.member_id) FILTER ( + WHERE pe.payment_type = 'engagement' + ) AS engagement + FROM paid_events pe + CROSS JOIN bounds b + WHERE pe.paid_at >= b.start_at + AND pe.paid_at < b.end_at + GROUP BY DATE_TRUNC('month', pe.paid_at) +), +all_time_months AS ( + SELECT + DATE_TRUNC('month', pe.paid_at) AS month_start, + COUNT(DISTINCT pe.member_id) AS unique_members + FROM paid_events pe + GROUP BY DATE_TRUNC('month', pe.paid_at) +), +peak_month AS ( + SELECT + atm.month_start, + atm.unique_members + FROM all_time_months atm + ORDER BY atm.unique_members DESC, atm.month_start DESC + LIMIT 1 +), +all_time_summary AS ( + SELECT + COUNT(DISTINCT pe.member_id) AS total_unique_members, + COUNT(DISTINCT pe.member_id) FILTER ( + WHERE pe.payment_type = 'taas' + ) AS taas_unique_members, + COUNT(DISTINCT pe.member_id) FILTER ( + WHERE pe.payment_type = 'task' + ) AS task_unique_members, + COUNT(DISTINCT pe.member_id) FILTER ( + WHERE pe.payment_type = 'contest' + ) AS contest_unique_members, + COUNT(DISTINCT pe.member_id) FILTER ( + WHERE pe.payment_type = 'engagement' + ) AS engagement_unique_members + FROM paid_events pe +) +SELECT + TO_CHAR(m.month_start, 'YYYY-MM-01') AS month, + COALESCE(sm.taas, 0) AS taas, + COALESCE(sm.task, 0) AS task, + COALESCE(sm.contest, 0) AS contest, + COALESCE(sm.engagement, 0) AS engagement, + ats.total_unique_members, + ats.taas_unique_members, + ats.task_unique_members, + ats.contest_unique_members, + ats.engagement_unique_members, + TO_CHAR(pm.month_start, 'YYYY-MM-01') AS peak_month, + COALESCE(pm.unique_members, 0) AS peak_month_unique_members +FROM months m +LEFT JOIN selected_months sm + ON sm.month_start = m.month_start +CROSS JOIN all_time_summary ats +LEFT JOIN peak_month pm + ON TRUE +ORDER BY m.month_start; diff --git a/sql/reports/dashboard/new-signups.sql b/sql/reports/dashboard/new-signups.sql new file mode 100644 index 0000000..a1aa24a --- /dev/null +++ b/sql/reports/dashboard/new-signups.sql @@ -0,0 +1,79 @@ +-- Monthly member signups and all-time signup summary. +-- +-- Parameters: +-- $1 timestamptz - inclusive reporting range start +-- $2 timestamptz - exclusive reporting range end +-- +-- identity.user timestamps are stored without a timezone and represent UTC. +WITH bounds AS ( + SELECT + $1::timestamptz AT TIME ZONE 'UTC' AS start_at, + $2::timestamptz AT TIME ZONE 'UTC' AS end_at +), +months AS ( + SELECT GENERATE_SERIES( + DATE_TRUNC('month', b.start_at), + DATE_TRUNC('month', b.end_at - INTERVAL '1 microsecond'), + INTERVAL '1 month' + ) AS month_start + FROM bounds b +), +selected_months AS ( + SELECT + DATE_TRUNC('month', u.create_date) AS month_start, + COUNT(*) FILTER (WHERE u.status = 'A') AS activated, + COUNT(*) FILTER (WHERE u.status <> 'A') AS not_activated + FROM identity."user" u + CROSS JOIN bounds b + WHERE u.create_date >= b.start_at + AND u.create_date < b.end_at + GROUP BY DATE_TRUNC('month', u.create_date) +), +all_time_months AS ( + SELECT + DATE_TRUNC('month', u.create_date) AS month_start, + COUNT(*) AS signup_count + FROM identity."user" u + WHERE u.create_date IS NOT NULL + GROUP BY DATE_TRUNC('month', u.create_date) +), +peak_month AS ( + SELECT + atm.month_start, + atm.signup_count + FROM all_time_months atm + ORDER BY atm.signup_count DESC, atm.month_start DESC + LIMIT 1 +), +all_time_summary AS ( + SELECT + COUNT(*) AS total_signups, + COUNT(*) FILTER (WHERE u.status = 'A') AS activated_members, + COUNT(*) FILTER (WHERE u.status <> 'A') AS not_activated_members, + COALESCE( + ROUND( + COUNT(*) FILTER (WHERE u.status = 'A') * 100.0 + / NULLIF(COUNT(*), 0), + 1 + ), + 0 + ) AS activation_rate + FROM identity."user" u +) +SELECT + TO_CHAR(m.month_start, 'YYYY-MM-01') AS month, + COALESCE(sm.activated, 0) AS activated, + COALESCE(sm.not_activated, 0) AS not_activated, + ats.total_signups, + ats.activated_members, + ats.not_activated_members, + ats.activation_rate, + TO_CHAR(pm.month_start, 'YYYY-MM-01') AS peak_month, + COALESCE(pm.signup_count, 0) AS peak_month_signups +FROM months m +LEFT JOIN selected_months sm + ON sm.month_start = m.month_start +CROSS JOIN all_time_summary ats +LEFT JOIN peak_month pm + ON TRUE +ORDER BY m.month_start; diff --git a/src/app.module.ts b/src/app.module.ts index fa7f2f9..1601f5e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,6 +13,7 @@ import { IdentityReportsModule } from "./reports/identity/identity-reports.modul import { ReportsModule } from "./reports/reports.module"; import { MemberSearchModule } from "./reports/member/member-search.module"; import { PaymentReportsModule } from "./reports/payment/payment-reports.module"; +import { DashboardReportsModule } from "./reports/dashboard/dashboard-reports.module"; @Module({ imports: [ @@ -27,6 +28,7 @@ import { PaymentReportsModule } from "./reports/payment/payment-reports.module"; ReportsModule, MemberSearchModule, PaymentReportsModule, + DashboardReportsModule, HealthModule, ], }) diff --git a/src/reports/dashboard/dashboard-reports.controller.spec.ts b/src/reports/dashboard/dashboard-reports.controller.spec.ts new file mode 100644 index 0000000..9cd0f7c --- /dev/null +++ b/src/reports/dashboard/dashboard-reports.controller.spec.ts @@ -0,0 +1,125 @@ +import { CsvSerializer } from "../../common/csv/csv-serializer"; +import { + DashboardExportRowDto, + DashboardSlug, + NewSignupsDashboardDto, +} from "./dashboard-reports.dto"; +import { DashboardReportsController } from "./dashboard-reports.controller"; +import { DashboardReportsService } from "./dashboard-reports.service"; + +const query = { + startDate: "2026-02-01T00:00:00.000Z", + endDate: "2026-04-01T00:00:00.000Z", +}; + +const newSignupsDashboard: NewSignupsDashboardDto = { + dashboard: DashboardSlug.NewSignups, + ...query, + months: [{ month: "2026-02-01", activated: 10, notActivated: 2 }], + summary: { + totalSignups: 12, + activatedMembers: 10, + notActivatedMembers: 2, + activationRate: 83.3, + peakMonth: "2026-02-01", + peakMonthSignups: 12, + }, +}; + +describe("DashboardReportsController", () => { + let controller: DashboardReportsController; + let service: { + getAllDashboards: jest.Mock; + getDashboard: jest.Mock; + exportAllDashboards: jest.Mock; + exportDashboard: jest.Mock; + }; + + beforeEach(() => { + service = { + getAllDashboards: jest.fn(), + getDashboard: jest.fn(), + exportAllDashboards: jest.fn(), + exportDashboard: jest.fn(), + }; + controller = new DashboardReportsController( + service as unknown as DashboardReportsService, + new CsvSerializer(), + ); + }); + + it("delegates the aggregate landing response", async () => { + const response = { + newSignups: newSignupsDashboard, + membersPaid: {}, + challengeParticipation: {}, + }; + service.getAllDashboards.mockResolvedValue(response); + + await expect(controller.getAllDashboards(query)).resolves.toBe(response); + expect(service.getAllDashboards).toHaveBeenCalledWith(query); + }); + + it("delegates a selected detail dashboard", async () => { + service.getDashboard.mockResolvedValue(newSignupsDashboard); + + await expect( + controller.getDashboard(DashboardSlug.NewSignups, query), + ).resolves.toBe(newSignupsDashboard); + expect(service.getDashboard).toHaveBeenCalledWith( + DashboardSlug.NewSignups, + query, + ); + }); + + it("serializes all-dashboard export rows as one flat CSV", async () => { + const rows: DashboardExportRowDto[] = [ + { + dashboard: DashboardSlug.NewSignups, + month: "2026-02-01", + activated: 10, + notActivated: 2, + }, + { + dashboard: DashboardSlug.MembersPaid, + month: "2026-02-01", + taas: 3, + task: 4, + contest: 5, + engagement: 2, + }, + ]; + service.exportAllDashboards.mockResolvedValue(rows); + + await expect(controller.exportAllDashboards(query)).resolves.toBe( + [ + "dashboard,month,activated,notActivated,taas,task,contest,engagement", + "new-signups,2026-02-01,10,2,,,,", + "members-paid,2026-02-01,,,3,4,5,2", + ].join("\n"), + ); + expect(service.exportAllDashboards).toHaveBeenCalledWith(query); + }); + + it("serializes one selected dashboard as CSV", async () => { + service.exportDashboard.mockResolvedValue([ + { + dashboard: DashboardSlug.ChallengeParticipation, + month: "2026-02-01", + registrants: 12, + submitters: 9, + }, + ]); + + await expect( + controller.exportDashboard(DashboardSlug.ChallengeParticipation, query), + ).resolves.toBe( + "dashboard,month,registrants,submitters\n" + + "challenge-participation,2026-02-01,12,9", + ); + expect(service.exportDashboard).toHaveBeenCalledWith( + DashboardSlug.ChallengeParticipation, + query, + ); + }); +}); diff --git a/src/reports/dashboard/dashboard-reports.controller.ts b/src/reports/dashboard/dashboard-reports.controller.ts new file mode 100644 index 0000000..db042a2 --- /dev/null +++ b/src/reports/dashboard/dashboard-reports.controller.ts @@ -0,0 +1,170 @@ +import { + Controller, + Get, + Header, + Param, + ParseEnumPipe, + Query, + UseGuards, +} from "@nestjs/common"; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiExtraModels, + ApiForbiddenResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiProduces, + ApiTags, + ApiUnauthorizedResponse, + getSchemaPath, +} from "@nestjs/swagger"; +import { CsvSerializer } from "../../common/csv/csv-serializer"; +import { + AllDashboardsDto, + ChallengeParticipationDashboardDto, + DashboardQueryDto, + DashboardResponse, + DashboardSlug, + MembersPaidDashboardDto, + NewSignupsDashboardDto, +} from "./dashboard-reports.dto"; +import { DashboardReportsService } from "./dashboard-reports.service"; +import { DashboardReportsGuard } from "./guards/dashboard-reports.guard"; + +/** + * Exposes landing, detail, and CSV endpoints for Reports Portal dashboards. + */ +@ApiTags("Dashboards") +@ApiBearerAuth() +@ApiExtraModels( + NewSignupsDashboardDto, + MembersPaidDashboardDto, + ChallengeParticipationDashboardDto, +) +@ApiUnauthorizedResponse({ description: "Unauthenticated." }) +@ApiForbiddenResponse({ + description: + "Requires an Administrator or Talent Manager role for a human token, or the reports:all scope for a machine token.", +}) +@UseGuards(DashboardReportsGuard) +@Controller("/dashboard") +export class DashboardReportsController { + /** + * Creates a dashboard controller. + * + * @param reports Dashboard query and mapping service. + * @param csvSerializer Serializer used by explicit download endpoints. + */ + constructor( + private readonly reports: DashboardReportsService, + private readonly csvSerializer: CsvSerializer, + ) {} + + /** + * Retrieves all dashboards for the landing page. + * + * @param query Optional half-open reporting range. + * @returns All three complete dashboard responses. + * @throws BadRequestException when the range is invalid. + */ + @Get() + @ApiOperation({ + summary: "Get all Reports Portal dashboards", + description: + "Returns monthly data for new signups, unique paid members, and challenge participation. The default range is the latest six UTC calendar months; summary metrics are all-time.", + }) + @ApiOkResponse({ type: AllDashboardsDto }) + @ApiBadRequestResponse({ description: "Invalid reporting date range." }) + getAllDashboards( + @Query() query: DashboardQueryDto, + ): Promise { + return this.reports.getAllDashboards(query); + } + + /** + * Downloads all dashboard month data as one flat CSV. + * + * @param query Optional half-open reporting range. + * @returns CSV text containing a dashboard discriminator on every row. + * @throws BadRequestException when the range is invalid. + */ + @Get("/export") + @ApiOperation({ summary: "Export all dashboard month data as CSV" }) + @ApiProduces("text/csv") + @ApiOkResponse({ + description: "Flat CSV rows for all three dashboards.", + type: String, + }) + @ApiBadRequestResponse({ description: "Invalid reporting date range." }) + @Header("Content-Type", "text/csv; charset=utf-8") + @Header("Content-Disposition", 'attachment; filename="dashboards.csv"') + async exportAllDashboards( + @Query() query: DashboardQueryDto, + ): Promise { + const rows = await this.reports.exportAllDashboards(query); + return this.csvSerializer.serialize(rows); + } + + /** + * Downloads one dashboard's month data as a flat CSV. + * + * @param dashboard Public dashboard slug. + * @param query Optional half-open reporting range. + * @returns CSV text containing the selected dashboard's month rows. + * @throws BadRequestException when the dashboard or range is invalid. + */ + @Get("/:dashboard/export") + @ApiOperation({ summary: "Export one dashboard's month data as CSV" }) + @ApiParam({ name: "dashboard", enum: DashboardSlug }) + @ApiProduces("text/csv") + @ApiOkResponse({ + description: "Flat CSV rows for the selected dashboard.", + type: String, + }) + @ApiBadRequestResponse({ + description: "Invalid dashboard slug or reporting date range.", + }) + @Header("Content-Type", "text/csv; charset=utf-8") + @Header("Content-Disposition", 'attachment; filename="dashboard.csv"') + async exportDashboard( + @Param("dashboard", new ParseEnumPipe(DashboardSlug)) + dashboard: DashboardSlug, + @Query() query: DashboardQueryDto, + ): Promise { + const rows = await this.reports.exportDashboard(dashboard, query); + return this.csvSerializer.serialize(rows); + } + + /** + * Retrieves one full dashboard for its detail view. + * + * @param dashboard Public dashboard slug. + * @param query Optional half-open reporting range. + * @returns Selected complete dashboard response. + * @throws BadRequestException when the dashboard or range is invalid. + */ + @Get("/:dashboard") + @ApiOperation({ summary: "Get one Reports Portal dashboard" }) + @ApiParam({ name: "dashboard", enum: DashboardSlug }) + @ApiOkResponse({ + schema: { + oneOf: [ + { $ref: getSchemaPath(NewSignupsDashboardDto) }, + { $ref: getSchemaPath(MembersPaidDashboardDto) }, + { $ref: getSchemaPath(ChallengeParticipationDashboardDto) }, + ], + }, + }) + @ApiBadRequestResponse({ + description: "Invalid dashboard slug or reporting date range.", + }) + getDashboard( + @Param("dashboard", new ParseEnumPipe(DashboardSlug)) + dashboard: DashboardSlug, + @Query() query: DashboardQueryDto, + ): Promise { + return this.reports.getDashboard(dashboard, query); + } +} diff --git a/src/reports/dashboard/dashboard-reports.dto.ts b/src/reports/dashboard/dashboard-reports.dto.ts new file mode 100644 index 0000000..113706c --- /dev/null +++ b/src/reports/dashboard/dashboard-reports.dto.ts @@ -0,0 +1,302 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsDateString, IsOptional } from "class-validator"; + +/** + * Public identifiers used to select a dashboard report. + */ +export enum DashboardSlug { + NewSignups = "new-signups", + MembersPaid = "members-paid", + ChallengeParticipation = "challenge-participation", +} + +/** + * Optional half-open UTC date range applied to dashboard month data. + * + * When both values are omitted, the API returns the latest six calendar + * months, including the current month. + */ +export class DashboardQueryDto { + @ApiPropertyOptional({ + description: + "Inclusive ISO-8601 reporting range start. Defaults to the first instant of the oldest month in the latest six-calendar-month window.", + example: "2026-02-01T00:00:00.000Z", + }) + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional({ + description: + "Exclusive ISO-8601 reporting range end. Defaults to the first instant of the month following the current month.", + example: "2026-08-01T00:00:00.000Z", + }) + @IsOptional() + @IsDateString() + endDate?: string; +} + +/** + * One month of member signup counts split by current activation state. + */ +export class NewSignupsMonthDto { + @ApiProperty({ example: "2026-02-01" }) + month: string; + + @ApiProperty({ example: 13247 }) + activated: number; + + @ApiProperty({ example: 4967 }) + notActivated: number; +} + +/** + * All-time member signup metrics shown beside the signup chart. + */ +export class NewSignupsSummaryDto { + @ApiProperty({ example: 18214 }) + totalSignups: number; + + @ApiProperty({ example: 13247 }) + activatedMembers: number; + + @ApiProperty({ example: 4967 }) + notActivatedMembers: number; + + @ApiProperty({ + description: "All-time activated-member percentage from 0 through 100.", + example: 72.7, + }) + activationRate: number; + + @ApiProperty({ + nullable: true, + example: "2024-04-01", + }) + peakMonth: string | null; + + @ApiProperty({ example: 4423 }) + peakMonthSignups: number; +} + +/** + * Monthly signup dashboard response used by landing and detail views. + */ +export class NewSignupsDashboardDto { + @ApiProperty({ + enum: [DashboardSlug.NewSignups], + example: DashboardSlug.NewSignups, + }) + dashboard: DashboardSlug.NewSignups; + + @ApiProperty({ example: "2026-02-01T00:00:00.000Z" }) + startDate: string; + + @ApiProperty({ + description: "Exclusive reporting range end.", + example: "2026-08-01T00:00:00.000Z", + }) + endDate: string; + + @ApiProperty({ type: [NewSignupsMonthDto] }) + months: NewSignupsMonthDto[]; + + @ApiProperty({ type: NewSignupsSummaryDto }) + summary: NewSignupsSummaryDto; +} + +/** + * One month of unique paid-member counts split by canonical payment type. + */ +export class MembersPaidMonthDto { + @ApiProperty({ example: "2026-02-01" }) + month: string; + + @ApiProperty({ example: 438 }) + taas: number; + + @ApiProperty({ example: 812 }) + task: number; + + @ApiProperty({ example: 1294 }) + contest: number; + + @ApiProperty({ example: 376 }) + engagement: number; +} + +/** + * All-time unique paid-member metrics shown beside the payments chart. + */ +export class MembersPaidSummaryDto { + @ApiProperty({ example: 19234 }) + totalUniqueMembers: number; + + @ApiProperty({ example: 2481 }) + taasUniqueMembers: number; + + @ApiProperty({ example: 6792 }) + taskUniqueMembers: number; + + @ApiProperty({ example: 14226 }) + contestUniqueMembers: number; + + @ApiProperty({ example: 3154 }) + engagementUniqueMembers: number; + + @ApiProperty({ nullable: true, example: "2025-11-01" }) + peakMonth: string | null; + + @ApiProperty({ example: 3812 }) + peakMonthUniqueMembers: number; +} + +/** + * Monthly unique-paid-members dashboard response. + */ +export class MembersPaidDashboardDto { + @ApiProperty({ + enum: [DashboardSlug.MembersPaid], + example: DashboardSlug.MembersPaid, + }) + dashboard: DashboardSlug.MembersPaid; + + @ApiProperty({ example: "2026-02-01T00:00:00.000Z" }) + startDate: string; + + @ApiProperty({ + description: "Exclusive reporting range end.", + example: "2026-08-01T00:00:00.000Z", + }) + endDate: string; + + @ApiProperty({ type: [MembersPaidMonthDto] }) + months: MembersPaidMonthDto[]; + + @ApiProperty({ type: MembersPaidSummaryDto }) + summary: MembersPaidSummaryDto; +} + +/** + * One month of unique challenge registration and submission activity. + */ +export class ChallengeParticipationMonthDto { + @ApiProperty({ example: "2026-02-01" }) + month: string; + + @ApiProperty({ example: 13292 }) + registrants: number; + + @ApiProperty({ example: 10148 }) + submitters: number; +} + +/** + * All-time challenge participation metrics shown beside the chart. + */ +export class ChallengeParticipationSummaryDto { + @ApiProperty({ example: 214568 }) + totalUniqueRegistrants: number; + + @ApiProperty({ example: 146329 }) + totalUniqueSubmitters: number; + + @ApiProperty({ + description: + "All-time unique submitters divided by unique registrants, expressed as a percentage from 0 through 100.", + example: 68.2, + }) + submissionRate: number; + + @ApiProperty({ nullable: true, example: "2025-10-01" }) + peakMonth: string | null; + + @ApiProperty({ example: 22431 }) + peakMonthRegistrants: number; +} + +/** + * Monthly challenge-participation dashboard response. + */ +export class ChallengeParticipationDashboardDto { + @ApiProperty({ + enum: [DashboardSlug.ChallengeParticipation], + example: DashboardSlug.ChallengeParticipation, + }) + dashboard: DashboardSlug.ChallengeParticipation; + + @ApiProperty({ example: "2026-02-01T00:00:00.000Z" }) + startDate: string; + + @ApiProperty({ + description: "Exclusive reporting range end.", + example: "2026-08-01T00:00:00.000Z", + }) + endDate: string; + + @ApiProperty({ type: [ChallengeParticipationMonthDto] }) + months: ChallengeParticipationMonthDto[]; + + @ApiProperty({ type: ChallengeParticipationSummaryDto }) + summary: ChallengeParticipationSummaryDto; +} + +/** + * Aggregate landing-page response containing all three full dashboards. + */ +export class AllDashboardsDto { + @ApiProperty({ type: NewSignupsDashboardDto }) + newSignups: NewSignupsDashboardDto; + + @ApiProperty({ type: MembersPaidDashboardDto }) + membersPaid: MembersPaidDashboardDto; + + @ApiProperty({ type: ChallengeParticipationDashboardDto }) + challengeParticipation: ChallengeParticipationDashboardDto; +} + +/** + * Flat monthly row returned by all-dashboard CSV exports. + * + * Metric columns that do not apply to the row's dashboard are omitted before + * CSV serialization. + */ +export class DashboardExportRowDto { + @ApiProperty({ enum: DashboardSlug }) + dashboard: DashboardSlug; + + @ApiProperty({ example: "2026-02-01" }) + month: string; + + @ApiPropertyOptional() + activated?: number; + + @ApiPropertyOptional() + notActivated?: number; + + @ApiPropertyOptional() + taas?: number; + + @ApiPropertyOptional() + task?: number; + + @ApiPropertyOptional() + contest?: number; + + @ApiPropertyOptional() + engagement?: number; + + @ApiPropertyOptional() + registrants?: number; + + @ApiPropertyOptional() + submitters?: number; +} + +/** + * Full response returned by a dashboard detail endpoint. + */ +export type DashboardResponse = + | NewSignupsDashboardDto + | MembersPaidDashboardDto + | ChallengeParticipationDashboardDto; diff --git a/src/reports/dashboard/dashboard-reports.module.spec.ts b/src/reports/dashboard/dashboard-reports.module.spec.ts new file mode 100644 index 0000000..7578ac9 --- /dev/null +++ b/src/reports/dashboard/dashboard-reports.module.spec.ts @@ -0,0 +1,63 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { CsvSerializer } from "../../common/csv/csv-serializer"; +import { SqlLoaderService } from "../../common/sql-loader.service"; +import { DbModule } from "../../db/db.module"; +import { DbService } from "../../db/db.service"; +import { DashboardReportsController } from "./dashboard-reports.controller"; +import { DashboardSlug } from "./dashboard-reports.dto"; +import { DashboardReportsModule } from "./dashboard-reports.module"; +import { DashboardReportsService } from "./dashboard-reports.service"; +import { DashboardReportsGuard } from "./guards/dashboard-reports.guard"; + +describe("DashboardReportsModule", () => { + let moduleRef: TestingModule; + + const db = { + query: jest.fn(), + }; + const sql = { + load: jest.fn(), + }; + + beforeEach(async () => { + db.query.mockReset().mockResolvedValue([]); + sql.load.mockReset().mockReturnValue("SELECT 1"); + + moduleRef = await Test.createTestingModule({ + imports: [DbModule, DashboardReportsModule], + }) + .overrideProvider(DbService) + .useValue(db) + .overrideProvider(SqlLoaderService) + .useValue(sql) + .compile(); + }); + + it("wires the controller, service, guard, and CSV serializer", () => { + expect(moduleRef.get(DashboardReportsController)).toBeInstanceOf( + DashboardReportsController, + ); + expect(moduleRef.get(DashboardReportsService)).toBeInstanceOf( + DashboardReportsService, + ); + expect(moduleRef.get(DashboardReportsGuard)).toBeInstanceOf( + DashboardReportsGuard, + ); + expect(moduleRef.get(CsvSerializer)).toBeInstanceOf(CsvSerializer); + }); + + it("injects the shared database and SQL loader into the report service", async () => { + const service = moduleRef.get(DashboardReportsService); + + await service.getDashboard(DashboardSlug.NewSignups, { + startDate: "2026-01-01", + endDate: "2026-02-01", + }); + + expect(sql.load).toHaveBeenCalledWith("reports/dashboard/new-signups.sql"); + expect(db.query).toHaveBeenCalledWith("SELECT 1", [ + "2026-01-01T00:00:00.000Z", + "2026-02-01T00:00:00.000Z", + ]); + }); +}); diff --git a/src/reports/dashboard/dashboard-reports.module.ts b/src/reports/dashboard/dashboard-reports.module.ts new file mode 100644 index 0000000..b988f5a --- /dev/null +++ b/src/reports/dashboard/dashboard-reports.module.ts @@ -0,0 +1,21 @@ +import { Module } from "@nestjs/common"; +import { CsvSerializer } from "../../common/csv/csv-serializer"; +import { SqlLoaderService } from "../../common/sql-loader.service"; +import { DashboardReportsController } from "./dashboard-reports.controller"; +import { DashboardReportsService } from "./dashboard-reports.service"; +import { DashboardReportsGuard } from "./guards/dashboard-reports.guard"; + +/** + * Bundles dashboard report queries, authorization, JSON responses, and CSV + * exports for the Reports Portal. + */ +@Module({ + controllers: [DashboardReportsController], + providers: [ + DashboardReportsService, + DashboardReportsGuard, + SqlLoaderService, + CsvSerializer, + ], +}) +export class DashboardReportsModule {} diff --git a/src/reports/dashboard/dashboard-reports.service.spec.ts b/src/reports/dashboard/dashboard-reports.service.spec.ts new file mode 100644 index 0000000..7c804bd --- /dev/null +++ b/src/reports/dashboard/dashboard-reports.service.spec.ts @@ -0,0 +1,351 @@ +import { BadRequestException } from "@nestjs/common"; +import { SqlLoaderService } from "../../common/sql-loader.service"; +import { DbService } from "../../db/db.service"; +import { + ChallengeParticipationDashboardDto, + DashboardSlug, + MembersPaidDashboardDto, + NewSignupsDashboardDto, +} from "./dashboard-reports.dto"; +import { + DashboardReportsService, + resolveDashboardDateRange, +} from "./dashboard-reports.service"; + +const rangeQuery = { + startDate: "2026-02-01T00:00:00.000Z", + endDate: "2026-04-01T00:00:00.000Z", +}; + +const newSignupsRows = [ + { + month: "2026-02-01", + activated: "10", + not_activated: "2", + total_signups: "100", + activated_members: "75", + not_activated_members: "25", + activation_rate: "75.0", + peak_month: "2025-01-01", + peak_month_signups: "20", + }, + { + month: "2026-03-01", + activated: "8", + not_activated: "3", + total_signups: "100", + activated_members: "75", + not_activated_members: "25", + activation_rate: "75.0", + peak_month: "2025-01-01", + peak_month_signups: "20", + }, +]; + +const membersPaidRows = [ + { + month: "2026-02-01", + taas: "3", + task: "4", + contest: "5", + engagement: "2", + total_unique_members: "40", + taas_unique_members: "8", + task_unique_members: "13", + contest_unique_members: "24", + engagement_unique_members: "7", + peak_month: "2025-11-01", + peak_month_unique_members: "15", + }, +]; + +const challengeParticipationRows = [ + { + month: "2026-02-01", + registrants: "12", + submitters: "9", + total_unique_registrants: "120", + total_unique_submitters: "90", + submission_rate: "75.0", + peak_month: "2025-10-01", + peak_month_registrants: "18", + }, +]; + +describe("resolveDashboardDateRange", () => { + it("defaults to the latest six UTC calendar months", () => { + expect( + resolveDashboardDateRange({}, new Date("2026-07-23T16:45:00.000Z")), + ).toEqual({ + startDate: "2026-02-01T00:00:00.000Z", + endDate: "2026-08-01T00:00:00.000Z", + }); + }); + + it("normalizes explicit date-only bounds to ISO timestamps", () => { + expect( + resolveDashboardDateRange({ + startDate: "2026-01-01", + endDate: "2026-05-01", + }), + ).toEqual({ + startDate: "2026-01-01T00:00:00.000Z", + endDate: "2026-05-01T00:00:00.000Z", + }); + }); + + it("derives a missing bound by six calendar months with day clamping", () => { + expect( + resolveDashboardDateRange({ + startDate: "2026-08-31T12:30:00.000Z", + }), + ).toEqual({ + startDate: "2026-08-31T12:30:00.000Z", + endDate: "2027-02-28T12:30:00.000Z", + }); + + expect( + resolveDashboardDateRange({ + endDate: "2026-08-31T12:30:00.000Z", + }), + ).toEqual({ + startDate: "2026-02-28T12:30:00.000Z", + endDate: "2026-08-31T12:30:00.000Z", + }); + }); + + it("rejects invalid and non-increasing ranges", () => { + expect(() => + resolveDashboardDateRange({ startDate: "not-a-date" }), + ).toThrow(BadRequestException); + expect(() => + resolveDashboardDateRange({ + startDate: "2026-04-01", + endDate: "2026-04-01", + }), + ).toThrow("endDate must be later than startDate."); + }); +}); + +describe("DashboardReportsService", () => { + let service: DashboardReportsService; + let db: { query: jest.Mock }; + let sql: { load: jest.Mock }; + + beforeEach(() => { + sql = { + load: jest.fn((path: string) => path), + }; + db = { + query: jest.fn((query: string) => { + switch (query) { + case "reports/dashboard/new-signups.sql": + return Promise.resolve(newSignupsRows); + case "reports/dashboard/members-paid.sql": + return Promise.resolve(membersPaidRows); + case "reports/dashboard/challenge-participation.sql": + return Promise.resolve(challengeParticipationRows); + default: + return Promise.resolve([]); + } + }), + }; + service = new DashboardReportsService( + db as unknown as DbService, + sql as unknown as SqlLoaderService, + ); + }); + + it("loads and maps all dashboard responses with one shared range", async () => { + await expect(service.getAllDashboards(rangeQuery)).resolves.toEqual({ + newSignups: { + dashboard: DashboardSlug.NewSignups, + ...rangeQuery, + months: [ + { + month: "2026-02-01", + activated: 10, + notActivated: 2, + }, + { + month: "2026-03-01", + activated: 8, + notActivated: 3, + }, + ], + summary: { + totalSignups: 100, + activatedMembers: 75, + notActivatedMembers: 25, + activationRate: 75, + peakMonth: "2025-01-01", + peakMonthSignups: 20, + }, + }, + membersPaid: { + dashboard: DashboardSlug.MembersPaid, + ...rangeQuery, + months: [ + { + month: "2026-02-01", + taas: 3, + task: 4, + contest: 5, + engagement: 2, + }, + ], + summary: { + totalUniqueMembers: 40, + taasUniqueMembers: 8, + taskUniqueMembers: 13, + contestUniqueMembers: 24, + engagementUniqueMembers: 7, + peakMonth: "2025-11-01", + peakMonthUniqueMembers: 15, + }, + }, + challengeParticipation: { + dashboard: DashboardSlug.ChallengeParticipation, + ...rangeQuery, + months: [ + { + month: "2026-02-01", + registrants: 12, + submitters: 9, + }, + ], + summary: { + totalUniqueRegistrants: 120, + totalUniqueSubmitters: 90, + submissionRate: 75, + peakMonth: "2025-10-01", + peakMonthRegistrants: 18, + }, + }, + }); + + expect(sql.load.mock.calls).toEqual([ + ["reports/dashboard/new-signups.sql"], + ["reports/dashboard/members-paid.sql"], + ["reports/dashboard/challenge-participation.sql"], + ]); + expect(db.query).toHaveBeenCalledTimes(3); + expect(db.query).toHaveBeenCalledWith("reports/dashboard/new-signups.sql", [ + rangeQuery.startDate, + rangeQuery.endDate, + ]); + }); + + it("loads only the requested detail dashboard", async () => { + const result = await service.getDashboard( + DashboardSlug.MembersPaid, + rangeQuery, + ); + + expect(result.dashboard).toBe(DashboardSlug.MembersPaid); + expect(sql.load).toHaveBeenCalledTimes(1); + expect(sql.load).toHaveBeenCalledWith("reports/dashboard/members-paid.sql"); + }); + + it("rejects an unsupported dashboard discriminator", async () => { + await expect( + service.getDashboard("unknown" as DashboardSlug, rangeQuery), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("returns a safe empty summary when a query returns no rows", async () => { + db.query.mockResolvedValueOnce([]); + + await expect( + service.getDashboard(DashboardSlug.NewSignups, rangeQuery), + ).resolves.toEqual({ + dashboard: DashboardSlug.NewSignups, + ...rangeQuery, + months: [], + summary: { + totalSignups: 0, + activatedMembers: 0, + notActivatedMembers: 0, + activationRate: 0, + peakMonth: null, + peakMonthSignups: 0, + }, + }); + }); + + it("flattens all dashboard month data for CSV serialization", async () => { + const dashboards = { + newSignups: { + dashboard: DashboardSlug.NewSignups, + ...rangeQuery, + months: [{ month: "2026-02-01", activated: 10, notActivated: 2 }], + summary: { + totalSignups: 12, + activatedMembers: 10, + notActivatedMembers: 2, + activationRate: 83.3, + peakMonth: "2026-02-01", + peakMonthSignups: 12, + }, + } satisfies NewSignupsDashboardDto, + membersPaid: { + dashboard: DashboardSlug.MembersPaid, + ...rangeQuery, + months: [ + { + month: "2026-02-01", + taas: 3, + task: 4, + contest: 5, + engagement: 2, + }, + ], + summary: { + totalUniqueMembers: 10, + taasUniqueMembers: 3, + taskUniqueMembers: 4, + contestUniqueMembers: 5, + engagementUniqueMembers: 2, + peakMonth: "2026-02-01", + peakMonthUniqueMembers: 10, + }, + } satisfies MembersPaidDashboardDto, + challengeParticipation: { + dashboard: DashboardSlug.ChallengeParticipation, + ...rangeQuery, + months: [{ month: "2026-02-01", registrants: 12, submitters: 9 }], + summary: { + totalUniqueRegistrants: 12, + totalUniqueSubmitters: 9, + submissionRate: 75, + peakMonth: "2026-02-01", + peakMonthRegistrants: 12, + }, + } satisfies ChallengeParticipationDashboardDto, + }; + jest.spyOn(service, "getAllDashboards").mockResolvedValue(dashboards); + + await expect(service.exportAllDashboards(rangeQuery)).resolves.toEqual([ + { + dashboard: DashboardSlug.NewSignups, + month: "2026-02-01", + activated: 10, + notActivated: 2, + }, + { + dashboard: DashboardSlug.MembersPaid, + month: "2026-02-01", + taas: 3, + task: 4, + contest: 5, + engagement: 2, + }, + { + dashboard: DashboardSlug.ChallengeParticipation, + month: "2026-02-01", + registrants: 12, + submitters: 9, + }, + ]); + }); +}); diff --git a/src/reports/dashboard/dashboard-reports.service.ts b/src/reports/dashboard/dashboard-reports.service.ts new file mode 100644 index 0000000..f26535a --- /dev/null +++ b/src/reports/dashboard/dashboard-reports.service.ts @@ -0,0 +1,413 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import { SqlLoaderService } from "../../common/sql-loader.service"; +import { DbService } from "../../db/db.service"; +import { + AllDashboardsDto, + ChallengeParticipationDashboardDto, + DashboardExportRowDto, + DashboardQueryDto, + DashboardResponse, + DashboardSlug, + MembersPaidDashboardDto, + NewSignupsDashboardDto, +} from "./dashboard-reports.dto"; + +type QueryValue = Date | string | number | null; + +type NewSignupsRow = { + month: string; + activated: QueryValue; + not_activated: QueryValue; + total_signups: QueryValue; + activated_members: QueryValue; + not_activated_members: QueryValue; + activation_rate: QueryValue; + peak_month: string | null; + peak_month_signups: QueryValue; +}; + +type MembersPaidRow = { + month: string; + taas: QueryValue; + task: QueryValue; + contest: QueryValue; + engagement: QueryValue; + total_unique_members: QueryValue; + taas_unique_members: QueryValue; + task_unique_members: QueryValue; + contest_unique_members: QueryValue; + engagement_unique_members: QueryValue; + peak_month: string | null; + peak_month_unique_members: QueryValue; +}; + +type ChallengeParticipationRow = { + month: string; + registrants: QueryValue; + submitters: QueryValue; + total_unique_registrants: QueryValue; + total_unique_submitters: QueryValue; + submission_rate: QueryValue; + peak_month: string | null; + peak_month_registrants: QueryValue; +}; + +/** + * Resolved half-open date range used by all dashboard SQL queries. + */ +export type DashboardDateRange = { + startDate: string; + endDate: string; +}; + +/** + * Parses an optional ISO date for dashboard range resolution. + * + * @param value Optional ISO-8601 value supplied by the request. + * @param fieldName Query field name used in validation errors. + * @returns Parsed date, or `undefined` when no value was supplied. + * @throws BadRequestException when the value is not a valid date. + */ +function parseOptionalDate( + value: string | undefined, + fieldName: string, +): Date | undefined { + if (!value) { + return undefined; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new BadRequestException(`${fieldName} must be a valid ISO date.`); + } + + return parsed; +} + +/** + * Shifts a timestamp by whole UTC calendar months while clamping the day to + * the final valid day in the destination month. + * + * @param value Source timestamp. + * @param monthCount Signed number of UTC calendar months to add. + * @returns Shifted timestamp with the original UTC time-of-day. + */ +function shiftUtcMonths(value: Date, monthCount: number): Date { + const sourceYear = value.getUTCFullYear(); + const sourceMonth = value.getUTCMonth(); + const targetFirst = new Date( + Date.UTC(sourceYear, sourceMonth + monthCount, 1), + ); + const finalDay = new Date( + Date.UTC(targetFirst.getUTCFullYear(), targetFirst.getUTCMonth() + 1, 0), + ).getUTCDate(); + + return new Date( + Date.UTC( + targetFirst.getUTCFullYear(), + targetFirst.getUTCMonth(), + Math.min(value.getUTCDate(), finalDay), + value.getUTCHours(), + value.getUTCMinutes(), + value.getUTCSeconds(), + value.getUTCMilliseconds(), + ), + ); +} + +/** + * Resolves optional query dates into an explicit half-open range. + * + * With no dates, the range covers the latest six UTC calendar months, + * including the current month. Supplying only one bound derives the other + * bound six calendar months away. + * + * @param query Optional dashboard range supplied by the caller. + * @param now Current time override used by deterministic tests. + * @returns Explicit ISO start and exclusive end timestamps. + * @throws BadRequestException when a date is invalid or end is not after start. + */ +export function resolveDashboardDateRange( + query: DashboardQueryDto, + now: Date = new Date(), +): DashboardDateRange { + const requestedStart = parseOptionalDate(query.startDate, "startDate"); + const requestedEnd = parseOptionalDate(query.endDate, "endDate"); + const defaultEnd = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1), + ); + + const start = + requestedStart ?? + (requestedEnd + ? shiftUtcMonths(requestedEnd, -6) + : shiftUtcMonths(defaultEnd, -6)); + const end = + requestedEnd ?? + (requestedStart ? shiftUtcMonths(requestedStart, 6) : defaultEnd); + + if (end.getTime() <= start.getTime()) { + throw new BadRequestException("endDate must be later than startDate."); + } + + return { + startDate: start.toISOString(), + endDate: end.toISOString(), + }; +} + +/** + * Converts PostgreSQL numeric and bigint results into finite JavaScript + * numbers suitable for JSON responses. + * + * @param value Raw database value. + * @returns Numeric value, falling back to zero for null or non-finite input. + */ +function toNumber(value: QueryValue | undefined): number { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; +} + +/** + * Loads and maps the SQL-backed reports used by the Reports Portal dashboards. + */ +@Injectable() +export class DashboardReportsService { + /** + * Creates a dashboard report service. + * + * @param db Shared PostgreSQL query service. + * @param sql SQL file loader. + */ + constructor( + private readonly db: DbService, + private readonly sql: SqlLoaderService, + ) {} + + /** + * Loads all three dashboards for one shared reporting range. + * + * @param query Optional date range. + * @returns Full dashboard objects keyed for the landing page. + * @throws BadRequestException when the range is invalid. + */ + async getAllDashboards(query: DashboardQueryDto): Promise { + const range = resolveDashboardDateRange(query); + const [newSignups, membersPaid, challengeParticipation] = await Promise.all( + [ + this.loadNewSignups(range), + this.loadMembersPaid(range), + this.loadChallengeParticipation(range), + ], + ); + + return { + newSignups, + membersPaid, + challengeParticipation, + }; + } + + /** + * Loads one dashboard selected by its public route slug. + * + * @param dashboard Dashboard route slug. + * @param query Optional date range. + * @returns Full selected dashboard object. + * @throws BadRequestException when the dashboard or range is invalid. + */ + async getDashboard( + dashboard: DashboardSlug, + query: DashboardQueryDto, + ): Promise { + const range = resolveDashboardDateRange(query); + + switch (dashboard) { + case DashboardSlug.NewSignups: + return this.loadNewSignups(range); + case DashboardSlug.MembersPaid: + return this.loadMembersPaid(range); + case DashboardSlug.ChallengeParticipation: + return this.loadChallengeParticipation(range); + default: + throw new BadRequestException("Unsupported dashboard."); + } + } + + /** + * Builds a flat monthly export containing all dashboards. + * + * @param query Optional date range. + * @returns Flat rows ready for CSV serialization. + * @throws BadRequestException when the range is invalid. + */ + async exportAllDashboards( + query: DashboardQueryDto, + ): Promise { + const dashboards = await this.getAllDashboards(query); + return [ + ...this.toExportRows(dashboards.newSignups), + ...this.toExportRows(dashboards.membersPaid), + ...this.toExportRows(dashboards.challengeParticipation), + ]; + } + + /** + * Builds a flat monthly export for one dashboard. + * + * @param dashboard Dashboard route slug. + * @param query Optional date range. + * @returns Flat rows ready for CSV serialization. + * @throws BadRequestException when the dashboard or range is invalid. + */ + async exportDashboard( + dashboard: DashboardSlug, + query: DashboardQueryDto, + ): Promise { + const response = await this.getDashboard(dashboard, query); + return this.toExportRows(response); + } + + /** + * Executes and maps the new-signups dashboard SQL. + * + * @param range Explicit half-open query range. + * @returns New-signups dashboard response. + */ + private async loadNewSignups( + range: DashboardDateRange, + ): Promise { + const query = this.sql.load("reports/dashboard/new-signups.sql"); + const rows = await this.db.query(query, [ + range.startDate, + range.endDate, + ]); + const summary = rows[0]; + + return { + dashboard: DashboardSlug.NewSignups, + ...range, + months: rows.map((row) => ({ + month: row.month, + activated: toNumber(row.activated), + notActivated: toNumber(row.not_activated), + })), + summary: { + totalSignups: toNumber(summary?.total_signups), + activatedMembers: toNumber(summary?.activated_members), + notActivatedMembers: toNumber(summary?.not_activated_members), + activationRate: toNumber(summary?.activation_rate), + peakMonth: summary?.peak_month ?? null, + peakMonthSignups: toNumber(summary?.peak_month_signups), + }, + }; + } + + /** + * Executes and maps the unique-members-paid dashboard SQL. + * + * @param range Explicit half-open query range. + * @returns Members-paid dashboard response. + */ + private async loadMembersPaid( + range: DashboardDateRange, + ): Promise { + const query = this.sql.load("reports/dashboard/members-paid.sql"); + const rows = await this.db.query(query, [ + range.startDate, + range.endDate, + ]); + const summary = rows[0]; + + return { + dashboard: DashboardSlug.MembersPaid, + ...range, + months: rows.map((row) => ({ + month: row.month, + taas: toNumber(row.taas), + task: toNumber(row.task), + contest: toNumber(row.contest), + engagement: toNumber(row.engagement), + })), + summary: { + totalUniqueMembers: toNumber(summary?.total_unique_members), + taasUniqueMembers: toNumber(summary?.taas_unique_members), + taskUniqueMembers: toNumber(summary?.task_unique_members), + contestUniqueMembers: toNumber(summary?.contest_unique_members), + engagementUniqueMembers: toNumber(summary?.engagement_unique_members), + peakMonth: summary?.peak_month ?? null, + peakMonthUniqueMembers: toNumber(summary?.peak_month_unique_members), + }, + }; + } + + /** + * Executes and maps the challenge-participation dashboard SQL. + * + * @param range Explicit half-open query range. + * @returns Challenge-participation dashboard response. + */ + private async loadChallengeParticipation( + range: DashboardDateRange, + ): Promise { + const query = this.sql.load( + "reports/dashboard/challenge-participation.sql", + ); + const rows = await this.db.query(query, [ + range.startDate, + range.endDate, + ]); + const summary = rows[0]; + + return { + dashboard: DashboardSlug.ChallengeParticipation, + ...range, + months: rows.map((row) => ({ + month: row.month, + registrants: toNumber(row.registrants), + submitters: toNumber(row.submitters), + })), + summary: { + totalUniqueRegistrants: toNumber(summary?.total_unique_registrants), + totalUniqueSubmitters: toNumber(summary?.total_unique_submitters), + submissionRate: toNumber(summary?.submission_rate), + peakMonth: summary?.peak_month ?? null, + peakMonthRegistrants: toNumber(summary?.peak_month_registrants), + }, + }; + } + + /** + * Flattens a dashboard response into monthly CSV rows. + * + * @param dashboard Full dashboard response. + * @returns Metric-specific flat rows with a dashboard discriminator. + */ + private toExportRows(dashboard: DashboardResponse): DashboardExportRowDto[] { + switch (dashboard.dashboard) { + case DashboardSlug.NewSignups: + return dashboard.months.map((month) => ({ + dashboard: dashboard.dashboard, + month: month.month, + activated: month.activated, + notActivated: month.notActivated, + })); + case DashboardSlug.MembersPaid: + return dashboard.months.map((month) => ({ + dashboard: dashboard.dashboard, + month: month.month, + taas: month.taas, + task: month.task, + contest: month.contest, + engagement: month.engagement, + })); + case DashboardSlug.ChallengeParticipation: + return dashboard.months.map((month) => ({ + dashboard: dashboard.dashboard, + month: month.month, + registrants: month.registrants, + submitters: month.submitters, + })); + } + } +} diff --git a/src/reports/dashboard/dashboard-reports.sql.spec.ts b/src/reports/dashboard/dashboard-reports.sql.spec.ts new file mode 100644 index 0000000..d8f31fa --- /dev/null +++ b/src/reports/dashboard/dashboard-reports.sql.spec.ts @@ -0,0 +1,64 @@ +import { SqlLoaderService } from "../../common/sql-loader.service"; + +describe("Dashboard report SQL", () => { + const sqlLoader = new SqlLoaderService(); + + it.each([ + "new-signups.sql", + "members-paid.sql", + "challenge-participation.sql", + ])( + "uses a half-open range and emits zero-filled calendar months: %s", + (file) => { + const sql = sqlLoader.load(`reports/dashboard/${file}`); + + expect(sql).toContain("$1::timestamptz"); + expect(sql).toContain("$2::timestamptz"); + expect(sql).toContain("GENERATE_SERIES"); + expect(sql).toContain("INTERVAL '1 microsecond'"); + expect(sql).toMatch(/activity_at|paid_at|create_date/); + expect(sql).toContain("COALESCE("); + expect(sql).toContain("ORDER BY m.month_start"); + }, + ); + + it("splits signups by current identity activation status", () => { + const sql = sqlLoader.load("reports/dashboard/new-signups.sql"); + + expect(sql).toContain('FROM identity."user" u'); + expect(sql).toContain("u.status = 'A'"); + expect(sql).toContain("u.status <> 'A'"); + expect(sql).toContain("AS activation_rate"); + expect(sql).toContain("AS peak_month_signups"); + }); + + it("counts paid members once per canonical payment bucket and month", () => { + const sql = sqlLoader.load("reports/dashboard/members-paid.sql"); + + expect(sql).toContain("p.payment_status = 'PAID'"); + expect(sql).toContain("w.type = 'PAYMENT'"); + expect(sql).toContain("COALESCE(p.date_paid, p.created_at)"); + expect(sql).toContain("w.category::text = 'TAAS_PAYMENT'"); + expect(sql).toContain("w.category::text = 'ENGAGEMENT_PAYMENT'"); + expect(sql).toContain("'TASK_REVIEW_PAYMENT'"); + expect(sql).toContain("'TASK_COPILOT_PAYMENT'"); + expect(sql).toContain( + "w.category::text IS DISTINCT FROM 'TOPGEAR_PAYMENT'", + ); + expect(sql).toMatch(/COUNT\(DISTINCT pe\.member_id\) FILTER/g); + }); + + it("counts registration and submission activity independently", () => { + const sql = sqlLoader.load("reports/dashboard/challenge-participation.sql"); + + expect(sql).toContain('FROM resources."Resource" r'); + expect(sql).toContain('JOIN resources."ResourceRole" rr'); + expect(sql).toContain("= 'submitter'"); + expect(sql).toContain("FROM reviews.submission s"); + expect(sql).toContain("s.status <> 'DELETED'"); + expect(sql).toContain('COALESCE(s."submittedDate", s."createdAt")'); + expect(sql).toContain("COUNT(DISTINCT re.member_id)"); + expect(sql).toContain("COUNT(DISTINCT se.member_id)"); + expect(sql).toContain("LEAST("); + }); +}); diff --git a/src/reports/dashboard/guards/dashboard-reports.guard.spec.ts b/src/reports/dashboard/guards/dashboard-reports.guard.spec.ts new file mode 100644 index 0000000..b84dffb --- /dev/null +++ b/src/reports/dashboard/guards/dashboard-reports.guard.spec.ts @@ -0,0 +1,79 @@ +import { + ExecutionContext, + ForbiddenException, + UnauthorizedException, +} from "@nestjs/common"; +import { Scopes, UserRoles } from "../../../app-constants"; +import { DashboardReportsGuard } from "./dashboard-reports.guard"; + +type AuthUserFixture = { + isMachine?: boolean; + roles?: string[] | string; + role?: string[] | string; + scopes?: string[] | string; +}; + +/** + * Builds a minimal Nest execution context for dashboard guard tests. + * + * @param authUser Optional authenticated-user fixture. + * @returns Execution context exposing the fixture on the HTTP request. + */ +function createExecutionContext(authUser?: AuthUserFixture): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => ({ authUser }), + }), + } as unknown as ExecutionContext; +} + +describe("DashboardReportsGuard", () => { + const guard = new DashboardReportsGuard(); + + it("rejects unauthenticated callers", () => { + expect(() => guard.canActivate(createExecutionContext())).toThrow( + UnauthorizedException, + ); + }); + + it.each([ + { roles: ["Administrator"] }, + { role: "Topcoder Administrator" }, + { roles: [UserRoles.TalentManager] }, + { role: "Topcoder Talent Manager" }, + ])("allows an authorized human role: %o", (authUser) => { + expect(guard.canActivate(createExecutionContext(authUser))).toBe(true); + }); + + it("allows machine clients with the all-reports scope", () => { + expect( + guard.canActivate( + createExecutionContext({ + isMachine: true, + scopes: [Scopes.AllReports], + }), + ), + ).toBe(true); + }); + + it("accepts a space-delimited machine scope claim", () => { + expect( + guard.canActivate( + createExecutionContext({ + isMachine: true, + scopes: `openid ${Scopes.AllReports}`, + }), + ), + ).toBe(true); + }); + + it.each([ + { roles: [UserRoles.ProductManager] }, + { scopes: [Scopes.AllReports] }, + { isMachine: true, scopes: [Scopes.Member.MemberSearch] }, + ])("rejects an unauthorized caller: %o", (authUser) => { + expect(() => guard.canActivate(createExecutionContext(authUser))).toThrow( + ForbiddenException, + ); + }); +}); diff --git a/src/reports/dashboard/guards/dashboard-reports.guard.ts b/src/reports/dashboard/guards/dashboard-reports.guard.ts new file mode 100644 index 0000000..3942488 --- /dev/null +++ b/src/reports/dashboard/guards/dashboard-reports.guard.ts @@ -0,0 +1,67 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + UnauthorizedException, +} from "@nestjs/common"; +import { Scopes, UserRoles } from "../../../app-constants"; +import { + AuthUserLike, + getNormalizedRoles, + hasAccessToScopes, + hasAdminRole, +} from "../../../auth/permissions.util"; + +const allowedHumanRoles = new Set([ + UserRoles.TalentManager.toLowerCase(), +]); + +/** + * Protects dashboard JSON and CSV endpoints. + * + * Human callers must be Administrators or Talent Managers. Machine callers + * must carry the all-reports scope. + */ +@Injectable() +export class DashboardReportsGuard implements CanActivate { + /** + * Evaluates the authenticated caller attached by the reports auth middleware. + * + * @param context Nest execution context for the incoming dashboard request. + * @returns `true` when the caller may access dashboard data. + * @throws UnauthorizedException when no authenticated caller is present. + * @throws ForbiddenException when the caller lacks the required role or scope. + */ + canActivate(context: ExecutionContext): boolean { + const authUser: AuthUserLike | undefined = context + .switchToHttp() + .getRequest().authUser; + + if (!authUser) { + throw new UnauthorizedException("You are not authenticated."); + } + + if (authUser.isMachine) { + if (hasAccessToScopes(authUser, [Scopes.AllReports])) { + return true; + } + + throw new ForbiddenException( + "You do not have the required permissions to access this resource.", + ); + } + + const roles = getNormalizedRoles(authUser); + if ( + hasAdminRole(roles) || + roles.some((role) => allowedHumanRoles.has(role)) + ) { + return true; + } + + throw new ForbiddenException( + "You do not have the required permissions to access this resource.", + ); + } +} From ac6bc1ba5d3ba9a8dcd65832e74771521d719e6a Mon Sep 17 00:00:00 2001 From: jmgasper Date: Thu, 23 Jul 2026 16:31:20 +1000 Subject: [PATCH 9/9] Dashboard tweaks --- sql/reports/dashboard/members-paid.sql | 14 +++++++------- .../dashboard/dashboard-reports.controller.spec.ts | 4 ++-- src/reports/dashboard/dashboard-reports.dto.ts | 6 +++--- .../dashboard/dashboard-reports.service.spec.ts | 14 +++++++------- src/reports/dashboard/dashboard-reports.service.ts | 10 +++++----- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/sql/reports/dashboard/members-paid.sql b/sql/reports/dashboard/members-paid.sql index 11246ff..a37aef9 100644 --- a/sql/reports/dashboard/members-paid.sql +++ b/sql/reports/dashboard/members-paid.sql @@ -36,7 +36,7 @@ paid_events AS MATERIALIZED ( 'DEPLOYMENT_TASK_PAYMENT', 'PROJECT_DEPLOYMENT_TASK_PAYMENT' ) THEN 'task' - ELSE 'contest' + ELSE 'challenge' END AS payment_type FROM finance.payment p JOIN finance.winnings w @@ -57,8 +57,8 @@ selected_months AS ( WHERE pe.payment_type = 'task' ) AS task, COUNT(DISTINCT pe.member_id) FILTER ( - WHERE pe.payment_type = 'contest' - ) AS contest, + WHERE pe.payment_type = 'challenge' + ) AS challenge, COUNT(DISTINCT pe.member_id) FILTER ( WHERE pe.payment_type = 'engagement' ) AS engagement @@ -93,8 +93,8 @@ all_time_summary AS ( WHERE pe.payment_type = 'task' ) AS task_unique_members, COUNT(DISTINCT pe.member_id) FILTER ( - WHERE pe.payment_type = 'contest' - ) AS contest_unique_members, + WHERE pe.payment_type = 'challenge' + ) AS challenge_unique_members, COUNT(DISTINCT pe.member_id) FILTER ( WHERE pe.payment_type = 'engagement' ) AS engagement_unique_members @@ -104,12 +104,12 @@ SELECT TO_CHAR(m.month_start, 'YYYY-MM-01') AS month, COALESCE(sm.taas, 0) AS taas, COALESCE(sm.task, 0) AS task, - COALESCE(sm.contest, 0) AS contest, + COALESCE(sm.challenge, 0) AS challenge, COALESCE(sm.engagement, 0) AS engagement, ats.total_unique_members, ats.taas_unique_members, ats.task_unique_members, - ats.contest_unique_members, + ats.challenge_unique_members, ats.engagement_unique_members, TO_CHAR(pm.month_start, 'YYYY-MM-01') AS peak_month, COALESCE(pm.unique_members, 0) AS peak_month_unique_members diff --git a/src/reports/dashboard/dashboard-reports.controller.spec.ts b/src/reports/dashboard/dashboard-reports.controller.spec.ts index 9cd0f7c..1831e7a 100644 --- a/src/reports/dashboard/dashboard-reports.controller.spec.ts +++ b/src/reports/dashboard/dashboard-reports.controller.spec.ts @@ -85,7 +85,7 @@ describe("DashboardReportsController", () => { month: "2026-02-01", taas: 3, task: 4, - contest: 5, + challenge: 5, engagement: 2, }, ]; @@ -93,7 +93,7 @@ describe("DashboardReportsController", () => { await expect(controller.exportAllDashboards(query)).resolves.toBe( [ - "dashboard,month,activated,notActivated,taas,task,contest,engagement", + "dashboard,month,activated,notActivated,taas,task,challenge,engagement", "new-signups,2026-02-01,10,2,,,,", "members-paid,2026-02-01,,,3,4,5,2", ].join("\n"), diff --git a/src/reports/dashboard/dashboard-reports.dto.ts b/src/reports/dashboard/dashboard-reports.dto.ts index 113706c..4870101 100644 --- a/src/reports/dashboard/dashboard-reports.dto.ts +++ b/src/reports/dashboard/dashboard-reports.dto.ts @@ -119,7 +119,7 @@ export class MembersPaidMonthDto { task: number; @ApiProperty({ example: 1294 }) - contest: number; + challenge: number; @ApiProperty({ example: 376 }) engagement: number; @@ -139,7 +139,7 @@ export class MembersPaidSummaryDto { taskUniqueMembers: number; @ApiProperty({ example: 14226 }) - contestUniqueMembers: number; + challengeUniqueMembers: number; @ApiProperty({ example: 3154 }) engagementUniqueMembers: number; @@ -281,7 +281,7 @@ export class DashboardExportRowDto { task?: number; @ApiPropertyOptional() - contest?: number; + challenge?: number; @ApiPropertyOptional() engagement?: number; diff --git a/src/reports/dashboard/dashboard-reports.service.spec.ts b/src/reports/dashboard/dashboard-reports.service.spec.ts index 7c804bd..1a93220 100644 --- a/src/reports/dashboard/dashboard-reports.service.spec.ts +++ b/src/reports/dashboard/dashboard-reports.service.spec.ts @@ -47,12 +47,12 @@ const membersPaidRows = [ month: "2026-02-01", taas: "3", task: "4", - contest: "5", + challenge: "5", engagement: "2", total_unique_members: "40", taas_unique_members: "8", task_unique_members: "13", - contest_unique_members: "24", + challenge_unique_members: "24", engagement_unique_members: "7", peak_month: "2025-11-01", peak_month_unique_members: "15", @@ -190,7 +190,7 @@ describe("DashboardReportsService", () => { month: "2026-02-01", taas: 3, task: 4, - contest: 5, + challenge: 5, engagement: 2, }, ], @@ -198,7 +198,7 @@ describe("DashboardReportsService", () => { totalUniqueMembers: 40, taasUniqueMembers: 8, taskUniqueMembers: 13, - contestUniqueMembers: 24, + challengeUniqueMembers: 24, engagementUniqueMembers: 7, peakMonth: "2025-11-01", peakMonthUniqueMembers: 15, @@ -296,7 +296,7 @@ describe("DashboardReportsService", () => { month: "2026-02-01", taas: 3, task: 4, - contest: 5, + challenge: 5, engagement: 2, }, ], @@ -304,7 +304,7 @@ describe("DashboardReportsService", () => { totalUniqueMembers: 10, taasUniqueMembers: 3, taskUniqueMembers: 4, - contestUniqueMembers: 5, + challengeUniqueMembers: 5, engagementUniqueMembers: 2, peakMonth: "2026-02-01", peakMonthUniqueMembers: 10, @@ -337,7 +337,7 @@ describe("DashboardReportsService", () => { month: "2026-02-01", taas: 3, task: 4, - contest: 5, + challenge: 5, engagement: 2, }, { diff --git a/src/reports/dashboard/dashboard-reports.service.ts b/src/reports/dashboard/dashboard-reports.service.ts index f26535a..3df4b40 100644 --- a/src/reports/dashboard/dashboard-reports.service.ts +++ b/src/reports/dashboard/dashboard-reports.service.ts @@ -30,12 +30,12 @@ type MembersPaidRow = { month: string; taas: QueryValue; task: QueryValue; - contest: QueryValue; + challenge: QueryValue; engagement: QueryValue; total_unique_members: QueryValue; taas_unique_members: QueryValue; task_unique_members: QueryValue; - contest_unique_members: QueryValue; + challenge_unique_members: QueryValue; engagement_unique_members: QueryValue; peak_month: string | null; peak_month_unique_members: QueryValue; @@ -326,14 +326,14 @@ export class DashboardReportsService { month: row.month, taas: toNumber(row.taas), task: toNumber(row.task), - contest: toNumber(row.contest), + challenge: toNumber(row.challenge), engagement: toNumber(row.engagement), })), summary: { totalUniqueMembers: toNumber(summary?.total_unique_members), taasUniqueMembers: toNumber(summary?.taas_unique_members), taskUniqueMembers: toNumber(summary?.task_unique_members), - contestUniqueMembers: toNumber(summary?.contest_unique_members), + challengeUniqueMembers: toNumber(summary?.challenge_unique_members), engagementUniqueMembers: toNumber(summary?.engagement_unique_members), peakMonth: summary?.peak_month ?? null, peakMonthUniqueMembers: toNumber(summary?.peak_month_unique_members), @@ -398,7 +398,7 @@ export class DashboardReportsService { month: month.month, taas: month.taas, task: month.task, - contest: month.contest, + challenge: month.challenge, engagement: month.engagement, })); case DashboardSlug.ChallengeParticipation: