diff --git a/.circleci/config.yml b/.circleci/config.yml index c79c575..f909fc6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -66,6 +66,8 @@ workflows: - develop - PM-4931 - improve-member-search-2 + tags: + only: /^dev-.*/ # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/README.md b/README.md index 60749df..ac64014 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,11 @@ The Reports Portal dashboard API is available under `/v6/reports/dashboard`: - `GET /v6/reports/dashboard` returns all dashboards, keyed as - `newSignups`, `membersPaid`, and `challengeParticipation`. + `newSignups`, `membersPaid`, `challengeParticipation`, + `memberPaymentByMonth`, and `memberPaymentByCustomer`. - `GET /v6/reports/dashboard/:dashboard` returns one dashboard. Supported - slugs are `new-signups`, `members-paid`, and `challenge-participation`. + slugs are `new-signups`, `members-paid`, `challenge-participation`, + `member-payment-by-month`, and `member-payment-by-customer`. - `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 @@ -24,20 +26,28 @@ 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. +includes the resolved timestamps and zero-filled calendar months. The signup, +members-paid, and challenge-participation dashboards also include 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. +- Paid-member activity uses the latest non-cancelled finance payment for a + `PAYMENT` winning. It is grouped by the payment creation month so projected + payments that are owed or on hold remain visible. Members are deduplicated + within each payment bucket and month. +- Member-payment values use the latest non-cancelled finance payment and group + `gross_amount` by payment creation month, falling back to `total_amount`. + The payment-by-customer dashboard ranks the top five billing-account clients + across the selected range and groups all unnamed or remaining clients under + `Other Customers`. +- Challenge participation uses the latest actual phase completion month for + Challenge, Marathon Match, and First2Finish cohorts. Registrants are + Submitter resources, and submitters have a non-deleted submission for the + same challenge and member. Each category is deduplicated by member and + cohort month. - Rates are percentages from 0 through 100. Human access is limited to Administrator and Talent Manager roles. Machine diff --git a/package.json b/package.json index c27abc0..02ed013 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "packageManager": "pnpm@11.15.0", "scripts": { "build": "nest build", + "deploy:dev": "BRANCH=$(git rev-parse --abbrev-ref HEAD) && TAG=\"dev-${BRANCH}\" && git tag -d \"$TAG\" 2>/dev/null; git push origin \":refs/tags/$TAG\" 2>/dev/null; git tag \"$TAG\" && git push origin \"$TAG\"", "start": "node dist/main.js", "start:dev": "nest start --watch", "start:prod": "node dist/main.js", diff --git a/sql/reports/dashboard/challenge-participation.sql b/sql/reports/dashboard/challenge-participation.sql index b48a863..df26712 100644 --- a/sql/reports/dashboard/challenge-participation.sql +++ b/sql/reports/dashboard/challenge-participation.sql @@ -4,8 +4,10 @@ -- $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. +-- The month is the challenge cohort's latest actual phase completion month, +-- matching the Challenge Registrants report. This avoids treating legacy +-- resource import timestamps as registration dates. 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, @@ -19,11 +21,31 @@ months AS ( ) AS month_start FROM bounds b ), -registration_events AS MATERIALIZED ( +eligible_challenges AS MATERIALIZED ( SELECT + c.id AS challenge_id, + lp."actualEndDate" AS activity_at + FROM challenges."Challenge" c + JOIN challenges."ChallengeType" ct + ON ct.id = c."typeId" + JOIN LATERAL ( + SELECT cp."actualEndDate" + FROM challenges."ChallengePhase" cp + WHERE cp."challengeId" = c.id + ORDER BY cp."scheduledEndDate" DESC + LIMIT 1 + ) lp + ON lp."actualEndDate" IS NOT NULL + WHERE ct.name IN ('Challenge', 'Marathon Match', 'First2Finish') +), +registration_events AS MATERIALIZED ( + SELECT DISTINCT + ec.challenge_id, NULLIF(TRIM(r."memberId"), '') AS member_id, - r."createdAt" AS activity_at - FROM resources."Resource" r + ec.activity_at + FROM eligible_challenges ec + JOIN resources."Resource" r + ON r."challengeId" = ec.challenge_id JOIN resources."ResourceRole" rr ON rr.id = r."roleId" WHERE COALESCE(NULLIF(TRIM(rr."nameLower"), ''), LOWER(rr.name)) = 'submitter' @@ -31,11 +53,16 @@ registration_events AS MATERIALIZED ( ), 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 + re.member_id, + re.activity_at + FROM registration_events re + WHERE EXISTS ( + SELECT 1 + FROM reviews.submission s + WHERE s."challengeId" = re.challenge_id + AND s."memberId" = re.member_id + AND s.status <> 'DELETED' + ) ), selected_registrations AS ( SELECT diff --git a/sql/reports/dashboard/member-payment-by-customer.sql b/sql/reports/dashboard/member-payment-by-customer.sql new file mode 100644 index 0000000..d619b60 --- /dev/null +++ b/sql/reports/dashboard/member-payment-by-customer.sql @@ -0,0 +1,149 @@ +-- Monthly member-payment values split by the selected range's top five clients. +-- +-- Parameters: +-- $1 timestamptz - inclusive reporting range start +-- $2 timestamptz - exclusive reporting range end +-- +-- The same ranked customer series is used for every month. The latest +-- non-cancelled payment record is counted in its creation month so projected +-- payments remain visible. Payments for unranked or unnamed clients are +-- grouped under Other Customers. +-- Billing-account ids are normalized and compared as text so the historical +-- zero sentinel falls back to challenge billing without unsafe integer casts. +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 +), +latest_payment_versions AS MATERIALIZED ( + SELECT + p.winnings_id, + MAX(p.version) AS max_version + FROM finance.payment p + GROUP BY p.winnings_id +), +payment_events AS MATERIALIZED ( + SELECT + p.created_at AS activity_at, + COALESCE(p.gross_amount, p.total_amount, 0) AS amount, + NULLIF(TRIM(cl.id), '') AS customer_id, + NULLIF(TRIM(cl.name), '') AS customer_label + FROM finance.payment p + JOIN latest_payment_versions lpv + ON lpv.winnings_id = p.winnings_id + AND lpv.max_version = p.version + JOIN finance.winnings w + ON w.winning_id = p.winnings_id + LEFT JOIN challenges."Challenge" c + ON c.id = w.external_id + LEFT JOIN challenges."ChallengeBilling" cb + ON cb."challengeId" = c.id + LEFT JOIN "billing-accounts"."BillingAccount" payment_ba + ON payment_ba.id::text = NULLIF( + TRIM(LEADING '0' FROM TRIM(p.billing_account)), + '' + ) + LEFT JOIN "billing-accounts"."BillingAccount" challenge_ba + ON challenge_ba.id::text = NULLIF( + TRIM(LEADING '0' FROM TRIM(cb."billingAccountId")), + '' + ) + LEFT JOIN "billing-accounts"."Client" cl + ON cl.id = COALESCE(payment_ba."clientId", challenge_ba."clientId") + WHERE p.payment_status IS DISTINCT FROM 'CANCELLED' + AND w.type = 'PAYMENT' + AND 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_events AS ( + SELECT pe.* + FROM payment_events pe + CROSS JOIN bounds b + WHERE pe.activity_at >= b.start_at + AND pe.activity_at < b.end_at +), +customer_totals AS ( + SELECT + se.customer_id, + se.customer_label, + SUM(se.amount) AS total_amount + FROM selected_events se + WHERE se.customer_id IS NOT NULL + AND se.customer_label IS NOT NULL + GROUP BY se.customer_id, se.customer_label +), +ranked_customers AS ( + SELECT + ct.customer_id, + ct.customer_label, + ROW_NUMBER() OVER ( + ORDER BY + ct.total_amount DESC, + LOWER(ct.customer_label), + ct.customer_label, + ct.customer_id + ) AS series_order + FROM customer_totals ct +), +top_customers AS ( + SELECT + rc.customer_id, + rc.customer_label, + rc.series_order + FROM ranked_customers rc + WHERE rc.series_order <= 5 +), +series AS ( + SELECT + 'customer-' || tc.customer_id AS series_key, + tc.customer_id, + tc.customer_label, + tc.series_order + FROM top_customers tc + + UNION ALL + + SELECT + 'other-customers' AS series_key, + NULL::text AS customer_id, + 'Other Customers' AS customer_label, + 6 AS series_order +), +monthly_amounts AS ( + SELECT + DATE_TRUNC('month', se.activity_at) AS month_start, + COALESCE( + 'customer-' || tc.customer_id, + 'other-customers' + ) AS series_key, + SUM(se.amount) AS amount + FROM selected_events se + LEFT JOIN top_customers tc + ON tc.customer_id = se.customer_id + AND tc.customer_label = se.customer_label + GROUP BY + DATE_TRUNC('month', se.activity_at), + COALESCE('customer-' || tc.customer_id, 'other-customers') +) +SELECT + TO_CHAR(m.month_start, 'YYYY-MM-01') AS month, + s.series_key, + s.customer_id, + s.customer_label, + s.series_order, + COALESCE(ma.amount, 0) AS amount +FROM months m +CROSS JOIN series s +LEFT JOIN monthly_amounts ma + ON ma.month_start = m.month_start + AND ma.series_key = s.series_key +ORDER BY m.month_start, s.series_order; diff --git a/sql/reports/dashboard/member-payment-by-month.sql b/sql/reports/dashboard/member-payment-by-month.sql new file mode 100644 index 0000000..97a4c3f --- /dev/null +++ b/sql/reports/dashboard/member-payment-by-month.sql @@ -0,0 +1,88 @@ +-- Monthly member-payment values split by canonical payment bucket. +-- +-- Parameters: +-- $1 timestamptz - inclusive reporting range start +-- $2 timestamptz - exclusive reporting range end +-- +-- The latest non-cancelled payment record is counted in its creation month so +-- projected payments remain visible. Gross amount is the preferred +-- member-payment value, with total amount used as a fallback. +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 +), +latest_payment_versions AS MATERIALIZED ( + SELECT + p.winnings_id, + MAX(p.version) AS max_version + FROM finance.payment p + GROUP BY p.winnings_id +), +payment_events AS MATERIALIZED ( + SELECT + p.created_at AS activity_at, + COALESCE(p.gross_amount, p.total_amount, 0) AS amount, + 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 latest_payment_versions lpv + ON lpv.winnings_id = p.winnings_id + AND lpv.max_version = p.version + JOIN finance.winnings w + ON w.winning_id = p.winnings_id + WHERE p.payment_status IS DISTINCT FROM 'CANCELLED' + AND w.type = 'PAYMENT' + AND 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.activity_at) AS month_start, + COALESCE(SUM(pe.amount) FILTER ( + WHERE pe.payment_type = 'taas' + ), 0) AS taas, + COALESCE(SUM(pe.amount) FILTER ( + WHERE pe.payment_type = 'task' + ), 0) AS task, + COALESCE(SUM(pe.amount) FILTER ( + WHERE pe.payment_type = 'challenge' + ), 0) AS challenge, + COALESCE(SUM(pe.amount) FILTER ( + WHERE pe.payment_type = 'engagement' + ), 0) AS engagement + FROM payment_events pe + CROSS JOIN bounds b + WHERE pe.activity_at >= b.start_at + AND pe.activity_at < b.end_at + GROUP BY DATE_TRUNC('month', pe.activity_at) +) +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 +FROM months m +LEFT JOIN selected_months sm + ON sm.month_start = m.month_start +ORDER BY m.month_start; diff --git a/sql/reports/dashboard/members-paid.sql b/sql/reports/dashboard/members-paid.sql index a37aef9..9ddcd20 100644 --- a/sql/reports/dashboard/members-paid.sql +++ b/sql/reports/dashboard/members-paid.sql @@ -4,11 +4,10 @@ -- $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. +-- The report is a financial projection, so the latest non-cancelled payment +-- record is counted in its creation month even when payout is still on hold or +-- owed. 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, @@ -22,10 +21,17 @@ months AS ( ) AS month_start FROM bounds b ), -paid_events AS MATERIALIZED ( +latest_payment_versions AS MATERIALIZED ( + SELECT + p.winnings_id, + MAX(p.version) AS max_version + FROM finance.payment p + GROUP BY p.winnings_id +), +payment_events AS MATERIALIZED ( SELECT NULLIF(TRIM(w.winner_id), '') AS member_id, - COALESCE(p.date_paid, p.created_at) AS paid_at, + p.created_at AS activity_at, CASE WHEN w.category::text = 'TAAS_PAYMENT' THEN 'taas' WHEN w.category::text = 'ENGAGEMENT_PAYMENT' THEN 'engagement' @@ -39,17 +45,20 @@ paid_events AS MATERIALIZED ( ELSE 'challenge' END AS payment_type FROM finance.payment p + JOIN latest_payment_versions lpv + ON lpv.winnings_id = p.winnings_id + AND lpv.max_version = p.version JOIN finance.winnings w ON w.winning_id = p.winnings_id - WHERE p.payment_status = 'PAID' + WHERE p.payment_status IS DISTINCT FROM 'CANCELLED' AND w.type = 'PAYMENT' - AND COALESCE(p.date_paid, p.created_at) IS NOT NULL + AND 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, + DATE_TRUNC('month', pe.activity_at) AS month_start, COUNT(DISTINCT pe.member_id) FILTER ( WHERE pe.payment_type = 'taas' ) AS taas, @@ -62,18 +71,18 @@ selected_months AS ( COUNT(DISTINCT pe.member_id) FILTER ( WHERE pe.payment_type = 'engagement' ) AS engagement - FROM paid_events pe + FROM payment_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) + WHERE pe.activity_at >= b.start_at + AND pe.activity_at < b.end_at + GROUP BY DATE_TRUNC('month', pe.activity_at) ), all_time_months AS ( SELECT - DATE_TRUNC('month', pe.paid_at) AS month_start, + DATE_TRUNC('month', pe.activity_at) AS month_start, COUNT(DISTINCT pe.member_id) AS unique_members - FROM paid_events pe - GROUP BY DATE_TRUNC('month', pe.paid_at) + FROM payment_events pe + GROUP BY DATE_TRUNC('month', pe.activity_at) ), peak_month AS ( SELECT @@ -98,7 +107,7 @@ all_time_summary AS ( COUNT(DISTINCT pe.member_id) FILTER ( WHERE pe.payment_type = 'engagement' ) AS engagement_unique_members - FROM paid_events pe + FROM payment_events pe ) SELECT TO_CHAR(m.month_start, 'YYYY-MM-01') AS month, diff --git a/sql/reports/topcoder/leaderboard-generic.sql b/sql/reports/topcoder/leaderboard-generic.sql new file mode 100644 index 0000000..6563e27 --- /dev/null +++ b/sql/reports/topcoder/leaderboard-generic.sql @@ -0,0 +1,220 @@ +WITH challenge_context AS ( + SELECT + c.id AS challenge_id, + c.name AS challenge_name, + COALESCE( + cp."actualStartDate", + cp."scheduledStartDate" + ) AS start_date, + COALESCE( + cp."actualEndDate", + cp."scheduledEndDate" + ) AS end_date, + GREATEST( + 1, + CEIL( + EXTRACT(EPOCH FROM COALESCE(cp."actualEndDate", cp."scheduledEndDate") - COALESCE(cp."actualStartDate", cp."scheduledStartDate")) + / 86400.0 + ) + ) AS duration_days + FROM challenges."Challenge" AS c + JOIN challenges."ChallengePhase" AS cp + ON cp."challengeId" = c.id + AND cp.name = 'Submission' + WHERE c.id = ANY($1::text[]) +), +member_submissions AS ( + SELECT + cc.challenge_id, + s.id AS submission_id, + s."memberId" AS user_id, + COALESCE( + NULLIF(TRIM(u.handle), ''), + NULLIF(TRIM(mem.handle), ''), + fallback.member_handle + ) AS handle, + COALESCE(NULLIF(TRIM(mem."firstName"), ''), NULLIF(TRIM(u.handle), ''), NULLIF(TRIM(mem.handle), '')) AS name, + COALESCE( + home_code.name, + home_id.name, + comp_code.name, + comp_id.name, + NULLIF(TRIM(mem."competitionCountryCode"), ''), + NULLIF(TRIM(mem."homeCountryCode"), '') + ) AS country, + COALESCE( + NULLIF(TRIM(mem."competitionCountryCode"), ''), + NULLIF(TRIM(mem."homeCountryCode"), '') + ) AS country_code, + mem."photoURL" AS photoURL, + mmr.rating AS rating, + mmr."ratingColor" AS ratingColor, + COALESCE( + CASE + WHEN challenge_reviewers.is_ai_only_challenge + THEN ai_decision."totalScore" + ELSE final_review."aggregateScore" + END, + s."finalScore"::double precision, + s."initialScore"::double precision + ) AS score, + COALESCE(s."submittedDate", s."createdAt") AS submitted_date, + cc.duration_days + FROM challenge_context AS cc + JOIN reviews."submission" AS s + ON s."challengeId" = cc.challenge_id + AND s."memberId" IS NOT NULL + LEFT JOIN LATERAL ( + SELECT rs."aggregateScore", rs."scorecardId" + FROM reviews."reviewSummation" AS rs + WHERE rs."submissionId" = s.id + AND COALESCE(rs."isFinal", TRUE) = TRUE + AND rs."isProvisional" IS DISTINCT FROM TRUE + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC + LIMIT 1 + ) AS final_review ON TRUE + LEFT JOIN LATERAL ( + SELECT + COUNT(*) > 0 AS has_reviewers, + BOOL_AND(cr."aiWorkflowId" IS NOT NULL AND cr."isMemberReview" = FALSE) AND COUNT(*) > 0 AS is_ai_only_challenge + FROM challenges."ChallengeReviewer" AS cr + WHERE cr."challengeId" = cc.challenge_id + ) AS challenge_reviewers ON TRUE + LEFT JOIN LATERAL ( + SELECT d."totalScore", d.status + FROM reviews."aiReviewDecision" AS d + WHERE d."submissionId" = s.id + AND UPPER(d.status::text) != 'PENDING' + ORDER BY d."updatedAt" DESC NULLS LAST + LIMIT 1 + ) AS ai_decision ON TRUE + LEFT JOIN reviews.scorecard AS sc + ON sc.id = final_review."scorecardId" + LEFT JOIN members."member" AS mem + ON mem."userId" = s."memberId"::bigint + LEFT JOIN LATERAL ( + SELECT DISTINCT ON (mmr."userId") + mmr.rating, + mmr."ratingColor" + FROM members."memberMaxRating" AS mmr + WHERE mmr."userId" = s."memberId"::bigint + ORDER BY mmr."userId", mmr.rating DESC + ) AS mmr ON TRUE + LEFT JOIN identity."user" AS u + ON s."memberId" ~ '^[0-9]+$' + AND u.user_id = s."memberId"::numeric + LEFT JOIN LATERAL ( + SELECT MAX(r."memberHandle") AS member_handle + FROM resources."Resource" AS r + WHERE r."challengeId" = cc.challenge_id + AND r."memberId" = s."memberId" + ) AS fallback ON TRUE + LEFT JOIN lookups."Country" AS home_code + ON UPPER(home_code."countryCode") = UPPER(mem."homeCountryCode") + LEFT JOIN lookups."Country" AS home_id + ON UPPER(home_id.id) = UPPER(mem."homeCountryCode") + LEFT JOIN lookups."Country" AS comp_code + ON UPPER(comp_code."countryCode") = UPPER(mem."competitionCountryCode") + LEFT JOIN lookups."Country" AS comp_id + ON UPPER(comp_id.id) = UPPER(mem."competitionCountryCode") + WHERE COALESCE( + CASE + WHEN challenge_reviewers.is_ai_only_challenge + THEN ai_decision."totalScore" + ELSE final_review."aggregateScore" + END, + s."finalScore"::double precision, + s."initialScore"::double precision + ) IS NOT NULL + AND ( + (challenge_reviewers.is_ai_only_challenge + AND UPPER(ai_decision.status::text) = 'PASSED') + OR ( + NOT challenge_reviewers.is_ai_only_challenge + AND COALESCE(final_review."aggregateScore", s."finalScore"::double precision, s."initialScore"::double precision) + >= COALESCE(sc."minimumPassingScore", sc."minScore", 0) + ) + ) +), +unique_member_submissions AS ( + SELECT DISTINCT ON (challenge_id, user_id) + ms.* + FROM member_submissions AS ms + ORDER BY + ms.challenge_id, + ms.user_id, + ms.score DESC NULLS LAST, + ms.submitted_date DESC NULLS LAST, + ms.submission_id DESC +), +challenge_prizes AS ( + SELECT + cps."challengeId" AS challenge_id, + ROW_NUMBER() OVER (PARTITION BY cps."challengeId" ORDER BY p.id) AS placement, + p.value + FROM challenges."ChallengePrizeSet" AS cps + JOIN challenges."Prize" AS p + ON p."prizeSetId" = cps.id + WHERE cps.type = 'PLACEMENT' +), +challenge_summary AS ( + SELECT + cc.challenge_id, + cc.challenge_name, + cc.duration_days, + COALESCE(SUM(pr.value), 0) AS prize_pool, + ( + SELECT COUNT(DISTINCT ums.user_id) + FROM unique_member_submissions AS ums + WHERE ums.challenge_id = cc.challenge_id + ) AS submissions_count, + JSONB_AGG( + JSONB_BUILD_OBJECT('placement', pr.placement, 'value', pr.value) ORDER BY pr.placement + ) FILTER (WHERE pr.value IS NOT NULL) AS placement_prizes + FROM challenge_context AS cc + LEFT JOIN challenge_prizes AS pr + ON pr.challenge_id = cc.challenge_id + GROUP BY cc.challenge_id, cc.challenge_name, cc.duration_days +) +SELECT + ums.challenge_id AS "challengeId", + cs.challenge_name AS "challengeName", + cs.duration_days AS "durationDays", + cs.prize_pool AS "prizePool", + cs.submissions_count AS "submissionsCount", + cs.placement_prizes AS "placementPrizes", + ums.user_id AS "userId", + ums.handle AS handle, + ums.name AS name, + COALESCE( + home_code.name, + home_id.name, + comp_code.name, + comp_id.name, + NULLIF(TRIM(ums.country), '') + ) AS country, + COALESCE( + NULLIF(TRIM(ums.country_code), ''), + NULLIF(TRIM(ums.country_code), '') + ) AS "countryCode", + ums.photoURL AS "photoURL", + ums.rating AS rating, + ums.ratingColor AS "ratingColor", + ums.score AS score, + ums.submitted_date AS submitted_date, + ROW_NUMBER() OVER ( + PARTITION BY ums.challenge_id + ORDER BY ums.score DESC NULLS LAST, ums.submitted_date ASC NULLS LAST, ums.user_id ASC + ) AS placement +FROM unique_member_submissions AS ums +LEFT JOIN lookups."Country" AS home_code + ON UPPER(home_code."countryCode") = UPPER(ums.country_code) +LEFT JOIN lookups."Country" AS home_id + ON UPPER(home_id.id) = UPPER(ums.country_code) +LEFT JOIN lookups."Country" AS comp_code + ON UPPER(comp_code."countryCode") = UPPER(ums.country_code) +LEFT JOIN lookups."Country" AS comp_id + ON UPPER(comp_id.id) = UPPER(ums.country_code) +JOIN challenge_summary AS cs + ON cs.challenge_id = ums.challenge_id +ORDER BY ums.challenge_id, ums.score DESC NULLS LAST, ums.submitted_date ASC NULLS LAST, ums.user_id ASC; diff --git a/sql/reports/topcoder/leaderboard-mm.sql b/sql/reports/topcoder/leaderboard-mm.sql new file mode 100644 index 0000000..6f477af --- /dev/null +++ b/sql/reports/topcoder/leaderboard-mm.sql @@ -0,0 +1,97 @@ +WITH challenge_context AS ( + SELECT + c.id AS challenge_id, + c.name AS challenge_name, + LOWER(ct.name) = 'marathon match' AS is_marathon_match + FROM challenges."Challenge" AS c + JOIN challenges."ChallengeType" AS ct + ON ct.id = c."typeId" + WHERE c.id = ANY($1::text[]) +), +submission_metrics AS ( + SELECT + cc.challenge_id, + s.id AS submission_id, + s."memberId" AS user_id, + COALESCE( + NULLIF(TRIM(u.handle), ''), + NULLIF(TRIM(mem.handle), ''), + fallback.member_handle + ) AS handle, + COALESCE(final_review."aggregateScore", s."finalScore"::double precision) AS standard_score, + provisional_review.provisional_score, + COALESCE(final_review."aggregateScore", s."finalScore"::double precision) AS final_score_raw, + COALESCE(s."submittedDate", s."createdAt") AS submitted_date + FROM challenge_context AS cc + JOIN reviews."submission" AS s + ON s."challengeId" = cc.challenge_id + AND s."memberId" IS NOT NULL + LEFT JOIN LATERAL ( + SELECT rs."aggregateScore" + FROM reviews."reviewSummation" AS rs + WHERE rs."submissionId" = s.id + AND COALESCE(rs."isFinal", TRUE) = TRUE + AND rs."isProvisional" IS DISTINCT FROM TRUE + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC + LIMIT 1 + ) AS final_review ON TRUE + LEFT JOIN LATERAL ( + SELECT 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 + LEFT JOIN members."member" AS mem + ON mem."userId" = s."memberId"::bigint + LEFT JOIN identity."user" AS u + ON s."memberId" ~ '^[0-9]+$' + AND u.user_id = s."memberId"::numeric + LEFT JOIN LATERAL ( + SELECT MAX(r."memberHandle") AS member_handle + FROM resources."Resource" AS r + WHERE r."challengeId" = cc.challenge_id + AND r."memberId" = s."memberId" + ) AS fallback ON TRUE + WHERE COALESCE(final_review."aggregateScore", s."finalScore"::double precision, s."initialScore"::double precision) IS NOT NULL +), +unique_member_submissions AS ( + SELECT DISTINCT ON (challenge_id, user_id) + sm.* + FROM submission_metrics AS sm + ORDER BY + sm.challenge_id, + sm.user_id, + sm.submitted_date DESC NULLS LAST, + sm.submission_id DESC +), +ranked_submissions AS ( + SELECT + ums.challenge_id, + ums.user_id AS "userId", + ums.submission_id AS "submissionId", + ums.handle AS handle, + ROW_NUMBER() OVER ( + PARTITION BY ums.challenge_id + ORDER BY ums.final_score_raw DESC NULLS LAST, ums.submitted_date ASC NULLS LAST, ums.user_id ASC + ) AS placement, + ums.provisional_score AS "provisionalScore", + ums.final_score_raw AS "finalScore", + ums.standard_score AS score + FROM unique_member_submissions AS ums +) +SELECT + cc.challenge_id AS "challengeId", + cc.challenge_name AS "challengeName", + rs."userId", + rs."submissionId", + rs.handle, + rs.placement, + rs."provisionalScore", + rs."finalScore", + rs.score +FROM ranked_submissions AS rs +JOIN challenge_context AS cc + ON cc.challenge_id = rs.challenge_id +ORDER BY rs.challenge_id, rs.placement; diff --git a/src/app-constants.ts b/src/app-constants.ts index c1bab63..72ca01d 100644 --- a/src/app-constants.ts +++ b/src/app-constants.ts @@ -6,6 +6,7 @@ export const Scopes = { TopgearCancelledChallenge: "reports:topgear-cancelled-challenge", AllReports: "reports:all", TopcoderReports: "reports:topcoder", + TopcoderLeaderboardReports: "reports:topcoder-leaderboard", Member: { EngagementData: "reports:member-engagement-data", RecentMemberData: "reports:member-recent-member-data", diff --git a/src/reports/dashboard/dashboard-reports.controller.spec.ts b/src/reports/dashboard/dashboard-reports.controller.spec.ts index 1831e7a..b634edc 100644 --- a/src/reports/dashboard/dashboard-reports.controller.spec.ts +++ b/src/reports/dashboard/dashboard-reports.controller.spec.ts @@ -2,6 +2,7 @@ import { CsvSerializer } from "../../common/csv/csv-serializer"; import { DashboardExportRowDto, DashboardSlug, + MemberPaymentByCustomerDashboardDto, NewSignupsDashboardDto, } from "./dashboard-reports.dto"; import { DashboardReportsController } from "./dashboard-reports.controller"; @@ -26,6 +27,32 @@ const newSignupsDashboard: NewSignupsDashboardDto = { }, }; +const memberPaymentByCustomerDashboard: MemberPaymentByCustomerDashboardDto = { + dashboard: DashboardSlug.MemberPaymentByCustomer, + ...query, + series: [ + { + key: "customer-client-a", + label: "Customer A", + customerId: "client-a", + }, + { + key: "other-customers", + label: "Other Customers", + customerId: null, + }, + ], + months: [ + { + month: "2026-02-01", + values: { + "customer-client-a": 5000, + "other-customers": 1000, + }, + }, + ], +}; + describe("DashboardReportsController", () => { let controller: DashboardReportsController; let service: { @@ -53,6 +80,8 @@ describe("DashboardReportsController", () => { newSignups: newSignupsDashboard, membersPaid: {}, challengeParticipation: {}, + memberPaymentByMonth: {}, + memberPaymentByCustomer: memberPaymentByCustomerDashboard, }; service.getAllDashboards.mockResolvedValue(response); @@ -61,13 +90,13 @@ describe("DashboardReportsController", () => { }); it("delegates a selected detail dashboard", async () => { - service.getDashboard.mockResolvedValue(newSignupsDashboard); + service.getDashboard.mockResolvedValue(memberPaymentByCustomerDashboard); await expect( - controller.getDashboard(DashboardSlug.NewSignups, query), - ).resolves.toBe(newSignupsDashboard); + controller.getDashboard(DashboardSlug.MemberPaymentByCustomer, query), + ).resolves.toBe(memberPaymentByCustomerDashboard); expect(service.getDashboard).toHaveBeenCalledWith( - DashboardSlug.NewSignups, + DashboardSlug.MemberPaymentByCustomer, query, ); }); @@ -88,14 +117,30 @@ describe("DashboardReportsController", () => { challenge: 5, engagement: 2, }, + { + dashboard: DashboardSlug.MemberPaymentByMonth, + month: "2026-02-01", + taas: 1000, + task: 2000, + challenge: 3000, + engagement: 4000, + }, + { + dashboard: DashboardSlug.MemberPaymentByCustomer, + month: "2026-02-01", + customer: "Customer A", + amount: 5000, + }, ]; 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", + "dashboard,month,activated,notActivated,taas,task,challenge,engagement,customer,amount", + "new-signups,2026-02-01,10,2,,,,,,", + "members-paid,2026-02-01,,,3,4,5,2,,", + "member-payment-by-month,2026-02-01,,,1000,2000,3000,4000,,", + "member-payment-by-customer,2026-02-01,,,,,,,Customer A,5000", ].join("\n"), ); expect(service.exportAllDashboards).toHaveBeenCalledWith(query); @@ -104,21 +149,28 @@ describe("DashboardReportsController", () => { it("serializes one selected dashboard as CSV", async () => { service.exportDashboard.mockResolvedValue([ { - dashboard: DashboardSlug.ChallengeParticipation, + dashboard: DashboardSlug.MemberPaymentByCustomer, + month: "2026-02-01", + customer: "Customer A", + amount: 5000, + }, + { + dashboard: DashboardSlug.MemberPaymentByCustomer, month: "2026-02-01", - registrants: 12, - submitters: 9, + customer: "Other Customers", + amount: 1000, }, ]); await expect( - controller.exportDashboard(DashboardSlug.ChallengeParticipation, query), + controller.exportDashboard(DashboardSlug.MemberPaymentByCustomer, query), ).resolves.toBe( - "dashboard,month,registrants,submitters\n" + - "challenge-participation,2026-02-01,12,9", + "dashboard,month,customer,amount\n" + + "member-payment-by-customer,2026-02-01,Customer A,5000\n" + + "member-payment-by-customer,2026-02-01,Other Customers,1000", ); expect(service.exportDashboard).toHaveBeenCalledWith( - DashboardSlug.ChallengeParticipation, + DashboardSlug.MemberPaymentByCustomer, query, ); }); diff --git a/src/reports/dashboard/dashboard-reports.controller.ts b/src/reports/dashboard/dashboard-reports.controller.ts index db042a2..f5223d3 100644 --- a/src/reports/dashboard/dashboard-reports.controller.ts +++ b/src/reports/dashboard/dashboard-reports.controller.ts @@ -27,6 +27,8 @@ import { DashboardQueryDto, DashboardResponse, DashboardSlug, + MemberPaymentByCustomerDashboardDto, + MemberPaymentByMonthDashboardDto, MembersPaidDashboardDto, NewSignupsDashboardDto, } from "./dashboard-reports.dto"; @@ -42,6 +44,8 @@ import { DashboardReportsGuard } from "./guards/dashboard-reports.guard"; NewSignupsDashboardDto, MembersPaidDashboardDto, ChallengeParticipationDashboardDto, + MemberPaymentByMonthDashboardDto, + MemberPaymentByCustomerDashboardDto, ) @ApiUnauthorizedResponse({ description: "Unauthenticated." }) @ApiForbiddenResponse({ @@ -66,14 +70,14 @@ export class DashboardReportsController { * Retrieves all dashboards for the landing page. * * @param query Optional half-open reporting range. - * @returns All three complete dashboard responses. + * @returns All five 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.", + "Returns monthly data for new signups, unique paid members, challenge participation, member-payment values by type, and member-payment values by customer. The default range is the latest six UTC calendar months; summary metrics on the original dashboards are all-time.", }) @ApiOkResponse({ type: AllDashboardsDto }) @ApiBadRequestResponse({ description: "Invalid reporting date range." }) @@ -94,7 +98,7 @@ export class DashboardReportsController { @ApiOperation({ summary: "Export all dashboard month data as CSV" }) @ApiProduces("text/csv") @ApiOkResponse({ - description: "Flat CSV rows for all three dashboards.", + description: "Flat CSV rows for all five dashboards.", type: String, }) @ApiBadRequestResponse({ description: "Invalid reporting date range." }) @@ -154,6 +158,8 @@ export class DashboardReportsController { { $ref: getSchemaPath(NewSignupsDashboardDto) }, { $ref: getSchemaPath(MembersPaidDashboardDto) }, { $ref: getSchemaPath(ChallengeParticipationDashboardDto) }, + { $ref: getSchemaPath(MemberPaymentByMonthDashboardDto) }, + { $ref: getSchemaPath(MemberPaymentByCustomerDashboardDto) }, ], }, }) diff --git a/src/reports/dashboard/dashboard-reports.dto.ts b/src/reports/dashboard/dashboard-reports.dto.ts index 4870101..c8b1969 100644 --- a/src/reports/dashboard/dashboard-reports.dto.ts +++ b/src/reports/dashboard/dashboard-reports.dto.ts @@ -8,6 +8,8 @@ export enum DashboardSlug { NewSignups = "new-signups", MembersPaid = "members-paid", ChallengeParticipation = "challenge-participation", + MemberPaymentByMonth = "member-payment-by-month", + MemberPaymentByCustomer = "member-payment-by-customer", } /** @@ -242,7 +244,116 @@ export class ChallengeParticipationDashboardDto { } /** - * Aggregate landing-page response containing all three full dashboards. + * One month of member-payment values split by canonical payment type. + */ +export class MemberPaymentMonthDto { + @ApiProperty({ example: "2026-02-01" }) + month: string; + + @ApiProperty({ example: 185250.5 }) + taas: number; + + @ApiProperty({ example: 92340 }) + task: number; + + @ApiProperty({ example: 246100.75 }) + challenge: number; + + @ApiProperty({ example: 63400 }) + engagement: number; +} + +/** + * Monthly member-payment values split by canonical payment type. + */ +export class MemberPaymentByMonthDashboardDto { + @ApiProperty({ + enum: [DashboardSlug.MemberPaymentByMonth], + example: DashboardSlug.MemberPaymentByMonth, + }) + dashboard: DashboardSlug.MemberPaymentByMonth; + + @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: [MemberPaymentMonthDto] }) + months: MemberPaymentMonthDto[]; +} + +/** + * One customer series used by the monthly member-payment chart. + */ +export class MemberPaymentCustomerSeriesDto { + @ApiProperty({ + description: "Stable API-generated key used in each month's values map.", + example: "customer-client-123", + }) + key: string; + + @ApiProperty({ example: "Example Customer" }) + label: string; + + @ApiProperty({ + nullable: true, + description: + "Billing-account client identifier, or null for Other Customers.", + example: "client-123", + }) + customerId: string | null; +} + +/** + * One month of member-payment values keyed by customer-series key. + */ +export class MemberPaymentByCustomerMonthDto { + @ApiProperty({ example: "2026-02-01" }) + month: string; + + @ApiProperty({ + type: "object", + additionalProperties: { type: "number" }, + example: { + "customer-client-123": 185250.5, + "other-customers": 92340, + }, + }) + values: Record; +} + +/** + * Monthly member-payment values split by the range's top customers. + */ +export class MemberPaymentByCustomerDashboardDto { + @ApiProperty({ + enum: [DashboardSlug.MemberPaymentByCustomer], + example: DashboardSlug.MemberPaymentByCustomer, + }) + dashboard: DashboardSlug.MemberPaymentByCustomer; + + @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: [MemberPaymentCustomerSeriesDto] }) + series: MemberPaymentCustomerSeriesDto[]; + + @ApiProperty({ type: [MemberPaymentByCustomerMonthDto] }) + months: MemberPaymentByCustomerMonthDto[]; +} + +/** + * Aggregate landing-page response containing all five full dashboards. */ export class AllDashboardsDto { @ApiProperty({ type: NewSignupsDashboardDto }) @@ -253,6 +364,12 @@ export class AllDashboardsDto { @ApiProperty({ type: ChallengeParticipationDashboardDto }) challengeParticipation: ChallengeParticipationDashboardDto; + + @ApiProperty({ type: MemberPaymentByMonthDashboardDto }) + memberPaymentByMonth: MemberPaymentByMonthDashboardDto; + + @ApiProperty({ type: MemberPaymentByCustomerDashboardDto }) + memberPaymentByCustomer: MemberPaymentByCustomerDashboardDto; } /** @@ -291,6 +408,12 @@ export class DashboardExportRowDto { @ApiPropertyOptional() submitters?: number; + + @ApiPropertyOptional() + customer?: string; + + @ApiPropertyOptional() + amount?: number; } /** @@ -299,4 +422,6 @@ export class DashboardExportRowDto { export type DashboardResponse = | NewSignupsDashboardDto | MembersPaidDashboardDto - | ChallengeParticipationDashboardDto; + | ChallengeParticipationDashboardDto + | MemberPaymentByMonthDashboardDto + | MemberPaymentByCustomerDashboardDto; diff --git a/src/reports/dashboard/dashboard-reports.service.spec.ts b/src/reports/dashboard/dashboard-reports.service.spec.ts index 1a93220..9ce747b 100644 --- a/src/reports/dashboard/dashboard-reports.service.spec.ts +++ b/src/reports/dashboard/dashboard-reports.service.spec.ts @@ -4,6 +4,8 @@ import { DbService } from "../../db/db.service"; import { ChallengeParticipationDashboardDto, DashboardSlug, + MemberPaymentByCustomerDashboardDto, + MemberPaymentByMonthDashboardDto, MembersPaidDashboardDto, NewSignupsDashboardDto, } from "./dashboard-reports.dto"; @@ -72,6 +74,47 @@ const challengeParticipationRows = [ }, ]; +const memberPaymentByMonthRows = [ + { + month: "2026-02-01", + taas: "1250.50", + task: "2400", + challenge: "3750.25", + engagement: "900", + }, + { + month: "2026-03-01", + taas: "1500", + task: "2100.75", + challenge: "4200", + engagement: "1100", + }, +]; + +const memberPaymentByCustomerRows = [ + { + month: "2026-02-01", + series_key: "customer-client-a", + customer_id: "client-a", + customer_label: "Customer A", + amount: "5000.25", + }, + { + month: "2026-02-01", + series_key: "other-customers", + customer_id: null, + customer_label: "Other Customers", + amount: "1200", + }, + { + month: "2026-03-01", + series_key: "customer-client-a", + customer_id: "client-a", + customer_label: "Customer A", + amount: "6200", + }, +]; + describe("resolveDashboardDateRange", () => { it("defaults to the latest six UTC calendar months", () => { expect( @@ -145,6 +188,10 @@ describe("DashboardReportsService", () => { return Promise.resolve(membersPaidRows); case "reports/dashboard/challenge-participation.sql": return Promise.resolve(challengeParticipationRows); + case "reports/dashboard/member-payment-by-month.sql": + return Promise.resolve(memberPaymentByMonthRows); + case "reports/dashboard/member-payment-by-customer.sql": + return Promise.resolve(memberPaymentByCustomerRows); default: return Promise.resolve([]); } @@ -222,14 +269,68 @@ describe("DashboardReportsService", () => { peakMonthRegistrants: 18, }, }, + memberPaymentByMonth: { + dashboard: DashboardSlug.MemberPaymentByMonth, + ...rangeQuery, + months: [ + { + month: "2026-02-01", + taas: 1250.5, + task: 2400, + challenge: 3750.25, + engagement: 900, + }, + { + month: "2026-03-01", + taas: 1500, + task: 2100.75, + challenge: 4200, + engagement: 1100, + }, + ], + }, + memberPaymentByCustomer: { + dashboard: DashboardSlug.MemberPaymentByCustomer, + ...rangeQuery, + series: [ + { + key: "customer-client-a", + label: "Customer A", + customerId: "client-a", + }, + { + key: "other-customers", + label: "Other Customers", + customerId: null, + }, + ], + months: [ + { + month: "2026-02-01", + values: { + "customer-client-a": 5000.25, + "other-customers": 1200, + }, + }, + { + month: "2026-03-01", + values: { + "customer-client-a": 6200, + "other-customers": 0, + }, + }, + ], + }, }); expect(sql.load.mock.calls).toEqual([ ["reports/dashboard/new-signups.sql"], ["reports/dashboard/members-paid.sql"], ["reports/dashboard/challenge-participation.sql"], + ["reports/dashboard/member-payment-by-month.sql"], + ["reports/dashboard/member-payment-by-customer.sql"], ]); - expect(db.query).toHaveBeenCalledTimes(3); + expect(db.query).toHaveBeenCalledTimes(5); expect(db.query).toHaveBeenCalledWith("reports/dashboard/new-signups.sql", [ rangeQuery.startDate, rangeQuery.endDate, @@ -238,13 +339,15 @@ describe("DashboardReportsService", () => { it("loads only the requested detail dashboard", async () => { const result = await service.getDashboard( - DashboardSlug.MembersPaid, + DashboardSlug.MemberPaymentByCustomer, rangeQuery, ); - expect(result.dashboard).toBe(DashboardSlug.MembersPaid); + expect(result.dashboard).toBe(DashboardSlug.MemberPaymentByCustomer); expect(sql.load).toHaveBeenCalledTimes(1); - expect(sql.load).toHaveBeenCalledWith("reports/dashboard/members-paid.sql"); + expect(sql.load).toHaveBeenCalledWith( + "reports/dashboard/member-payment-by-customer.sql", + ); }); it("rejects an unsupported dashboard discriminator", async () => { @@ -322,6 +425,44 @@ describe("DashboardReportsService", () => { peakMonthRegistrants: 12, }, } satisfies ChallengeParticipationDashboardDto, + memberPaymentByMonth: { + dashboard: DashboardSlug.MemberPaymentByMonth, + ...rangeQuery, + months: [ + { + month: "2026-02-01", + taas: 1000, + task: 2000, + challenge: 3000, + engagement: 4000, + }, + ], + } satisfies MemberPaymentByMonthDashboardDto, + memberPaymentByCustomer: { + dashboard: DashboardSlug.MemberPaymentByCustomer, + ...rangeQuery, + series: [ + { + key: "customer-client-a", + label: "Customer A", + customerId: "client-a", + }, + { + key: "other-customers", + label: "Other Customers", + customerId: null, + }, + ], + months: [ + { + month: "2026-02-01", + values: { + "customer-client-a": 6500, + "other-customers": 3500, + }, + }, + ], + } satisfies MemberPaymentByCustomerDashboardDto, }; jest.spyOn(service, "getAllDashboards").mockResolvedValue(dashboards); @@ -346,6 +487,26 @@ describe("DashboardReportsService", () => { registrants: 12, submitters: 9, }, + { + dashboard: DashboardSlug.MemberPaymentByMonth, + month: "2026-02-01", + taas: 1000, + task: 2000, + challenge: 3000, + engagement: 4000, + }, + { + dashboard: DashboardSlug.MemberPaymentByCustomer, + month: "2026-02-01", + customer: "Customer A", + amount: 6500, + }, + { + dashboard: DashboardSlug.MemberPaymentByCustomer, + month: "2026-02-01", + customer: "Other Customers", + amount: 3500, + }, ]); }); }); diff --git a/src/reports/dashboard/dashboard-reports.service.ts b/src/reports/dashboard/dashboard-reports.service.ts index 3df4b40..8932c60 100644 --- a/src/reports/dashboard/dashboard-reports.service.ts +++ b/src/reports/dashboard/dashboard-reports.service.ts @@ -8,6 +8,8 @@ import { DashboardQueryDto, DashboardResponse, DashboardSlug, + MemberPaymentByCustomerDashboardDto, + MemberPaymentByMonthDashboardDto, MembersPaidDashboardDto, NewSignupsDashboardDto, } from "./dashboard-reports.dto"; @@ -52,6 +54,22 @@ type ChallengeParticipationRow = { peak_month_registrants: QueryValue; }; +type MemberPaymentByMonthRow = { + month: string; + taas: QueryValue; + task: QueryValue; + challenge: QueryValue; + engagement: QueryValue; +}; + +type MemberPaymentByCustomerRow = { + month: string; + series_key: string; + customer_id: string | null; + customer_label: string; + amount: QueryValue; +}; + /** * Resolved half-open date range used by all dashboard SQL queries. */ @@ -185,7 +203,7 @@ export class DashboardReportsService { ) {} /** - * Loads all three dashboards for one shared reporting range. + * Loads all five dashboards for one shared reporting range. * * @param query Optional date range. * @returns Full dashboard objects keyed for the landing page. @@ -193,18 +211,26 @@ export class DashboardReportsService { */ 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), - ], - ); + const [ + newSignups, + membersPaid, + challengeParticipation, + memberPaymentByMonth, + memberPaymentByCustomer, + ] = await Promise.all([ + this.loadNewSignups(range), + this.loadMembersPaid(range), + this.loadChallengeParticipation(range), + this.loadMemberPaymentByMonth(range), + this.loadMemberPaymentByCustomer(range), + ]); return { newSignups, membersPaid, challengeParticipation, + memberPaymentByMonth, + memberPaymentByCustomer, }; } @@ -229,6 +255,10 @@ export class DashboardReportsService { return this.loadMembersPaid(range); case DashboardSlug.ChallengeParticipation: return this.loadChallengeParticipation(range); + case DashboardSlug.MemberPaymentByMonth: + return this.loadMemberPaymentByMonth(range); + case DashboardSlug.MemberPaymentByCustomer: + return this.loadMemberPaymentByCustomer(range); default: throw new BadRequestException("Unsupported dashboard."); } @@ -249,6 +279,8 @@ export class DashboardReportsService { ...this.toExportRows(dashboards.newSignups), ...this.toExportRows(dashboards.membersPaid), ...this.toExportRows(dashboards.challengeParticipation), + ...this.toExportRows(dashboards.memberPaymentByMonth), + ...this.toExportRows(dashboards.memberPaymentByCustomer), ]; } @@ -377,6 +409,87 @@ export class DashboardReportsService { }; } + /** + * Executes and maps the monthly member-payment value SQL. + * + * @param range Explicit half-open query range. + * @returns Monthly member-payment values split by canonical payment type. + */ + private async loadMemberPaymentByMonth( + range: DashboardDateRange, + ): Promise { + const query = this.sql.load( + "reports/dashboard/member-payment-by-month.sql", + ); + const rows = await this.db.query(query, [ + range.startDate, + range.endDate, + ]); + + return { + dashboard: DashboardSlug.MemberPaymentByMonth, + ...range, + months: rows.map((row) => ({ + month: row.month, + taas: toNumber(row.taas), + task: toNumber(row.task), + challenge: toNumber(row.challenge), + engagement: toNumber(row.engagement), + })), + }; + } + + /** + * Executes and maps the monthly member-payment-by-customer SQL. + * + * @param range Explicit half-open query range. + * @returns Stable customer series and zero-filled monthly value maps. + */ + private async loadMemberPaymentByCustomer( + range: DashboardDateRange, + ): Promise { + const query = this.sql.load( + "reports/dashboard/member-payment-by-customer.sql", + ); + const rows = await this.db.query(query, [ + range.startDate, + range.endDate, + ]); + const seriesByKey = new Map< + string, + MemberPaymentByCustomerDashboardDto["series"][number] + >(); + const monthValues = new Map>(); + + for (const row of rows) { + if (!seriesByKey.has(row.series_key)) { + seriesByKey.set(row.series_key, { + key: row.series_key, + label: row.customer_label, + customerId: row.customer_id, + }); + } + + const values = monthValues.get(row.month) ?? new Map(); + values.set(row.series_key, toNumber(row.amount)); + monthValues.set(row.month, values); + } + + const series = [...seriesByKey.values()]; + + return { + dashboard: DashboardSlug.MemberPaymentByCustomer, + ...range, + series, + months: [...monthValues].map(([month, values]) => ({ + month, + values: Object.fromEntries( + series.map(({ key }) => [key, values.get(key) ?? 0]), + ), + })), + }; + } + /** * Flattens a dashboard response into monthly CSV rows. * @@ -408,6 +521,24 @@ export class DashboardReportsService { registrants: month.registrants, submitters: month.submitters, })); + case DashboardSlug.MemberPaymentByMonth: + 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.MemberPaymentByCustomer: + return dashboard.months.flatMap((month) => + dashboard.series.map((series) => ({ + dashboard: dashboard.dashboard, + month: month.month, + customer: series.label, + amount: month.values[series.key] ?? 0, + })), + ); } } } diff --git a/src/reports/dashboard/dashboard-reports.sql.spec.ts b/src/reports/dashboard/dashboard-reports.sql.spec.ts index d8f31fa..a88c0ef 100644 --- a/src/reports/dashboard/dashboard-reports.sql.spec.ts +++ b/src/reports/dashboard/dashboard-reports.sql.spec.ts @@ -7,6 +7,8 @@ describe("Dashboard report SQL", () => { "new-signups.sql", "members-paid.sql", "challenge-participation.sql", + "member-payment-by-month.sql", + "member-payment-by-customer.sql", ])( "uses a half-open range and emits zero-filled calendar months: %s", (file) => { @@ -32,12 +34,16 @@ describe("Dashboard report SQL", () => { expect(sql).toContain("AS peak_month_signups"); }); - it("counts paid members once per canonical payment bucket and month", () => { + it("counts projected 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("MAX(p.version) AS max_version"); + expect(sql).toContain("lpv.max_version = p.version"); + expect(sql).toContain("p.payment_status IS DISTINCT FROM 'CANCELLED'"); expect(sql).toContain("w.type = 'PAYMENT'"); - expect(sql).toContain("COALESCE(p.date_paid, p.created_at)"); + expect(sql).toContain("p.created_at AS activity_at"); + expect(sql).not.toContain("p.payment_status = 'PAID'"); + expect(sql).not.toContain("p.date_paid"); expect(sql).toContain("w.category::text = 'TAAS_PAYMENT'"); expect(sql).toContain("w.category::text = 'ENGAGEMENT_PAYMENT'"); expect(sql).toContain("'TASK_REVIEW_PAYMENT'"); @@ -48,15 +54,85 @@ describe("Dashboard report SQL", () => { expect(sql).toMatch(/COUNT\(DISTINCT pe\.member_id\) FILTER/g); }); - it("counts registration and submission activity independently", () => { + it("sums projected member-payment values by canonical payment bucket", () => { + const sql = sqlLoader.load("reports/dashboard/member-payment-by-month.sql"); + + expect(sql).toContain("MAX(p.version) AS max_version"); + expect(sql).toContain("lpv.max_version = p.version"); + expect(sql).toContain("p.payment_status IS DISTINCT FROM 'CANCELLED'"); + expect(sql).toContain("w.type = 'PAYMENT'"); + expect(sql).toContain("p.created_at AS activity_at"); + expect(sql).not.toContain("p.payment_status = 'PAID'"); + expect(sql).not.toContain("p.date_paid"); + expect(sql).toContain( + "COALESCE(p.gross_amount, p.total_amount, 0) AS amount", + ); + 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( + "w.category::text IS DISTINCT FROM 'TOPGEAR_PAYMENT'", + ); + expect(sql).toMatch(/SUM\(pe\.amount\) FILTER/g); + }); + + it("ranks five clients once and zero-fills an Other Customers series", () => { + const sql = sqlLoader.load( + "reports/dashboard/member-payment-by-customer.sql", + ); + + expect(sql).toContain("MAX(p.version) AS max_version"); + expect(sql).toContain("p.payment_status IS DISTINCT FROM 'CANCELLED'"); + expect(sql).toContain("p.created_at AS activity_at"); + expect(sql).not.toContain("p.payment_status = 'PAID'"); + expect(sql).not.toContain("p.date_paid"); + expect(sql).toContain( + "COALESCE(p.gross_amount, p.total_amount, 0) AS amount", + ); + expect(sql).toContain('LEFT JOIN challenges."ChallengeBilling" cb'); + expect(sql).toContain( + 'LEFT JOIN "billing-accounts"."BillingAccount" payment_ba', + ); + expect(sql).toContain( + 'LEFT JOIN "billing-accounts"."BillingAccount" challenge_ba', + ); + expect(sql).toContain('LEFT JOIN "billing-accounts"."Client" cl'); + expect(sql).toContain("payment_ba.id::text"); + expect(sql).toContain("challenge_ba.id::text"); + expect(sql).toContain("TRIM(LEADING '0'"); + expect(sql).toContain( + 'COALESCE(payment_ba."clientId", challenge_ba."clientId")', + ); + expect(sql).not.toContain("::integer"); + expect(sql).toContain("ROW_NUMBER() OVER"); + expect(sql).toContain("ct.total_amount DESC"); + expect(sql).toContain("WHERE rc.series_order <= 5"); + expect(sql).toContain("'other-customers' AS series_key"); + expect(sql).toContain("'Other Customers' AS customer_label"); + expect(sql).toContain("CROSS JOIN series s"); + expect(sql).toContain("COALESCE(ma.amount, 0) AS amount"); + }); + + it("counts registrants and linked submitters by challenge completion cohort", () => { const sql = sqlLoader.load("reports/dashboard/challenge-participation.sql"); - expect(sql).toContain('FROM resources."Resource" r'); + expect(sql).toContain('FROM challenges."Challenge" c'); + expect(sql).toContain('JOIN challenges."ChallengeType" ct'); + expect(sql).toContain( + "ct.name IN ('Challenge', 'Marathon Match', 'First2Finish')", + ); + expect(sql).toContain('FROM challenges."ChallengePhase" cp'); + expect(sql).toContain('lp."actualEndDate" AS activity_at'); + expect(sql).toContain('ORDER BY cp."scheduledEndDate" DESC'); + expect(sql).toContain('JOIN 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."challengeId" = re.challenge_id'); + expect(sql).toContain('s."memberId" = re.member_id'); expect(sql).toContain("s.status <> 'DELETED'"); - expect(sql).toContain('COALESCE(s."submittedDate", s."createdAt")'); + expect(sql).not.toContain('r."createdAt" AS activity_at'); + expect(sql).not.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/topcoder/dto/leaderboard-generic.dto.ts b/src/reports/topcoder/dto/leaderboard-generic.dto.ts new file mode 100644 index 0000000..90f5320 --- /dev/null +++ b/src/reports/topcoder/dto/leaderboard-generic.dto.ts @@ -0,0 +1,42 @@ +import { Transform } from "class-transformer"; +import { IsArray, IsBoolean, IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator"; + +export class LeaderboardGenericQueryDto { + @Transform(({ value }) => { + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } + return value; + }) + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + challengeIds!: string[]; + + @Transform(({ value }) => (typeof value === "string" ? Number(value) : value)) + @IsOptional() + @IsNumber() + pointsPerDay?: number; + + @Transform(({ value }) => { + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } + return value; + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + placementPrizeAmounts?: string[]; + + @Transform(({ value }) => value === "true" || value === true) + @IsOptional() + @IsBoolean() + showHeadingAndSubtitle?: boolean; +} diff --git a/src/reports/topcoder/dto/leaderboard-mm.dto.ts b/src/reports/topcoder/dto/leaderboard-mm.dto.ts new file mode 100644 index 0000000..5543bbc --- /dev/null +++ b/src/reports/topcoder/dto/leaderboard-mm.dto.ts @@ -0,0 +1,51 @@ +import { Transform } from "class-transformer"; +import { IsArray, IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator"; + +export class LeaderboardMmQueryDto { + @Transform(({ value }) => { + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } + return value; + }) + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + challengeIds!: string[]; + + @Transform(({ value }) => { + if (typeof value === "string") { + return value + .split(",") + .map((item) => Number(item.trim())) + .filter((item) => Number.isFinite(item)); + } + return value; + }) + @IsOptional() + @IsArray() + @IsNumber({}, { each: true }) + placementPoints?: number[]; + + @Transform(({ value }) => (typeof value === "string" ? Number(value) : value)) + @IsOptional() + @IsNumber() + defaultPlacementPoints?: number; + + @Transform(({ value }) => { + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } + return value; + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + writersAndTesters?: string[]; +} diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index 9c476d4..20ebfad 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -9,6 +9,8 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { TopcoderReportsService } from "./topcoder-reports.service"; import { ChallengeSubmitterDataQueryDto } from "./dto/challenge-submitter-data.dto"; +import { LeaderboardGenericQueryDto } from "./dto/leaderboard-generic.dto"; +import { LeaderboardMmQueryDto } from "./dto/leaderboard-mm.dto"; import { RegistrantCountriesQueryDto } from "./dto/registrant-countries.dto"; import { RecentMemberDataQueryDto } from "./dto/recent-member-data.dto"; import { WeeklyMemberParticipationQueryDto } from "./dto/weekly-member-participation.dto"; @@ -52,6 +54,28 @@ export class TopcoderReportsController { return this.reports.getChallengeSubmitterData(challengeId); } + @Get("/topcoder/leaderboard/generic") + @RequiredScopes( + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.TopcoderLeaderboardReports, + ) + @ApiOperation({ summary: "Generic leaderboard report data" }) + getLeaderboardGeneric(@Query() query: LeaderboardGenericQueryDto) { + return this.reports.getLeaderboardGeneric(query); + } + + @Get("/topcoder/leaderboard/mm") + @RequiredScopes( + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.TopcoderLeaderboardReports, + ) + @ApiOperation({ summary: "Marathon Match leaderboard report data" }) + getLeaderboardMm(@Query() query: LeaderboardMmQueryDto) { + return this.reports.getLeaderboardMm(query); + } + @Get("/topcoder/mm-stats/:handle") @ApiOperation({ summary: "Marathon match performance snapshot for a specific handle", diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 8fc095a..f0d659b 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -100,6 +100,37 @@ type ChallengeSubmitterDataRow = { finalScore: string | number | null; }; +type LeaderboardGenericRow = { + challengeId: string; + challengeName: string; + durationDays: number; + prizePool: number; + submissionsCount: number; + placementPrizes: { placement: number; value: number }[] | null; + userId: string; + handle: string; + name: string | null; + country: string | null; + countryCode: string | null; + photoURL: string | null; + rating: string | number | null; + ratingColor: string | null; + score: number; + submittedDate: string; + placement: number; +}; + +type LeaderboardMmRow = { + challengeId: string; + challengeName: string; + userId: string; + handle: string; + placement: number; + provisionalScore: number | null; + finalScore: number | null; + score: number | null; +}; + type EngagementDataBaseRow = { member_id: string | null; fallback_handle: string | null; @@ -971,6 +1002,287 @@ export class TopcoderReportsService implements OnModuleDestroy { return projectNamesById; } + async getLeaderboardGeneric(filters: { + challengeIds: string[]; + pointsPerDay?: number; + placementPrizeAmounts?: string[]; + showHeadingAndSubtitle?: boolean; + }) { + const query = this.sql.load("reports/topcoder/leaderboard-generic.sql"); + const rows = await this.db.query(query, [ + filters.challengeIds, + ]); + + const useCmsPlacementPrizes = + Array.isArray(filters.placementPrizeAmounts) && + filters.placementPrizeAmounts.length > 0; + const cmsPlacementPrizeAmounts = (filters.placementPrizeAmounts ?? []) + .map((amount) => Number(amount)) + .filter((amount) => Number.isFinite(amount)); + + const challenges: Record< + string, + { id: string; name: string; submissions: { [memberId: string]: number } } + > = {}; + const entriesByUser: Record< + string, + { + userId: string; + handle: string; + name: string | null; + country: string | null; + countryCode: string | null; + wins: Record; + points: number; + prizes: number; + photoURL: string | null; + rating: number; + ratingColor: string | null; + } + > = {}; + + rows.forEach((row) => { + challenges[row.challengeId] = challenges[row.challengeId] ?? { + id: row.challengeId, + name: row.challengeName, + submissions: {}, + }; + challenges[row.challengeId].submissions[row.userId] = row.placement; + + if (!entriesByUser[row.userId]) { + entriesByUser[row.userId] = { + userId: row.userId, + handle: row.handle, + name: row.name, + country: row.country, + countryCode: row.countryCode, + photoURL: row.photoURL, + wins: {}, + points: 0, + prizes: 0, + rating: row.rating as number, + ratingColor: row.ratingColor, + }; + } + + const log = + Math.log10(Math.max(1, row.prizePool)) + + row.durationDays * (filters.pointsPerDay ?? 0); + const relativeRank = row.placement / Math.max(1, row.submissionsCount); + const sqrt = Math.sqrt(relativeRank); + const pointsByWin = Math.round(log / sqrt); + + entriesByUser[row.userId].points += pointsByWin; + entriesByUser[row.userId].wins[row.challengeId] = pointsByWin; + if (!useCmsPlacementPrizes) { + const prizeValue = + (row.placementPrizes ?? []).find((p) => p.placement === row.placement) + ?.value ?? 0; + entriesByUser[row.userId].prizes += prizeValue; + } + }); + + const placementData = Object.values(entriesByUser) + .filter((entry) => entry.points > 0) + .sort((a, b) => b.points - a.points) + .map((entry, index) => ({ + userId: entry.userId, + handle: entry.handle, + name: entry.name ?? "", + country: entry.country ?? "", + countryCode: entry.countryCode ?? "", + photoURL: entry.photoURL, + placement: index, + points: entry.points, + wins: entry.wins, + rating: entry.rating, + ratingColor: entry.ratingColor, + prizes: useCmsPlacementPrizes + ? (cmsPlacementPrizeAmounts[index] ?? 0) + : entry.prizes, + })); + + return { + placementData: filters.showHeadingAndSubtitle + ? placementData.slice(0, 10) + : placementData, + challenges, + }; + } + + private calculateWriterTesterBonuses( + placementData: Record, + writersAndTesters: string[] | undefined, + pointsMap: number[] = [], + baseScoringPoints = 1, + ) { + const contributions = (writersAndTesters ?? []) + .map((entry) => { + const [handle = "", challengeId = "", role = ""] = entry.split(":"); + if (!handle || !challengeId || !["writer", "tester"].includes(role)) { + return null; + } + return { handle, challengeId, role: role as "writer" | "tester" }; + }) + .filter(Boolean) as { + handle: string; + challengeId: string; + role: "writer" | "tester"; + }[]; + + const bonusPoints: Record = {}; + const eligibility: Record = {}; + const writerTesterRoles: Record< + string, + Record + > = {}; + + if (!contributions.length) { + return { bonusPoints, eligibility, writerTesterRoles }; + } + + const pointsFn = (winner: any) => + winner.score && winner.score <= 0 + ? 0 + : Number(pointsMap[winner.placement - 1] ?? baseScoringPoints); + + const challengeIds = Object.keys(placementData); + const competitionMatchesByHandle: Record> = {}; + const competitionPointsByHandle: Record = {}; + + challengeIds.forEach((challengeId) => { + placementData[challengeId].forEach((entry) => { + competitionMatchesByHandle[entry.handle] = + competitionMatchesByHandle[entry.handle] ?? new Set(); + competitionMatchesByHandle[entry.handle].add(challengeId); + competitionPointsByHandle[entry.handle] = + (competitionPointsByHandle[entry.handle] ?? 0) + pointsFn(entry); + }); + }); + + const matchCount = challengeIds.length; + const minCompetitions = Math.ceil(matchCount / 2); + const contributionByHandle: Record> = {}; + + contributions.forEach(({ handle, challengeId, role }) => { + if (!challengeIds.includes(challengeId)) { + return; + } + if (competitionMatchesByHandle[handle]?.has(challengeId)) { + return; + } + contributionByHandle[handle] = contributionByHandle[handle] ?? new Set(); + contributionByHandle[handle].add(challengeId); + writerTesterRoles[handle] = writerTesterRoles[handle] ?? {}; + writerTesterRoles[handle][challengeId] = role; + }); + + Object.entries(contributionByHandle).forEach( + ([handle, contributionsSet]) => { + const contributionMatches = contributionsSet.size; + const competitionMatches = + competitionMatchesByHandle[handle]?.size ?? 0; + const competitionPoints = competitionPointsByHandle[handle] ?? 0; + const averageCompetitionPoints = competitionMatches + ? competitionPoints / competitionMatches + : 0; + const eligibleForChampionship = + competitionMatches >= minCompetitions && + contributionMatches <= matchCount / 2; + const bonus = eligibleForChampionship + ? averageCompetitionPoints * contributionMatches + : 0; + + bonusPoints[handle] = Number(bonus.toFixed(2)); + eligibility[handle] = { + competitionMatches, + contributionMatches, + averageCompetitionPoints: Number(averageCompetitionPoints.toFixed(2)), + bonusPoints: bonusPoints[handle], + eligibleForChampionship, + }; + }, + ); + + return { bonusPoints, eligibility, writerTesterRoles }; + } + + async getLeaderboardMm(filters: { + challengeIds: string[]; + placementPoints?: number[]; + defaultPlacementPoints?: number; + writersAndTesters?: string[]; + }) { + const query = this.sql.load("reports/topcoder/leaderboard-mm.sql"); + const rows = await this.db.query(query, [ + filters.challengeIds, + ]); + + const placementData = rows.reduce( + (acc: Record, row) => { + acc[row.challengeId] = acc[row.challengeId] ?? []; + acc[row.challengeId].push({ + challengeId: row.challengeId, + challengeName: row.challengeName, + userId: row.userId, + handle: row.handle, + placement: row.placement, + provisionalScore: row.provisionalScore, + finalScore: row.finalScore, + score: row.score, + }); + return acc; + }, + {} as Record, + ); + + const memberHandles = new Set(); + Object.values(placementData).forEach((entries) => { + entries.forEach((entry) => memberHandles.add(entry.handle)); + }); + + const membersDetails: Record< + string, + { + userId: string; + handle: string; + name: string; + country: string; + countryCode: string; + } + > = {}; + memberHandles.forEach((handle) => { + membersDetails[handle] = { + userId: handle, + handle, + name: handle, + country: "", + countryCode: "", + }; + }); + + const writerTesterData = this.calculateWriterTesterBonuses( + placementData, + filters.writersAndTesters, + filters.placementPoints ?? [], + filters.defaultPlacementPoints ?? 1, + ); + + return { + membersDetails, + challengesDetails: Object.fromEntries( + Object.entries(placementData).map(([challengeId, entries]) => [ + challengeId, + { name: entries[0]?.challengeName ?? "" }, + ]), + ), + placementData, + writerTesterBonusPoints: writerTesterData.bonusPoints, + writerTesterRoles: writerTesterData.writerTesterRoles, + writerTesterEligibility: writerTesterData.eligibility, + }; + } + /** * Formats a preferred address row into the report output string. *