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 1993c1e..905bed2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,21 @@ # ---- Base Stage ---- -FROM node:20-alpine AS base +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/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/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 diff --git a/sql/reports/challenges/submitters.sql b/sql/reports/challenges/submitters.sql index 3a19f08..2007671 100644 --- a/sql/reports/challenges/submitters.sql +++ b/sql/reports/challenges/submitters.sql @@ -1,7 +1,8 @@ WITH challenge_context AS ( SELECT c.id, - (ct.name = 'Marathon Match') AS is_marathon_match + (ct.name = 'Marathon Match') AS is_marathon_match, + (c.status = 'COMPLETED') AS is_completed FROM challenges."Challenge" AS c JOIN challenges."ChallengeType" AS ct ON ct.id = c."typeId" @@ -17,17 +18,46 @@ submission_metrics AS ( s."finalScore"::double precision, s."initialScore"::double precision ) AS standard_score, - provisional_review.provisional_score, - COALESCE( - final_review."aggregateScore", - s."finalScore"::double precision - ) AS final_score_raw + CASE + WHEN s.status IN ( + 'FAILED_SCREENING', + 'FAILED_REVIEW', + 'FAILED_CHECKPOINT_SCREENING', + 'FAILED_CHECKPOINT_REVIEW', + 'DELETED' + ) THEN NULL + WHEN provisional_review.has_provisional_review THEN CASE + WHEN provisional_review.provisional_score >= 0 THEN provisional_review.provisional_score + ELSE NULL + END + WHEN s."initialScore"::double precision >= 0 THEN s."initialScore"::double precision + ELSE NULL + END AS provisional_score, + CASE + WHEN NOT cc.is_completed THEN NULL + WHEN s.status IN ( + 'FAILED_SCREENING', + 'FAILED_REVIEW', + 'FAILED_CHECKPOINT_SCREENING', + 'FAILED_CHECKPOINT_REVIEW', + 'DELETED' + ) THEN NULL + WHEN final_review.has_final_review THEN CASE + WHEN final_review."aggregateScore" >= 0 THEN final_review."aggregateScore" + ELSE NULL + END + WHEN s."finalScore"::double precision >= 0 THEN s."finalScore"::double precision + ELSE NULL + END AS final_score_raw, + cc.is_completed FROM challenge_context AS cc JOIN reviews."submission" AS s ON s."challengeId" = cc.id AND s."memberId" IS NOT NULL LEFT JOIN LATERAL ( - SELECT rs."aggregateScore" + SELECT + TRUE AS has_final_review, + rs."aggregateScore" FROM reviews."reviewSummation" AS rs WHERE rs."submissionId" = s.id AND COALESCE(rs."isFinal", TRUE) = TRUE @@ -36,7 +66,9 @@ submission_metrics AS ( LIMIT 1 ) AS final_review ON TRUE LEFT JOIN LATERAL ( - SELECT rs."aggregateScore" AS provisional_score + SELECT + TRUE AS has_provisional_review, + rs."aggregateScore" AS provisional_score FROM reviews."reviewSummation" AS rs WHERE rs."submissionId" = s.id AND rs."isProvisional" IS TRUE @@ -73,9 +105,12 @@ mm_latest_submission_scores AS ( sm."memberId", sm.provisional_score AS provisional_score_raw, sm.final_score_raw, - COALESCE(sm.final_score_raw, sm.provisional_score) AS effective_score_raw, + sm.is_completed, sm.submission_timestamp FROM submission_metrics AS sm + WHERE + sm.provisional_score IS NOT NULL + OR sm.final_score_raw IS NOT NULL ORDER BY sm."memberId", sm.submission_timestamp DESC NULLS LAST, @@ -93,13 +128,13 @@ mm_ranked_scores AS ( ELSE ROUND(mlss.final_score_raw::numeric, 2) END AS "finalScore", CASE - WHEN mlss.effective_score_raw IS NULL THEN NULL - ELSE ROW_NUMBER() OVER ( + WHEN mlss.is_completed AND mlss.final_score_raw IS NOT NULL THEN ROW_NUMBER() OVER ( ORDER BY - mlss.effective_score_raw DESC NULLS LAST, + mlss.final_score_raw DESC NULLS LAST, mlss.submission_timestamp ASC NULLS LAST, mlss."memberId" ASC ) + ELSE NULL END AS "finalRank" FROM mm_latest_submission_scores AS mlss ) @@ -177,6 +212,10 @@ ORDER BY WHEN sm.is_marathon_match THEN mrs."finalRank" ELSE NULL END ASC NULLS LAST, + CASE + WHEN sm.is_marathon_match AND mrs."finalRank" IS NULL THEN mrs."provisionalScore" + ELSE NULL + END DESC NULLS LAST, CASE WHEN sm.is_marathon_match THEN NULL ELSE sms."submissionScore" diff --git a/sql/reports/challenges/valid-submitters.sql b/sql/reports/challenges/valid-submitters.sql index 1f4ee22..3c6a252 100644 --- a/sql/reports/challenges/valid-submitters.sql +++ b/sql/reports/challenges/valid-submitters.sql @@ -1,7 +1,8 @@ WITH challenge_context AS ( SELECT c.id, - (ct.name = 'Marathon Match') AS is_marathon_match + (ct.name = 'Marathon Match') AS is_marathon_match, + (c.status = 'COMPLETED') AS is_completed FROM challenges."Challenge" AS c JOIN challenges."ChallengeType" AS ct ON ct.id = c."typeId" @@ -17,11 +18,38 @@ submission_metrics AS ( s."finalScore"::double precision, s."initialScore"::double precision ) AS standard_score, - provisional_review.provisional_score, - COALESCE( - final_review."aggregateScore", - s."finalScore"::double precision - ) AS final_score_raw, + CASE + WHEN s.status IN ( + 'FAILED_SCREENING', + 'FAILED_REVIEW', + 'FAILED_CHECKPOINT_SCREENING', + 'FAILED_CHECKPOINT_REVIEW', + 'DELETED' + ) THEN NULL + WHEN provisional_review.has_provisional_review THEN CASE + WHEN provisional_review.provisional_score >= 0 THEN provisional_review.provisional_score + ELSE NULL + END + WHEN s."initialScore"::double precision >= 0 THEN s."initialScore"::double precision + ELSE NULL + END AS provisional_score, + CASE + WHEN NOT cc.is_completed THEN NULL + WHEN s.status IN ( + 'FAILED_SCREENING', + 'FAILED_REVIEW', + 'FAILED_CHECKPOINT_SCREENING', + 'FAILED_CHECKPOINT_REVIEW', + 'DELETED' + ) THEN NULL + WHEN final_review.has_final_review THEN CASE + WHEN final_review."aggregateScore" >= 0 THEN final_review."aggregateScore" + ELSE NULL + END + WHEN s."finalScore"::double precision >= 0 THEN s."finalScore"::double precision + ELSE NULL + END AS final_score_raw, + cc.is_completed, ( passing_review.is_passing IS TRUE OR COALESCE(s."finalScore"::double precision, 0) > 98 @@ -31,7 +59,9 @@ submission_metrics AS ( ON s."challengeId" = cc.id AND s."memberId" IS NOT NULL LEFT JOIN LATERAL ( - SELECT rs."aggregateScore" + SELECT + TRUE AS has_final_review, + rs."aggregateScore" FROM reviews."reviewSummation" AS rs WHERE rs."submissionId" = s.id AND COALESCE(rs."isFinal", TRUE) = TRUE @@ -40,7 +70,9 @@ submission_metrics AS ( LIMIT 1 ) AS final_review ON TRUE LEFT JOIN LATERAL ( - SELECT rs."aggregateScore" AS provisional_score + SELECT + TRUE AS has_provisional_review, + rs."aggregateScore" AS provisional_score FROM reviews."reviewSummation" AS rs WHERE rs."submissionId" = s.id AND rs."isProvisional" IS TRUE @@ -90,9 +122,12 @@ mm_latest_submission_scores AS ( vsm."memberId", vsm.provisional_score AS provisional_score_raw, vsm.final_score_raw, - COALESCE(vsm.final_score_raw, vsm.provisional_score) AS effective_score_raw, + vsm.is_completed, vsm.submission_timestamp FROM valid_submission_metrics AS vsm + WHERE + vsm.provisional_score IS NOT NULL + OR vsm.final_score_raw IS NOT NULL ORDER BY vsm."memberId", vsm.submission_timestamp DESC NULLS LAST, @@ -110,13 +145,13 @@ mm_ranked_scores AS ( ELSE ROUND(mlss.final_score_raw::numeric, 2) END AS "finalScore", CASE - WHEN mlss.effective_score_raw IS NULL THEN NULL - ELSE ROW_NUMBER() OVER ( + WHEN mlss.is_completed AND mlss.final_score_raw IS NOT NULL THEN ROW_NUMBER() OVER ( ORDER BY - mlss.effective_score_raw DESC NULLS LAST, + mlss.final_score_raw DESC NULLS LAST, mlss.submission_timestamp ASC NULLS LAST, mlss."memberId" ASC ) + ELSE NULL END AS "finalRank" FROM mm_latest_submission_scores AS mlss ) @@ -194,6 +229,10 @@ ORDER BY WHEN vsm.is_marathon_match THEN mrs."finalRank" ELSE NULL END ASC NULLS LAST, + CASE + WHEN vsm.is_marathon_match AND mrs."finalRank" IS NULL THEN mrs."provisionalScore" + ELSE NULL + END DESC NULLS LAST, CASE WHEN vsm.is_marathon_match THEN NULL ELSE sms."submissionScore" diff --git a/sql/reports/challenges/winners.sql b/sql/reports/challenges/winners.sql index 8dc628a..85ac684 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" @@ -9,23 +10,53 @@ 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, s."initialScore"::double precision ) AS standard_score, - provisional_review.provisional_score, - COALESCE( - final_review."aggregateScore", - s."finalScore"::double precision - ) AS final_score_raw + CASE + WHEN s.status IN ( + 'FAILED_SCREENING', + 'FAILED_REVIEW', + 'FAILED_CHECKPOINT_SCREENING', + 'FAILED_CHECKPOINT_REVIEW', + 'DELETED' + ) THEN NULL + WHEN provisional_review.has_provisional_review THEN CASE + WHEN provisional_review.provisional_score >= 0 THEN provisional_review.provisional_score + ELSE NULL + END + WHEN s."initialScore"::double precision >= 0 THEN s."initialScore"::double precision + ELSE NULL + END AS provisional_score, + CASE + WHEN NOT cc.is_completed THEN NULL + WHEN s.status IN ( + 'FAILED_SCREENING', + 'FAILED_REVIEW', + 'FAILED_CHECKPOINT_SCREENING', + 'FAILED_CHECKPOINT_REVIEW', + 'DELETED' + ) THEN NULL + WHEN final_review.has_final_review THEN CASE + WHEN final_review."aggregateScore" >= 0 THEN final_review."aggregateScore" + ELSE NULL + END + WHEN s."finalScore"::double precision >= 0 THEN s."finalScore"::double precision + ELSE NULL + END AS final_score_raw FROM challenge_context AS cc JOIN reviews."submission" AS s ON s."challengeId" = cc.id AND s."memberId" IS NOT NULL LEFT JOIN LATERAL ( - SELECT rs."aggregateScore" + SELECT + TRUE AS has_final_review, + rs."aggregateScore" FROM reviews."reviewSummation" AS rs WHERE rs."submissionId" = s.id AND COALESCE(rs."isFinal", TRUE) = TRUE @@ -34,15 +65,20 @@ submission_metrics AS ( LIMIT 1 ) AS final_review ON TRUE LEFT JOIN LATERAL ( - SELECT MAX(rs."aggregateScore") AS provisional_score + SELECT + TRUE AS has_provisional_review, + rs."aggregateScore" AS provisional_score FROM reviews."reviewSummation" AS rs WHERE rs."submissionId" = s.id AND rs."isProvisional" IS TRUE + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC + LIMIT 1 ) AS provisional_review ON TRUE ), winner_members AS MATERIALIZED ( SELECT cc.is_marathon_match, + cc.is_completed, cw."userId"::text AS "memberId", MAX(cw.handle) AS "winnerHandle", MIN(cw.placement) AS placement @@ -52,6 +88,7 @@ winner_members AS MATERIALIZED ( AND cw.type = 'PLACEMENT' GROUP BY cc.is_marathon_match, + cc.is_completed, cw."userId" ), standard_member_scores AS ( @@ -61,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( @@ -126,7 +169,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/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..a37aef9 --- /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 'challenge' + 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 = 'challenge' + ) AS challenge, + 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 = 'challenge' + ) AS challenge_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.challenge, 0) AS challenge, + COALESCE(sm.engagement, 0) AS engagement, + ats.total_unique_members, + ats.taas_unique_members, + ats.task_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 +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/challenges/challenge-export-sql.spec.ts b/src/reports/challenges/challenge-export-sql.spec.ts index 15b92b1..2023771 100644 --- a/src/reports/challenges/challenge-export-sql.spec.ts +++ b/src/reports/challenges/challenge-export-sql.spec.ts @@ -3,18 +3,98 @@ 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).toContain(`(c.status = 'COMPLETED') AS is_completed`); + expect(sql).toContain("WHEN NOT cc.is_completed THEN NULL"); + expect(sql).toContain("TRUE AS has_final_review"); + expect(sql).toContain(`WHEN s.status IN (`); + expect(sql).toContain("WHEN final_review.has_final_review THEN CASE"); + expect(sql).toContain( + `WHEN final_review."aggregateScore" >= 0 THEN final_review."aggregateScore"`, + ); + expect(sql).toContain( + `WHEN s."finalScore"::double precision >= 0 THEN s."finalScore"::double precision`, + ); + }, + ); + + it.each(challengeUserSqlPaths)( + "uses only non-failed Marathon Match provisional score fallbacks in %s", + (sqlPath) => { + const sql = sqlLoader.load(sqlPath); + + expect(sql).toContain( + "WHEN provisional_review.has_provisional_review THEN CASE", + ); + expect(sql).toContain( + "WHEN provisional_review.provisional_score >= 0 THEN provisional_review.provisional_score", + ); + expect(sql).toContain( + `WHEN s."initialScore"::double precision >= 0 THEN s."initialScore"::double precision`, + ); + expect(sql).toContain("TRUE AS has_provisional_review"); + expect(sql).toContain(`'FAILED_REVIEW'`); + expect(sql).toContain(`'DELETED'`); + }, + ); + + it.each(challengeUserSqlPaths)( + "guards failed Marathon Match provisional reviews before falling back in %s", + (sqlPath) => { + const sql = sqlLoader.load(sqlPath); + + expect(sql).not.toContain(`AND rs."aggregateScore" >= 0`); + }, + ); + + it.each([ + "reports/challenges/submitters.sql", + "reports/challenges/valid-submitters.sql", + ])("only ranks completed Marathon Match submissions in %s", (sqlPath) => { + const sql = sqlLoader.load(sqlPath); + + expect(sql).toContain( + "WHEN mlss.is_completed AND mlss.final_score_raw IS NOT NULL THEN ROW_NUMBER() OVER", + ); + expect(sql).toMatch( + /WHERE\s+\w+\.provisional_score IS NOT NULL\s+OR \w+\.final_score_raw IS NOT NULL/, + ); + expect(sql).toMatch( + /WHEN \w+\.is_marathon_match AND mrs\."finalRank" IS NULL THEN mrs\."provisionalScore"/, + ); + }); + + it.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( - /COALESCE\(\s*final_review\."aggregateScore",\s*s\."finalScore"::double precision\s*\)\s+AS final_score_raw/, + /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"); + + expect(sql).toContain( + "WHEN wm.is_marathon_match AND wm.is_completed THEN wm.placement", + ); + }); }); diff --git a/src/reports/challenges/challenges-reports.service.spec.ts b/src/reports/challenges/challenges-reports.service.spec.ts index 999ac5d..91e5c17 100644 --- a/src/reports/challenges/challenges-reports.service.spec.ts +++ b/src/reports/challenges/challenges-reports.service.spec.ts @@ -87,6 +87,71 @@ describe("ChallengesReportsService", () => { ]); }); + it("omits Marathon Match final columns when final scoring is unavailable", async () => { + db.query.mockResolvedValue([ + { + userId: 88779578, + handle: "adipowfamo", + email: "topcodergh+adipowfamo@gmail.com", + firstName: "Adipo", + lastName: "Wfamo", + country: "Australia", + isMarathonMatch: true, + provisionalScore: 87.29, + finalScore: null, + finalRank: null, + }, + { + userId: 10000039, + handle: "testaws1", + email: "topcodergh+testaws1@gmail.com", + firstName: "Testaws", + lastName: "One", + country: "Japan", + isMarathonMatch: true, + provisionalScore: 90.83, + finalScore: null, + finalRank: null, + }, + ]); + + const result = await service.getSubmitters({ + challengeId: "1bb94965-32e3-40a6-9933-2c6bd9dcdca8", + }); + + expect(result).toEqual([ + { + userId: 88779578, + handle: "adipowfamo", + email: "topcodergh+adipowfamo@gmail.com", + firstName: "Adipo", + lastName: "Wfamo", + country: "Australia", + provisionalScore: 87.29, + }, + { + userId: 10000039, + handle: "testaws1", + email: "topcodergh+testaws1@gmail.com", + firstName: "Testaws", + lastName: "One", + country: "Japan", + provisionalScore: 90.83, + }, + ]); + expect(Object.keys(result[0])).toEqual([ + "userId", + "handle", + "email", + "firstName", + "lastName", + "country", + "provisionalScore", + ]); + expect(result[0]).not.toHaveProperty("finalScore"); + expect(result[0]).not.toHaveProperty("finalRank"); + }); + it("returns Marathon Match winners with final scores when available", async () => { db.query.mockResolvedValue([ { diff --git a/src/reports/challenges/challenges-reports.service.ts b/src/reports/challenges/challenges-reports.service.ts index a94d39a..df29539 100644 --- a/src/reports/challenges/challenges-reports.service.ts +++ b/src/reports/challenges/challenges-reports.service.ts @@ -184,7 +184,7 @@ export class ChallengesReportsService { /** * Normalizes raw challenge user report rows into the exported column shape. * @param records SQL rows for one challenge report, including the internal Marathon Match flag. - * @returns Export-ready records with either submissionScore or the Marathon Match-specific score and ranking columns. + * @returns Export-ready records with either submissionScore or the available Marathon Match score and ranking columns. * @throws Does not throw. It is used as a pure formatter inside the challenge report service methods. */ private formatChallengeUserReport( @@ -197,6 +197,12 @@ export class ChallengesReportsService { const isMarathonMatch = records.some( (record) => record.isMarathonMatch === true, ); + const hasFinalScore = records.some( + (record) => record.finalScore !== null && record.finalScore !== undefined, + ); + const hasFinalRank = records.some( + (record) => record.finalRank !== null && record.finalRank !== undefined, + ); return records.map((record) => { const normalized: ChallengeUserRecordDto = { @@ -210,8 +216,12 @@ export class ChallengesReportsService { if (isMarathonMatch) { normalized.provisionalScore = record.provisionalScore ?? null; - normalized.finalScore = record.finalScore ?? null; - normalized.finalRank = record.finalRank ?? null; + if (hasFinalScore) { + normalized.finalScore = record.finalScore ?? null; + } + if (hasFinalRank) { + normalized.finalRank = record.finalRank ?? null; + } return normalized; } diff --git a/src/reports/challenges/dtos/challenge-users.dto.ts b/src/reports/challenges/dtos/challenge-users.dto.ts index 8e5dcfb..be29742 100644 --- a/src/reports/challenges/dtos/challenge-users.dto.ts +++ b/src/reports/challenges/dtos/challenge-users.dto.ts @@ -17,9 +17,9 @@ export class ChallengeUsersPathParamDto { /** * User record returned by challenge user reports including resolved country. * Standard challenge submission-based reports expose submissionScore. - * Marathon Match submission-based reports expose provisionalScore and - * finalScore from the latest submission, plus finalRank by current effective - * score, breaking ties by earlier submission time. + * Marathon Match submission-based reports expose provisionalScore from the + * latest non-failed scored submission. Completed Marathon Match reports also + * expose finalScore and finalRank when final scoring data is available. */ export interface ChallengeUserRecordDto { userId: number; diff --git a/src/reports/dashboard/dashboard-reports.controller.spec.ts b/src/reports/dashboard/dashboard-reports.controller.spec.ts new file mode 100644 index 0000000..1831e7a --- /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, + challenge: 5, + engagement: 2, + }, + ]; + service.exportAllDashboards.mockResolvedValue(rows); + + await expect(controller.exportAllDashboards(query)).resolves.toBe( + [ + "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"), + ); + 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..4870101 --- /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 }) + challenge: 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 }) + challengeUniqueMembers: 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() + challenge?: 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..1a93220 --- /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", + challenge: "5", + engagement: "2", + total_unique_members: "40", + taas_unique_members: "8", + task_unique_members: "13", + challenge_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, + challenge: 5, + engagement: 2, + }, + ], + summary: { + totalUniqueMembers: 40, + taasUniqueMembers: 8, + taskUniqueMembers: 13, + challengeUniqueMembers: 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, + challenge: 5, + engagement: 2, + }, + ], + summary: { + totalUniqueMembers: 10, + taasUniqueMembers: 3, + taskUniqueMembers: 4, + challengeUniqueMembers: 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, + challenge: 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..3df4b40 --- /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; + challenge: QueryValue; + engagement: QueryValue; + total_unique_members: QueryValue; + taas_unique_members: QueryValue; + task_unique_members: QueryValue; + challenge_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), + 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), + challengeUniqueMembers: toNumber(summary?.challenge_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, + challenge: month.challenge, + 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.", + ); + } +} diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index eab8d1d..3c110a2 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 use the latest non-failed provisionalScore and include completed challenge finalScore/finalRank values.", AppScopes.Challenge.Winners, [challengeIdParam], ),