diff --git a/.circleci/config.yml b/.circleci/config.yml index 8eb7042..f8b519c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -65,7 +65,8 @@ workflows: only: - develop - pm-1127_1 - + - pm-4203_1 + # Production builds are exectuted only on tagged commits to the # master branch. - "build-prod": diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml index 7b9fa48..9cbcf52 100644 --- a/.github/workflows/trivy.yaml +++ b/.github/workflows/trivy.yaml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@v4 - name: Run Trivy scanner in repo mode - uses: aquasecurity/trivy-action@0.33.1 + uses: aquasecurity/trivy-action@0.35.0 with: scan-type: "fs" ignore-unfixed: true diff --git a/README.md b/README.md index d59cf23..add20e6 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ This repository houses the reports API for all Topcoder and Topgear reports on the Topcoder platform. The reports are pulled directly from live data, not a data warehouse, so they should be up-to-date when they are generated and the response is returned. Reports return JSON data by default. Endpoints that support CSV can also return -CSV when the request sets `Accept: text/csv` (including the Challenges and -Topcoder report groups). +CSV when the request sets `Accept: text/csv` (including the Challenges, +Topcoder, Member, and Admin report groups). ## Security diff --git a/sql/reports/challenges/registered-users.sql b/sql/reports/challenges/registered-users.sql new file mode 100644 index 0000000..2a82024 --- /dev/null +++ b/sql/reports/challenges/registered-users.sql @@ -0,0 +1,60 @@ +WITH challenge_context AS ( + SELECT c.id + FROM challenges."Challenge" AS c + WHERE c.id = $1::text +), +registered_members AS MATERIALIZED ( + SELECT + res."memberId", + MAX(res."memberHandle") AS "memberHandle" + FROM challenge_context AS cc + JOIN resources."Resource" AS res + ON res."challengeId" = cc.id + JOIN resources."ResourceRole" AS rr + ON rr.id = res."roleId" + AND rr.name = 'Submitter' + GROUP BY res."memberId" +) +SELECT + COALESCE( + u.user_id::bigint, + CASE + WHEN rm."memberId" ~ '^[0-9]+$' THEN rm."memberId"::bigint + ELSE NULL + END + ) AS "userId", + COALESCE( + NULLIF(TRIM(u.handle), ''), + NULLIF(TRIM(mem.handle), ''), + rm."memberHandle" + ) AS "handle", + COALESCE(e.address, NULLIF(TRIM(mem.email), '')) AS "email", + COALESCE( + comp_code.name, + comp_id.name, + home_code.name, + home_id.name, + NULLIF(TRIM(mem."competitionCountryCode"), ''), + NULLIF(TRIM(mem."homeCountryCode"), '') + ) AS "country" +FROM registered_members AS rm +LEFT JOIN identity."user" AS u + ON rm."memberId" ~ '^[0-9]+$' + AND u.user_id = rm."memberId"::numeric +LEFT JOIN identity.email AS e + ON e.user_id = u.user_id + AND e.primary_ind = 1 +LEFT JOIN members."member" AS mem + ON rm."memberId" ~ '^[0-9]+$' + AND mem."userId" = rm."memberId"::bigint +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") +ORDER BY + "handle" ASC NULLS LAST, + "userId" ASC NULLS LAST; diff --git a/sql/reports/challenges/registrants-history.sql b/sql/reports/challenges/registrants-history.sql index d1ec062..f38e0ef 100644 --- a/sql/reports/challenges/registrants-history.sql +++ b/sql/reports/challenges/registrants-history.sql @@ -8,8 +8,11 @@ filtered_challenges AS MATERIALIZED ( SELECT c.id, c.status, + ct.name AS "challengeType", lp."actualEndDate" AS "challengeCompletedDate" FROM challenges."Challenge" c + JOIN challenges."ChallengeType" ct + ON ct.id = c."typeId" LEFT JOIN LATERAL ( SELECT cp."actualEndDate" FROM challenges."ChallengePhase" cp @@ -40,8 +43,8 @@ filtered_challenges AS MATERIALIZED ( ) -- filter by challenge status AND ($3::text[] IS NULL OR c.status::text = ANY($3::text[])) - -- exclude task challenge types from this report - AND COALESCE(c."taskIsTask", false) = false + -- include only challenge types supported by this report + AND ct.name IN ('Challenge', 'Marathon Match', 'First2Finish') -- filter by completion date bounds on the latest challenge phase end date AND ( ($4::timestamptz IS NULL AND $5::timestamptz IS NULL) @@ -57,6 +60,7 @@ registrants AS MATERIALIZED ( SELECT fc.id AS "challengeId", fc.status AS "challengeStatus", + fc."challengeType", fc."challengeCompletedDate", registrant."memberId", registrant."registrantHandle" @@ -71,23 +75,29 @@ registrants AS MATERIALIZED ( AND res."roleId" = sr.id GROUP BY res."memberId" ) registrant ON true - LIMIT 1000 ) SELECT r."challengeId", r."challengeStatus", + r."challengeType", win."winnerHandle", - COALESCE(sub."isWinner", false) AS "isWinner", + ( + COALESCE(win."isWinner", false) + OR COALESCE(sub."isWinner", false) + ) AS "isWinner", CASE WHEN r."challengeStatus" = 'COMPLETED' THEN r."challengeCompletedDate" ELSE null END AS "challengeCompletedDate", r."registrantHandle", - sub."registrantFinalScore" + COALESCE(sub."registrantFinalScore", sum."registrantFinalScore") + AS "registrantFinalScore" FROM registrants r LEFT JOIN LATERAL ( - SELECT MAX(cw.handle) AS "winnerHandle" + SELECT + MAX(cw.handle) AS "winnerHandle", + COUNT(*) > 0 AS "isWinner" FROM challenges."ChallengeWinner" cw WHERE cw."challengeId" = r."challengeId" AND cw."userId"::text = r."memberId" @@ -96,8 +106,19 @@ LEFT JOIN LATERAL ( LEFT JOIN LATERAL ( SELECT BOOL_OR(s.placement = 1) AS "isWinner", - ROUND(MAX(s."finalScore"), 2) AS "registrantFinalScore" + COALESCE(ROUND(MAX(s."finalScore")::numeric, 2), ROUND(MAX(s."initialScore")::numeric, 2)) AS "registrantFinalScore" FROM reviews.submission s WHERE s."challengeId" = r."challengeId" AND s."memberId" = r."memberId" -) sub ON true; +) sub ON true +LEFT JOIN LATERAL ( + SELECT + ROUND(MAX(rs."aggregateScore")::numeric, 2) AS "registrantFinalScore" + FROM reviews."reviewSummation" rs + WHERE rs."submissionId" IN ( + SELECT id from reviews.submission + WHERE "challengeId" = r."challengeId" + AND "memberId" = r."memberId" + ) +) sum ON true; + diff --git a/sql/reports/challenges/submitters.sql b/sql/reports/challenges/submitters.sql new file mode 100644 index 0000000..9231bc8 --- /dev/null +++ b/sql/reports/challenges/submitters.sql @@ -0,0 +1,166 @@ +WITH challenge_context AS ( + SELECT + c.id, + (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 = $1::text +), +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, + final_review."aggregateScore" 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" + 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 +), +submitter_members AS MATERIALIZED ( + SELECT + cc.is_marathon_match, + res."memberId", + MAX(res."memberHandle") AS "memberHandle" + FROM challenge_context AS cc + JOIN resources."Resource" AS res + ON res."challengeId" = cc.id + JOIN resources."ResourceRole" AS rr + ON rr.id = res."roleId" + AND rr.name = 'Submitter' + JOIN submission_metrics AS smx + ON smx."memberId" = res."memberId" + GROUP BY + cc.is_marathon_match, + res."memberId" +), +standard_member_scores AS ( + SELECT + sm."memberId", + ROUND(MAX(sm.standard_score)::numeric, 2) AS "submissionScore" + FROM submission_metrics AS sm + GROUP BY sm."memberId" +), +mm_latest_submission_scores AS ( + SELECT DISTINCT ON (sm."memberId") + 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.submission_timestamp + FROM submission_metrics AS sm + ORDER BY + sm."memberId", + sm.submission_timestamp DESC NULLS LAST, + sm.submission_id DESC +), +mm_ranked_scores AS ( + SELECT + mlss."memberId", + CASE + WHEN mlss.provisional_score_raw IS NULL THEN NULL + ELSE ROUND(mlss.provisional_score_raw::numeric, 2) + END AS "provisionalScore", + CASE + WHEN mlss.effective_score_raw IS NULL THEN NULL + ELSE ROW_NUMBER() OVER ( + ORDER BY + mlss.effective_score_raw DESC NULLS LAST, + mlss.submission_timestamp ASC NULLS LAST, + mlss."memberId" ASC + ) + END AS "finalRank" + FROM mm_latest_submission_scores AS mlss +) +SELECT + COALESCE( + u.user_id::bigint, + CASE + WHEN sm."memberId" ~ '^[0-9]+$' THEN sm."memberId"::bigint + ELSE NULL + END + ) AS "userId", + COALESCE( + NULLIF(TRIM(u.handle), ''), + NULLIF(TRIM(mem.handle), ''), + sm."memberHandle" + ) AS "handle", + COALESCE(e.address, NULLIF(TRIM(mem.email), '')) AS "email", + COALESCE( + comp_code.name, + comp_id.name, + home_code.name, + home_id.name, + NULLIF(TRIM(mem."competitionCountryCode"), ''), + NULLIF(TRIM(mem."homeCountryCode"), '') + ) AS "country", + sm.is_marathon_match AS "isMarathonMatch", + CASE + WHEN sm.is_marathon_match THEN NULL + ELSE sms."submissionScore" + END AS "submissionScore", + CASE + WHEN sm.is_marathon_match THEN mrs."provisionalScore" + ELSE NULL + END AS "provisionalScore", + CASE + WHEN sm.is_marathon_match THEN mrs."finalRank" + ELSE NULL + END AS "finalRank" +FROM submitter_members AS sm +LEFT JOIN identity."user" AS u + ON sm."memberId" ~ '^[0-9]+$' + AND u.user_id = sm."memberId"::numeric +LEFT JOIN identity.email AS e + ON e.user_id = u.user_id + AND e.primary_ind = 1 +LEFT JOIN members."member" AS mem + ON sm."memberId" ~ '^[0-9]+$' + AND mem."userId" = sm."memberId"::bigint +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") +LEFT JOIN standard_member_scores AS sms + ON sms."memberId" = sm."memberId" +LEFT JOIN mm_ranked_scores AS mrs + ON mrs."memberId" = sm."memberId" +ORDER BY + CASE + WHEN sm.is_marathon_match THEN mrs."finalRank" + ELSE NULL + END ASC NULLS LAST, + CASE + WHEN sm.is_marathon_match THEN NULL + ELSE sms."submissionScore" + END DESC NULLS LAST, + "handle" ASC NULLS LAST, + "userId" ASC NULLS LAST; diff --git a/sql/reports/challenges/valid-submitters.sql b/sql/reports/challenges/valid-submitters.sql new file mode 100644 index 0000000..c223e44 --- /dev/null +++ b/sql/reports/challenges/valid-submitters.sql @@ -0,0 +1,183 @@ +WITH challenge_context AS ( + SELECT + c.id, + (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 = $1::text +), +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, + final_review."aggregateScore" AS final_score_raw, + ( + passing_review.is_passing IS TRUE + OR COALESCE(s."finalScore"::double precision, 0) > 98 + ) AS is_valid_submission + 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" + 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 LATERAL ( + SELECT TRUE AS is_passing + FROM reviews."reviewSummation" AS rs + WHERE rs."submissionId" = s.id + AND rs."isPassing" = TRUE + AND COALESCE(rs."isFinal", TRUE) = TRUE + LIMIT 1 + ) AS passing_review ON TRUE +), +valid_submission_metrics AS ( + SELECT * + FROM submission_metrics + WHERE is_valid_submission = TRUE +), +valid_submitter_members AS MATERIALIZED ( + SELECT + cc.is_marathon_match, + res."memberId", + MAX(res."memberHandle") AS "memberHandle" + FROM challenge_context AS cc + JOIN resources."Resource" AS res + ON res."challengeId" = cc.id + JOIN resources."ResourceRole" AS rr + ON rr.id = res."roleId" + AND rr.name = 'Submitter' + JOIN valid_submission_metrics AS vsmx + ON vsmx."memberId" = res."memberId" + GROUP BY + cc.is_marathon_match, + res."memberId" +), +standard_member_scores AS ( + SELECT + vsm."memberId", + ROUND(MAX(vsm.standard_score)::numeric, 2) AS "submissionScore" + FROM valid_submission_metrics AS vsm + GROUP BY vsm."memberId" +), +mm_latest_submission_scores AS ( + SELECT DISTINCT ON (vsm."memberId") + 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.submission_timestamp + FROM valid_submission_metrics AS vsm + ORDER BY + vsm."memberId", + vsm.submission_timestamp DESC NULLS LAST, + vsm.submission_id DESC +), +mm_ranked_scores AS ( + SELECT + mlss."memberId", + CASE + WHEN mlss.provisional_score_raw IS NULL THEN NULL + ELSE ROUND(mlss.provisional_score_raw::numeric, 2) + END AS "provisionalScore", + CASE + WHEN mlss.effective_score_raw IS NULL THEN NULL + ELSE ROW_NUMBER() OVER ( + ORDER BY + mlss.effective_score_raw DESC NULLS LAST, + mlss.submission_timestamp ASC NULLS LAST, + mlss."memberId" ASC + ) + END AS "finalRank" + FROM mm_latest_submission_scores AS mlss +) +SELECT + COALESCE( + u.user_id::bigint, + CASE + WHEN vsm."memberId" ~ '^[0-9]+$' THEN vsm."memberId"::bigint + ELSE NULL + END + ) AS "userId", + COALESCE( + NULLIF(TRIM(u.handle), ''), + NULLIF(TRIM(mem.handle), ''), + vsm."memberHandle" + ) AS "handle", + COALESCE(e.address, NULLIF(TRIM(mem.email), '')) AS "email", + COALESCE( + comp_code.name, + comp_id.name, + home_code.name, + home_id.name, + NULLIF(TRIM(mem."competitionCountryCode"), ''), + NULLIF(TRIM(mem."homeCountryCode"), '') + ) AS "country", + vsm.is_marathon_match AS "isMarathonMatch", + CASE + WHEN vsm.is_marathon_match THEN NULL + ELSE sms."submissionScore" + END AS "submissionScore", + CASE + WHEN vsm.is_marathon_match THEN mrs."provisionalScore" + ELSE NULL + END AS "provisionalScore", + CASE + WHEN vsm.is_marathon_match THEN mrs."finalRank" + ELSE NULL + END AS "finalRank" +FROM valid_submitter_members AS vsm +LEFT JOIN identity."user" AS u + ON vsm."memberId" ~ '^[0-9]+$' + AND u.user_id = vsm."memberId"::numeric +LEFT JOIN identity.email AS e + ON e.user_id = u.user_id + AND e.primary_ind = 1 +LEFT JOIN members."member" AS mem + ON vsm."memberId" ~ '^[0-9]+$' + AND mem."userId" = vsm."memberId"::bigint +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") +LEFT JOIN standard_member_scores AS sms + ON sms."memberId" = vsm."memberId" +LEFT JOIN mm_ranked_scores AS mrs + ON mrs."memberId" = vsm."memberId" +ORDER BY + CASE + WHEN vsm.is_marathon_match THEN mrs."finalRank" + ELSE NULL + END ASC NULLS LAST, + CASE + WHEN vsm.is_marathon_match THEN NULL + ELSE sms."submissionScore" + END DESC NULLS LAST, + "handle" ASC NULLS LAST, + "userId" ASC NULLS LAST; diff --git a/sql/reports/challenges/winners.sql b/sql/reports/challenges/winners.sql new file mode 100644 index 0000000..a9ae4df --- /dev/null +++ b/sql/reports/challenges/winners.sql @@ -0,0 +1,142 @@ +WITH challenge_context AS ( + SELECT + c.id, + (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 = $1::text +), +submission_metrics AS ( + SELECT + s."memberId", + COALESCE( + final_review."aggregateScore", + s."finalScore"::double precision, + s."initialScore"::double precision + ) AS standard_score, + provisional_review.provisional_score, + final_review."aggregateScore" 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" + 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 MAX(rs."aggregateScore") AS provisional_score + FROM reviews."reviewSummation" AS rs + WHERE rs."submissionId" = s.id + AND rs."isProvisional" IS TRUE + ) AS provisional_review ON TRUE +), +winner_members AS MATERIALIZED ( + SELECT + cc.is_marathon_match, + cw."userId"::text AS "memberId", + MAX(cw.handle) AS "winnerHandle", + MIN(cw.placement) AS placement + FROM challenge_context AS cc + JOIN challenges."ChallengeWinner" AS cw + ON cw."challengeId" = cc.id + AND cw.type = 'PLACEMENT' + GROUP BY + cc.is_marathon_match, + cw."userId" +), +standard_member_scores AS ( + SELECT + sm."memberId", + ROUND(MAX(sm.standard_score)::numeric, 2) AS "submissionScore" + FROM submission_metrics AS sm + GROUP BY sm."memberId" +), +mm_member_scores AS ( + SELECT + sm."memberId", + MAX(sm.provisional_score) AS provisional_score_raw, + MAX(sm.final_score_raw) AS final_score_raw + FROM submission_metrics AS sm + GROUP BY sm."memberId" +), +mm_winner_scores AS ( + SELECT + mms."memberId", + CASE + WHEN mms.provisional_score_raw IS NULL THEN NULL + ELSE ROUND(mms.provisional_score_raw::numeric, 2) + END AS "provisionalScore" + FROM mm_member_scores AS mms +) +SELECT + COALESCE( + u.user_id::bigint, + CASE + WHEN wm."memberId" ~ '^[0-9]+$' THEN wm."memberId"::bigint + ELSE NULL + END + ) AS "userId", + COALESCE( + NULLIF(TRIM(u.handle), ''), + NULLIF(TRIM(mem.handle), ''), + wm."winnerHandle" + ) AS "handle", + COALESCE(e.address, NULLIF(TRIM(mem.email), '')) AS "email", + COALESCE( + comp_code.name, + comp_id.name, + home_code.name, + home_id.name, + NULLIF(TRIM(mem."competitionCountryCode"), ''), + NULLIF(TRIM(mem."homeCountryCode"), '') + ) AS "country", + wm.is_marathon_match AS "isMarathonMatch", + CASE + WHEN wm.is_marathon_match THEN NULL + ELSE sms."submissionScore" + END AS "submissionScore", + CASE + WHEN wm.is_marathon_match THEN mrs."provisionalScore" + ELSE NULL + END AS "provisionalScore", + CASE + WHEN wm.is_marathon_match THEN wm.placement + ELSE NULL + END AS "finalRank" +FROM winner_members AS wm +LEFT JOIN identity."user" AS u + ON wm."memberId" ~ '^[0-9]+$' + AND u.user_id = wm."memberId"::numeric +LEFT JOIN identity.email AS e + ON e.user_id = u.user_id + AND e.primary_ind = 1 +LEFT JOIN members."member" AS mem + ON wm."memberId" ~ '^[0-9]+$' + AND mem."userId" = wm."memberId"::bigint +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") +LEFT JOIN standard_member_scores AS sms + ON sms."memberId" = wm."memberId" +LEFT JOIN mm_winner_scores AS mrs + ON mrs."memberId" = wm."memberId" +ORDER BY + wm.placement ASC NULLS LAST, + CASE + WHEN wm.is_marathon_match THEN NULL + ELSE sms."submissionScore" + END DESC NULLS LAST, + "handle" ASC NULLS LAST, + "userId" ASC NULLS LAST; diff --git a/sql/reports/identity/users-by-group.sql b/sql/reports/identity/users-by-group.sql new file mode 100644 index 0000000..ce8e9f6 --- /dev/null +++ b/sql/reports/identity/users-by-group.sql @@ -0,0 +1,33 @@ +SELECT + DISTINCT u.user_id AS "userId", + u.handle AS "handle", + e.address AS "email" +FROM groups."Group" g +JOIN groups."GroupMember" gm + ON g.id = gm."groupId" +LEFT JOIN groups."User" gu + ON gu.id = gm."memberId" +JOIN identity."user" u + ON u.user_id::text = ( + CASE + WHEN gm."memberId" ~ '^[0-9]+$' THEN gm."memberId" + WHEN gu."universalUID" ~ '^[0-9]+$' THEN gu."universalUID" + ELSE NULL + END + ) +LEFT JOIN identity.email e + ON e.user_id = u.user_id + AND e.primary_ind = 1 +WHERE ($1::text IS NOT NULL OR $2::text IS NOT NULL) + AND gm."membershipType" = 'user' + AND g.status = 'active' + AND ( + $1::text IS NULL + OR g.id = $1::text + OR g."oldId" = $1::text + ) + AND ( + $2::text IS NULL + OR LOWER(g.name) = LOWER($2::text) + OR LOWER(COALESCE(g.description, '')) = LOWER($2::text) + ); diff --git a/sql/reports/identity/users-by-handles.sql b/sql/reports/identity/users-by-handles.sql new file mode 100644 index 0000000..6e547e5 --- /dev/null +++ b/sql/reports/identity/users-by-handles.sql @@ -0,0 +1,29 @@ +WITH input_handles AS ( + SELECT + handle_input, + ordinality + FROM unnest($1::text[]) WITH ORDINALITY AS t(handle_input, ordinality) +) +SELECT + ih.handle_input AS "handle", + u.user_id AS "userId", + pe.address AS "email", + COALESCE( + NULLIF(BTRIM(mem."competitionCountryCode"), ''), + NULLIF(BTRIM(mem."homeCountryCode"), '') + ) AS "country" +FROM input_handles AS ih +LEFT JOIN identity."user" AS u + ON LOWER(u.handle) = LOWER(ih.handle_input) +LEFT JOIN LATERAL ( + SELECT e.address + FROM identity.email AS e + WHERE e.user_id = u.user_id + AND NULLIF(BTRIM(e.address), '') IS NOT NULL + ORDER BY COALESCE(e.primary_ind, 0) DESC, e.email_id ASC + LIMIT 1 +) AS pe + ON TRUE +LEFT JOIN members."member" AS mem + ON mem."userId" = u.user_id +ORDER BY ih.ordinality; diff --git a/sql/reports/identity/users-by-role.sql b/sql/reports/identity/users-by-role.sql new file mode 100644 index 0000000..45e8133 --- /dev/null +++ b/sql/reports/identity/users-by-role.sql @@ -0,0 +1,16 @@ +SELECT + u.user_id AS "userId", + u.handle AS "handle", + e.address AS "email" +FROM identity.role r +JOIN identity.role_assignment ra + ON r.id = ra.role_id +JOIN identity."user" u + ON ra.subject_id = u.user_id +LEFT JOIN identity.email e + ON e.user_id = u.user_id + AND e.primary_ind = 1 +WHERE ($1::int IS NOT NULL OR $2::text IS NOT NULL) + AND ra.subject_type = 1 + AND ($1::int IS NULL OR r.id = $1::int) + AND ($2::text IS NULL OR LOWER(r.name) = LOWER($2::text)); diff --git a/sql/reports/topcoder/challenge-submitter-data.sql b/sql/reports/topcoder/challenge-submitter-data.sql new file mode 100644 index 0000000..2930202 --- /dev/null +++ b/sql/reports/topcoder/challenge-submitter-data.sql @@ -0,0 +1,155 @@ +WITH challenge_context AS ( + SELECT + c.id, + (LOWER(ct.name) = LOWER('Marathon Match')) AS is_marathon_match + FROM challenges."Challenge" AS c + JOIN challenges."ChallengeType" AS ct + ON ct.id = c."typeId" + WHERE c.id = $1::text +), +member_submissions AS ( + SELECT + cc.id AS challenge_id, + cc.is_marathon_match, + s.id AS submission_id, + s."memberId"::bigint AS user_id, + s.placement, + s."submittedDate" + FROM challenge_context AS cc + JOIN reviews.submission AS s + ON s."challengeId" = cc.id + AND s."memberId" IS NOT NULL +), +submitters AS ( + SELECT DISTINCT + ms.user_id, + ms.is_marathon_match + FROM member_submissions AS ms +), +primary_submission AS ( + SELECT DISTINCT ON (ms.user_id) + ms.user_id, + ms.submission_id, + ms.placement + FROM member_submissions AS ms + ORDER BY + ms.user_id, + ms.placement NULLS LAST, + ms."submittedDate" DESC NULLS LAST, + ms.submission_id DESC +), +winner_placements AS ( + SELECT + cw."userId"::bigint AS user_id, + MIN(cw.placement) AS placement + FROM challenge_context AS cc + JOIN challenges."ChallengeWinner" AS cw + ON cw."challengeId" = cc.id + WHERE cw.type = 'PLACEMENT' + GROUP BY cw."userId" +), +marathon_scores AS ( + SELECT + st.user_id, + COALESCE( + jsonb_agg( + rs."aggregateScore" + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt"), rs.id + ) FILTER ( + WHERE rs."isProvisional" IS TRUE + ), + '[]'::jsonb + ) AS provisional_scores + FROM submitters AS st + LEFT JOIN member_submissions AS ms + ON ms.user_id = st.user_id + LEFT JOIN reviews."reviewSummation" AS rs + ON rs."submissionId" = ms.submission_id + GROUP BY st.user_id +), +marathon_final_score AS ( + SELECT + st.user_id, + COALESCE(wp.placement, ps.placement) AS placement, + final_score."aggregateScore" AS final_score + FROM submitters AS st + LEFT JOIN winner_placements AS wp + ON wp.user_id = st.user_id + LEFT JOIN primary_submission AS ps + ON ps.user_id = st.user_id + LEFT JOIN LATERAL ( + SELECT rs."aggregateScore" + FROM reviews."reviewSummation" AS rs + WHERE rs."submissionId" = ps.submission_id + AND rs."isFinal" IS TRUE + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC, rs.id DESC + LIMIT 1 + ) AS final_score ON TRUE +), +submitter_profiles AS ( + SELECT + st.user_id, + COALESCE( + NULLIF(TRIM(mem.handle), ''), + NULLIF(TRIM(handle_fallback.member_handle), '') + ) AS handle, + mem.email, + COALESCE( + home_code.name, + home_id.name, + NULLIF(TRIM(mem."homeCountryCode"), ''), + comp_code.name, + comp_id.name, + NULLIF(TRIM(mem."competitionCountryCode"), '') + ) AS country, + st.is_marathon_match, + mfs.placement, + ms.provisional_scores, + mfs.final_score + FROM submitters AS st + LEFT JOIN members.member AS mem + ON mem."userId" = st.user_id + LEFT JOIN LATERAL ( + SELECT MAX(res."memberHandle") AS member_handle + FROM resources."Resource" AS res + WHERE res."challengeId" = $1::text + AND res."memberId" = st.user_id::text + ) AS handle_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") + LEFT JOIN marathon_scores AS ms + ON ms.user_id = st.user_id + LEFT JOIN marathon_final_score AS mfs + ON mfs.user_id = st.user_id +) +SELECT + sp.user_id AS "userId", + sp.handle AS "handle", + sp.email AS "email", + sp.country AS "country", + CASE + WHEN sp.is_marathon_match THEN sp.placement + ELSE NULL + END AS "place", + CASE + WHEN sp.is_marathon_match THEN sp.provisional_scores + ELSE NULL + END AS "provisionalScores", + CASE + WHEN sp.is_marathon_match THEN sp.final_score + ELSE NULL + END AS "finalScore" +FROM submitter_profiles AS sp +ORDER BY + CASE + WHEN sp.is_marathon_match THEN sp.placement + ELSE NULL + END ASC NULLS LAST, + sp.handle ASC NULLS LAST, + sp.user_id ASC; diff --git a/sql/reports/topcoder/completed-profiles-count.sql b/sql/reports/topcoder/completed-profiles-count.sql new file mode 100644 index 0000000..97c432b --- /dev/null +++ b/sql/reports/topcoder/completed-profiles-count.sql @@ -0,0 +1,59 @@ +-- Count members with 100% completed profiles (optionally filtered by country) +WITH member_skills AS ( + SELECT + us.user_id, + COUNT(*) AS skill_count, + ARRAY_AGG(DISTINCT us.skill_id::uuid) AS skill_ids + FROM skills.user_skill us + GROUP BY us.user_id + HAVING COUNT(*) >= 3 +) +SELECT COUNT(*) AS total +FROM members.member m +INNER JOIN member_skills ms ON ms.user_id = m."userId" +WHERE m.description IS NOT NULL + AND m.description <> '' + AND m."photoURL" IS NOT NULL + AND m."photoURL" <> '' + AND m."homeCountryCode" IS NOT NULL + AND ($1::text IS NULL OR COALESCE(m."homeCountryCode", m."competitionCountryCode") = $1) + AND ($3::uuid[] IS NULL OR ms.skill_ids @> $3::uuid[]) + AND ( + $2::boolean IS NULL + OR m."availableForGigs" = $2::boolean + ) + AND EXISTS ( + SELECT 1 + FROM members."memberTraits" mt + INNER JOIN members."memberTraitWork" mw ON mw."memberTraitId" = mt.id + WHERE mt."userId" = m."userId" + ) + AND EXISTS ( + SELECT 1 + FROM members."memberTraits" mt + INNER JOIN members."memberTraitEducation" me ON me."memberTraitId" = mt.id + WHERE mt."userId" = m."userId" + ) + AND EXISTS ( + SELECT 1 + FROM members."memberTraits" mt + INNER JOIN members."memberTraitPersonalization" mtp ON mtp."memberTraitId" = mt.id + WHERE mt."userId" = m."userId" + AND mtp.key = 'openToWork' + AND mtp.value IS NOT NULL + AND ( + NOT (mtp.value::jsonb ? 'availability') + OR ( + mtp.value::jsonb ? 'availability' + AND mtp.value::jsonb ? 'preferredRoles' + AND jsonb_array_length(mtp.value::jsonb -> 'preferredRoles') > 0 + ) + ) + ) + AND EXISTS ( + SELECT 1 + FROM members."memberAddress" ma + WHERE ma."userId" = m."userId" + AND ma.city IS NOT NULL + AND TRIM(ma.city) <> '' + ); diff --git a/sql/reports/topcoder/completed-profiles.sql b/sql/reports/topcoder/completed-profiles.sql new file mode 100644 index 0000000..dfc7191 --- /dev/null +++ b/sql/reports/topcoder/completed-profiles.sql @@ -0,0 +1,102 @@ +-- A profile is considered complete if it has: +-- - description (bio) +-- - profile photo +-- - at least 3 skills +-- - engagement availability (personalization trait with openToWork, availability boolean, and preferredRoles array) +-- - at least one work history entry +-- - at least one education entry +-- - at least one location (city in address AND homeCountryCode) + +WITH member_skills AS ( + SELECT + us.user_id, + COUNT(*) AS skill_count, + ARRAY_AGG(DISTINCT us.skill_id::uuid) AS skill_ids + FROM skills.user_skill us + GROUP BY us.user_id + HAVING COUNT(*) >= 3 -- Filter early to reduce dataset +) +SELECT + m."userId" AS "userId", + m.handle, + m."firstName" AS "firstName", + m."lastName" AS "lastName", + m."photoURL" AS "photoURL", + COALESCE(m."homeCountryCode", m."competitionCountryCode") AS "countryCode", + m.country AS "countryName", + ot.open_to_work_value AS "openToWork", + m."availableForGigs" AS "isOpenToWork", + ma.city, + ms.skill_count AS "skillCount" +FROM members.member m +INNER JOIN member_skills ms ON ms.user_id = m."userId" +LEFT JOIN LATERAL ( + SELECT + mtp.value AS open_to_work_value, + ( + mtp.value::jsonb ? 'availability' + AND btrim(mtp.value->>'availability') <> '' + ) AS is_open_to_work + FROM members."memberTraits" mt + INNER JOIN members."memberTraitPersonalization" mtp ON mtp."memberTraitId" = mt.id + WHERE mt."userId" = m."userId" + AND mtp.key = 'openToWork' + ORDER BY mt."updatedAt" DESC + LIMIT 1 +) ot ON TRUE +LEFT JOIN LATERAL ( + SELECT + ma.city + FROM members."memberAddress" ma + WHERE ma."userId" = m."userId" + AND ma.city IS NOT NULL + AND TRIM(ma.city) <> '' + ORDER BY ma.id ASC + LIMIT 1 +) ma ON TRUE +WHERE m.description IS NOT NULL + AND m.description <> '' + AND m."photoURL" IS NOT NULL + AND m."photoURL" <> '' + AND m."homeCountryCode" IS NOT NULL + AND ($1::text IS NULL OR COALESCE(m."homeCountryCode", m."competitionCountryCode") = $1) + AND ($5::uuid[] IS NULL OR ms.skill_ids @> $5::uuid[]) + AND ( + $2::boolean IS NULL + OR m."availableForGigs" = $2::boolean + ) + -- Check work history exists + AND EXISTS ( + SELECT 1 + FROM members."memberTraits" mt + INNER JOIN members."memberTraitWork" mw ON mw."memberTraitId" = mt.id + WHERE mt."userId" = m."userId" + ) + -- Check education exists + AND EXISTS ( + SELECT 1 + FROM members."memberTraits" mt + INNER JOIN members."memberTraitEducation" me ON me."memberTraitId" = mt.id + WHERE mt."userId" = m."userId" + ) + -- Check engagement availability exists + AND ot.open_to_work_value IS NOT NULL + AND ( + NOT (ot.open_to_work_value::jsonb ? 'availability') + OR ( + ot.open_to_work_value::jsonb ? 'availability' + AND ot.open_to_work_value::jsonb ? 'preferredRoles' + AND jsonb_array_length(ot.open_to_work_value::jsonb -> 'preferredRoles') > 0 + ) + ) + -- Check location exists + AND EXISTS ( + SELECT 1 + FROM members."memberAddress" ma + WHERE ma."userId" = m."userId" + AND ma.city IS NOT NULL + AND TRIM(ma.city) <> '' + ) +ORDER BY m.handle +LIMIT $3::int +OFFSET $4::int; diff --git a/sql/reports/topcoder/mm-stats.sql b/sql/reports/topcoder/mm-stats.sql index 64f4255..c31961c 100644 --- a/sql/reports/topcoder/mm-stats.sql +++ b/sql/reports/topcoder/mm-stats.sql @@ -45,15 +45,28 @@ LEFT JOIN LATERAL ( WHERE mmr."userId" = mb."userId" ) AS max_rating ON TRUE LEFT JOIN LATERAL ( - SELECT mmar.* + SELECT COUNT(*) AS competitions + FROM members."memberStatsHistory" AS msh + WHERE msh."userId" = mb."userId" + AND msh."trackId" = 'DATA_SCIENCE' + AND msh."typeId" = 'MARATHON_MATCH' + AND msh."newRating" IS NOT NULL +) AS marathon_stats_history ON TRUE +LEFT JOIN LATERAL ( + SELECT + ms.rating, + ms."globalRank" AS rank, + ms.challenges, + ms.wins, + ms."topFiveFinishes", + ms."avgRank", + marathon_stats_history.competitions FROM members."memberStats" AS ms - JOIN members."memberDataScienceStats" AS mds - ON mds."memberStatsId" = ms.id - JOIN members."memberMarathonStats" AS mmar - ON mmar."dataScienceStatsId" = mds.id WHERE ms."userId" = mb."userId" + AND ms."trackId" = 'DATA_SCIENCE' + AND ms."typeId" = 'MARATHON_MATCH' ORDER BY - CASE WHEN ms."isPrivate" THEN 1 ELSE 0 END, + ms."isPrivate" ASC, ms."updatedAt" DESC NULLS LAST, ms.id DESC LIMIT 1 diff --git a/src/app-constants.ts b/src/app-constants.ts index 42777a9..c7e1365 100644 --- a/src/app-constants.ts +++ b/src/app-constants.ts @@ -6,6 +6,9 @@ export const Scopes = { TopgearCancelledChallenge: "reports:topgear-cancelled-challenge", AllReports: "reports:all", TopcoderReports: "reports:topcoder", + Member: { + RecentMemberData: "reports:member-recent-member-data", + }, TopgearChallengeTechnology: "reports:topgear-challenge-technology", TopgearChallengeStatsByUser: "reports:topgear-challenge-stats-by-user", TopgearChallengeRegistrantDetails: @@ -23,9 +26,44 @@ export const Scopes = { History: "reports:challenge-history", Registrants: "reports:challenge-registrants", SubmissionLinks: "reports:challenge-submission-links", + RegisteredUsers: "reports:challenge-registered-users", + Submitters: "reports:challenge-submitters", + ValidSubmitters: "reports:challenge-valid-submitters", + Winners: "reports:challenge-winners", + }, + Identity: { + UsersByRole: "reports:identity-users-by-role", + UsersByGroup: "reports:identity-users-by-group", + UsersByHandles: "reports:identity-users-by-handles", }, }; -export const UserRoles = { +export const AdminRoles = { Admin: "Administrator", }; + +export const UserRoles = { + ProductManager: "Product Manager", + ProjectManager: "Project Manager", + TalentManager: "Talent Manager", +}; + +const challengeReportAccessRoles = [ + UserRoles.ProductManager, + UserRoles.TalentManager, +] as const; + +export const ScopeRoleAccess: Record = { + [Scopes.Challenge.History]: challengeReportAccessRoles, + [Scopes.Challenge.Registrants]: challengeReportAccessRoles, + [Scopes.Challenge.SubmissionLinks]: challengeReportAccessRoles, + [Scopes.Challenge.RegisteredUsers]: challengeReportAccessRoles, + [Scopes.Challenge.Submitters]: challengeReportAccessRoles, + [Scopes.Challenge.ValidSubmitters]: challengeReportAccessRoles, + [Scopes.Challenge.Winners]: challengeReportAccessRoles, + [Scopes.Member.RecentMemberData]: [UserRoles.TalentManager], + [Scopes.Identity.UsersByHandles]: [ + UserRoles.TalentManager, + UserRoles.ProjectManager, + ], +}; diff --git a/src/app.module.ts b/src/app.module.ts index 2d8eed5..3fccf17 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,7 @@ import { TopcoderReportsModule } from "./reports/topcoder/topcoder-reports.modul import { StatisticsModule } from "./statistics/statistics.module"; import { SfdcReportsModule } from "./reports/sfdc/sfdc-reports.module"; import { ChallengesReportsModule } from "./reports/challenges/challenges-reports.module"; +import { IdentityReportsModule } from "./reports/identity/identity-reports.module"; import { ReportsModule } from "./reports/reports.module"; @Module({ @@ -20,6 +21,7 @@ import { ReportsModule } from "./reports/reports.module"; StatisticsModule, SfdcReportsModule, ChallengesReportsModule, + IdentityReportsModule, ReportsModule, HealthModule, ], diff --git a/src/auth/guards/permissions.guard.ts b/src/auth/guards/permissions.guard.ts index 8adf313..a84926b 100644 --- a/src/auth/guards/permissions.guard.ts +++ b/src/auth/guards/permissions.guard.ts @@ -7,14 +7,10 @@ import { } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import { SCOPES_KEY } from "../decorators/scopes.decorator"; -import { UserRoles } from "../../app-constants"; +import { hasAccessToScopes } from "../permissions.util"; @Injectable() export class PermissionsGuard implements CanActivate { - private static readonly adminRoles = new Set( - Object.values(UserRoles).map((role) => role.toLowerCase()), - ); - constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { @@ -33,45 +29,12 @@ export class PermissionsGuard implements CanActivate { throw new UnauthorizedException("You are not authenticated."); } - if (authUser.isMachine) { - const scopes: string[] = authUser.scopes ?? []; - if (this.hasRequiredScope(scopes, requiredScopes)) { - return true; - } - } else { - const roles: string[] = authUser.roles ?? []; - if (this.isAdmin(roles)) { - return true; - } - - const scopes: string[] = authUser.scopes ?? []; - if (this.hasRequiredScope(scopes, requiredScopes)) { - return true; - } + if (hasAccessToScopes(authUser, requiredScopes)) { + return true; } throw new ForbiddenException( "You do not have the required permissions to access this resource.", ); } - - private hasRequiredScope( - scopes: string[], - requiredScopes: string[], - ): boolean { - if (!scopes?.length) { - return false; - } - - const normalizedScopes = scopes.map((scope) => scope?.toLowerCase()); - return requiredScopes.some((scope) => - normalizedScopes.includes(scope?.toLowerCase()), - ); - } - - private isAdmin(roles: string[]): boolean { - return roles.some((role) => - PermissionsGuard.adminRoles.has(role?.toLowerCase()), - ); - } } diff --git a/src/auth/guards/topcoder-reports.guard.spec.ts b/src/auth/guards/topcoder-reports.guard.spec.ts new file mode 100644 index 0000000..5d417bd --- /dev/null +++ b/src/auth/guards/topcoder-reports.guard.spec.ts @@ -0,0 +1,98 @@ +import { ExecutionContext, ForbiddenException } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { Scopes as AppScopes, UserRoles } from "../../app-constants"; +import { Scopes as RequiredScopes } from "../decorators/scopes.decorator"; +import { TopcoderReportsGuard } from "./topcoder-reports.guard"; + +/** + * Test controller used to attach scope metadata to representative topcoder handlers. + * This mirrors how the real controller exposes class-level and method-level scopes. + */ +@RequiredScopes(AppScopes.AllReports, AppScopes.TopcoderReports) +class TestTopcoderReportsController { + /** + * Represents the Recent Member Data route for guard metadata tests. + * Returns no value because the handler body is not exercised in these unit tests. + */ + @RequiredScopes( + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.Member.RecentMemberData, + ) + getRecentMemberData(): undefined { + return undefined; + } + + /** + * Represents a standard topcoder report route without extra role mappings. + * Returns no value because the handler body is not exercised in these unit tests. + */ + get90DayMemberSpend(): undefined { + return undefined; + } + + /** + * Represents the Completed Profiles route that keeps a dedicated role exception. + * Returns no value because the handler body is not exercised in these unit tests. + */ + getCompletedProfiles(): undefined { + return undefined; + } +} + +type TestHandlerName = keyof TestTopcoderReportsController; + +/** + * Builds the minimal execution context surface that the guard reads in these tests. + * @param handlerName Handler name whose metadata should be evaluated. + * @param authUser Auth user payload to expose on the mocked HTTP request. + * @returns ExecutionContext-like object suitable for TopcoderReportsGuard.canActivate. + */ +function createExecutionContext( + handlerName: TestHandlerName, + authUser: { roles?: string[]; scopes?: string[]; isMachine?: boolean }, +): ExecutionContext { + return { + getClass: () => TestTopcoderReportsController, + getHandler: () => TestTopcoderReportsController.prototype[handlerName], + switchToHttp: () => ({ + getRequest: () => ({ + authUser, + }), + }), + } as unknown as ExecutionContext; +} + +describe("TopcoderReportsGuard", () => { + const guard = new TopcoderReportsGuard(new Reflector()); + + it("allows talent managers to access recent member data via route scopes", () => { + expect( + guard.canActivate( + createExecutionContext("getRecentMemberData", { + roles: [UserRoles.TalentManager], + }), + ), + ).toBe(true); + }); + + it("does not expand talent manager access to standard topcoder reports", () => { + expect(() => + guard.canActivate( + createExecutionContext("get90DayMemberSpend", { + roles: [UserRoles.TalentManager], + }), + ), + ).toThrow(ForbiddenException); + }); + + it("preserves the completed profiles talent manager exception", () => { + expect( + guard.canActivate( + createExecutionContext("getCompletedProfiles", { + roles: [UserRoles.TalentManager], + }), + ), + ).toBe(true); + }); +}); diff --git a/src/auth/guards/topcoder-reports.guard.ts b/src/auth/guards/topcoder-reports.guard.ts index ba6b435..ddfe7f3 100644 --- a/src/auth/guards/topcoder-reports.guard.ts +++ b/src/auth/guards/topcoder-reports.guard.ts @@ -5,14 +5,23 @@ import { Injectable, UnauthorizedException, } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; import { Scopes, UserRoles } from "../../app-constants"; +import { SCOPES_KEY } from "../decorators/scopes.decorator"; +import { + AuthUserLike, + getNormalizedRoles, + hasAccessToScopes, +} from "../permissions.util"; @Injectable() export class TopcoderReportsGuard implements CanActivate { - private static readonly adminRoles = new Set( - Object.values(UserRoles).map((role) => role.toLowerCase()), - ); + private static readonly completedProfilesRoles = new Set([ + UserRoles.TalentManager.toLowerCase(), + ]); + + constructor(private readonly reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); @@ -22,19 +31,16 @@ export class TopcoderReportsGuard implements CanActivate { throw new UnauthorizedException("You are not authenticated."); } - if (authUser.isMachine) { - const scopes: string[] = authUser.scopes ?? []; - if (this.hasRequiredScope(scopes)) { - return true; - } + const requiredScopes = this.reflector.getAllAndOverride( + SCOPES_KEY, + [context.getHandler(), context.getClass()], + ) ?? [Scopes.TopcoderReports, Scopes.AllReports]; - throw new ForbiddenException( - "You do not have the required permissions to access this resource.", - ); + if (hasAccessToScopes(authUser, requiredScopes)) { + return true; } - const roles: string[] = authUser.roles ?? []; - if (this.isAdmin(roles)) { + if (this.canAccessCompletedProfiles(context, authUser)) { return true; } @@ -43,17 +49,18 @@ export class TopcoderReportsGuard implements CanActivate { ); } - private hasRequiredScope(scopes: string[]): boolean { - const normalizedScopes = scopes.map((scope) => scope?.toLowerCase()); - return ( - normalizedScopes.includes(Scopes.TopcoderReports.toLowerCase()) || - normalizedScopes.includes(Scopes.AllReports.toLowerCase()) - ); - } + private canAccessCompletedProfiles( + context: ExecutionContext, + authUser: AuthUserLike, + ): boolean { + const handlerName = context.getHandler().name; + + if (handlerName !== "getCompletedProfiles") { + return false; + } - private isAdmin(roles: string[]): boolean { - return roles.some((role) => - TopcoderReportsGuard.adminRoles.has(role?.toLowerCase()), + return getNormalizedRoles(authUser).some((role) => + TopcoderReportsGuard.completedProfilesRoles.has(role), ); } } diff --git a/src/auth/permissions.util.spec.ts b/src/auth/permissions.util.spec.ts new file mode 100644 index 0000000..e4017a8 --- /dev/null +++ b/src/auth/permissions.util.spec.ts @@ -0,0 +1,56 @@ +import { Scopes } from "../app-constants"; +import { hasAccessToScopes, hasRequiredRoleAccess } from "./permissions.util"; + +describe("permissions.util", () => { + it("allows topcoder-prefixed project manager roles for bulk member lookup", () => { + expect( + hasAccessToScopes( + { + roles: ["Topcoder Project Manager"], + }, + [Scopes.Identity.UsersByHandles], + ), + ).toBe(true); + }); + + it("allows topcoder-prefixed talent manager roles for bulk member lookup", () => { + expect( + hasAccessToScopes( + { + roles: ["Topcoder Talent Manager"], + }, + [Scopes.Identity.UsersByHandles], + ), + ).toBe(true); + }); + + it("allows topcoder-prefixed talent manager roles for recent member data", () => { + expect( + hasAccessToScopes( + { + roles: ["Topcoder Talent Manager"], + }, + [Scopes.Member.RecentMemberData], + ), + ).toBe(true); + }); + + it("normalizes comma-separated role claims before checking scoped access", () => { + expect( + hasRequiredRoleAccess("Topcoder Talent Manager, Topcoder User", [ + Scopes.Identity.UsersByHandles, + ]), + ).toBe(true); + }); + + it("does not promote manager roles to all-reports access", () => { + expect( + hasAccessToScopes( + { + roles: ["Topcoder Talent Manager"], + }, + [Scopes.AllReports], + ), + ).toBe(false); + }); +}); diff --git a/src/auth/permissions.util.ts b/src/auth/permissions.util.ts new file mode 100644 index 0000000..53d59ba --- /dev/null +++ b/src/auth/permissions.util.ts @@ -0,0 +1,143 @@ +import { AdminRoles, ScopeRoleAccess } from "../app-constants"; + +export type AuthUserLike = { + isMachine?: boolean; + roles?: string[] | string; + role?: string[] | string; + scopes?: string[] | string; +}; + +const topcoderRolePrefixPattern = /^topcoder\s+/i; + +const adminRoles = new Set( + Object.values(AdminRoles).map((role) => role.toLowerCase()), +); + +const scopedRoleAccess = new Map( + Object.entries(ScopeRoleAccess).map(([scope, roles]) => [ + scope.toLowerCase(), + new Set(roles.map((role) => role.toLowerCase())), + ]), +); + +function normalizeClaims( + values: readonly string[] | string | undefined, + separator: RegExp, +): string[] { + const normalizedValues = Array.isArray(values) + ? values + : typeof values === "string" + ? values.split(separator) + : []; + + return normalizedValues + .map((value) => value?.trim().toLowerCase()) + .filter((value): value is string => !!value); +} + +function normalizeRoles( + values: readonly string[] | string | undefined, +): string[] { + return normalizeClaims(values, /,/).map((role) => + role.replace(topcoderRolePrefixPattern, ""), + ); +} + +function normalizeScopes( + values: readonly string[] | string | undefined, +): string[] { + return normalizeClaims(values, /\s+/); +} + +/** + * Returns normalized role claims from either `roles` or `role`. + */ +export function getNormalizedRoles(authUser?: AuthUserLike): string[] { + return [ + ...normalizeRoles(authUser?.roles), + ...normalizeRoles(authUser?.role), + ]; +} + +/** + * Returns true when the caller has any of the required scopes. + * Scope comparisons are case-insensitive. + */ +export function hasRequiredScope( + scopes: readonly string[] | string | undefined, + requiredScopes: readonly string[] = [], +): boolean { + const normalizedScopes = new Set(normalizeScopes(scopes)); + + if (!normalizedScopes.size || !requiredScopes.length) { + return false; + } + + return requiredScopes.some((scope) => + normalizedScopes.has(scope?.toLowerCase()), + ); +} + +/** + * Returns true when the caller has one of the configured administrator roles. + */ +export function hasAdminRole( + roles: readonly string[] | string | undefined, +): boolean { + return normalizeRoles(roles).some((role) => adminRoles.has(role)); +} + +/** + * Returns true when any required scope is mapped to one of the caller's roles. + */ +export function hasRequiredRoleAccess( + roles: readonly string[] | string | undefined, + requiredScopes: readonly string[] = [], +): boolean { + const normalizedRoles = normalizeRoles(roles); + + if (!normalizedRoles.length || !requiredScopes.length) { + return false; + } + + return requiredScopes.some((scope) => { + const allowedRoles = scopedRoleAccess.get(scope?.toLowerCase()); + return ( + !!allowedRoles && normalizedRoles.some((role) => allowedRoles.has(role)) + ); + }); +} + +/** + * Evaluates report access using the same rules as the request guards: + * machines need scopes, admins get full access, and human users can also + * inherit access from role-to-scope mappings. + */ +export function hasAccessToScopes( + authUser: AuthUserLike | undefined, + requiredScopes: readonly string[] = [], +): boolean { + if (!requiredScopes.length) { + return true; + } + + if (!authUser) { + return false; + } + + if (authUser.isMachine) { + return hasRequiredScope(authUser.scopes, requiredScopes); + } + + const roles = getNormalizedRoles(authUser); + + if (hasAdminRole(roles)) { + return true; + } + + if (hasRequiredRoleAccess(roles, requiredScopes)) { + return true; + } + + return hasRequiredScope(authUser.scopes, requiredScopes); +} diff --git a/src/reports/challenges/challenges-reports.controller.ts b/src/reports/challenges/challenges-reports.controller.ts index 4f2cad7..c5c43bb 100644 --- a/src/reports/challenges/challenges-reports.controller.ts +++ b/src/reports/challenges/challenges-reports.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, + Param, Query, UseGuards, UseInterceptors, @@ -25,6 +26,7 @@ import { SubmissionLinksDto, SubmissionLinksQueryDto, } from "./dtos/submission-links.dto"; +import { ChallengeUsersPathParamDto } from "./dtos/challenge-users.dto"; @ApiTags("Challenges Reports") @ApiProduces("application/json", "text/csv") @@ -83,4 +85,68 @@ export class ChallengesReportsController { const report = await this.reportsService.getSubmissionLinks(query); return report; } + + @Get("/:challengeId/registered-users") + @UseGuards(PermissionsGuard) + @Scopes(AppScopes.AllReports, AppScopes.Challenge.RegisteredUsers) + @ApiBearerAuth() + @ApiOperation({ + summary: "Return the challenge registered users report", + }) + @ApiResponse({ + status: 200, + description: "Export successful.", + }) + async getRegisteredUsers(@Param() params: ChallengeUsersPathParamDto) { + const report = await this.reportsService.getRegisteredUsers(params); + return report; + } + + @Get("/:challengeId/submitters") + @UseGuards(PermissionsGuard) + @Scopes(AppScopes.AllReports, AppScopes.Challenge.Submitters) + @ApiBearerAuth() + @ApiOperation({ + summary: "Return the challenge submitters report", + }) + @ApiResponse({ + status: 200, + description: "Export successful.", + }) + async getSubmitters(@Param() params: ChallengeUsersPathParamDto) { + const report = await this.reportsService.getSubmitters(params); + return report; + } + + @Get("/:challengeId/valid-submitters") + @UseGuards(PermissionsGuard) + @Scopes(AppScopes.AllReports, AppScopes.Challenge.ValidSubmitters) + @ApiBearerAuth() + @ApiOperation({ + summary: "Return the challenge valid submitters report", + }) + @ApiResponse({ + status: 200, + description: "Export successful.", + }) + async getValidSubmitters(@Param() params: ChallengeUsersPathParamDto) { + const report = await this.reportsService.getValidSubmitters(params); + return report; + } + + @Get("/:challengeId/winners") + @UseGuards(PermissionsGuard) + @Scopes(AppScopes.AllReports, AppScopes.Challenge.Winners) + @ApiBearerAuth() + @ApiOperation({ + summary: "Return the challenge winners report", + }) + @ApiResponse({ + status: 200, + description: "Export successful.", + }) + async getWinners(@Param() params: ChallengeUsersPathParamDto) { + const report = await this.reportsService.getWinners(params); + return report; + } } diff --git a/src/reports/challenges/challenges-reports.service.ts b/src/reports/challenges/challenges-reports.service.ts index daf2437..aeefc48 100644 --- a/src/reports/challenges/challenges-reports.service.ts +++ b/src/reports/challenges/challenges-reports.service.ts @@ -9,6 +9,14 @@ import { import { multiValueArrayFilter } from "src/common/filtering"; import { ChallengesReportResponseDto } from "./dtos/challenge.dto"; import { SubmissionLinksQueryDto } from "./dtos/submission-links.dto"; +import { + ChallengeUserRecordDto, + ChallengeUsersPathParamDto, +} from "./dtos/challenge-users.dto"; + +type ChallengeUserReportQueryRow = ChallengeUserRecordDto & { + isMarathonMatch?: boolean | null; +}; @Injectable() export class ChallengesReportsService { @@ -76,4 +84,136 @@ export class ChallengesReportsService { return payments; } + + /** + * Retrieves all users registered for the specified challenge. + * @param filters Path params containing challengeId. + * @returns Registered user records with handle, email, and country details. + * @throws Does not throw. Logs query errors and returns an empty array. + */ + async getRegisteredUsers( + filters: ChallengeUsersPathParamDto, + ): Promise { + this.logger.debug("Starting getRegisteredUsers with filters:", filters); + const query = this.sql.load("reports/challenges/registered-users.sql"); + + try { + const results = await this.db.query(query, [ + filters.challengeId, + ]); + + return results; + } catch (e) { + this.logger.error(e); + return []; + } + } + + /** + * Retrieves users who submitted at least one submission for the specified challenge. + * @param filters Path params containing challengeId. + * @returns Submitter records with core profile fields and the export-specific score columns for the challenge type. + * @throws Does not throw. Logs query errors and returns an empty array. + */ + async getSubmitters( + filters: ChallengeUsersPathParamDto, + ): Promise { + this.logger.debug("Starting getSubmitters with filters:", filters); + const query = this.sql.load("reports/challenges/submitters.sql"); + + try { + const results = await this.db.query(query, [ + filters.challengeId, + ]); + + return this.formatChallengeUserReport(results); + } catch (e) { + this.logger.error(e); + return []; + } + } + + /** + * Retrieves users with at least one passing submission for the specified challenge. + * @param filters Path params containing challengeId. + * @returns Valid submitter records with core profile fields and the export-specific score columns for the challenge type. + * @throws Does not throw. Logs query errors and returns an empty array. + */ + async getValidSubmitters( + filters: ChallengeUsersPathParamDto, + ): Promise { + this.logger.debug("Starting getValidSubmitters with filters:", filters); + const query = this.sql.load("reports/challenges/valid-submitters.sql"); + + try { + const results = await this.db.query(query, [ + filters.challengeId, + ]); + + return this.formatChallengeUserReport(results); + } catch (e) { + this.logger.error(e); + return []; + } + } + + /** + * Retrieves winner records for the specified challenge. + * @param filters Path params containing challengeId. + * @returns Winner records with core profile fields and the export-specific score columns for the challenge type. + * @throws Does not throw. Logs query errors and returns an empty array. + */ + async getWinners( + filters: ChallengeUsersPathParamDto, + ): Promise { + this.logger.debug("Starting getWinners with filters:", filters); + const query = this.sql.load("reports/challenges/winners.sql"); + + try { + const results = await this.db.query(query, [ + filters.challengeId, + ]); + + return this.formatChallengeUserReport(results); + } catch (e) { + this.logger.error(e); + return []; + } + } + + /** + * Normalizes raw challenge user report rows into the exported column shape. + * @param records SQL rows for one challenge report, including the internal Marathon Match flag. + * @returns Export-ready records with either submissionScore or Marathon Match-specific columns. + * @throws Does not throw. It is used as a pure formatter inside the challenge report service methods. + */ + private formatChallengeUserReport( + records: ChallengeUserReportQueryRow[], + ): ChallengeUserRecordDto[] { + if (!records.length) { + return []; + } + + const isMarathonMatch = records.some( + (record) => record.isMarathonMatch === true, + ); + + return records.map((record) => { + const normalized: ChallengeUserRecordDto = { + userId: record.userId, + handle: record.handle, + email: record.email ?? null, + country: record.country ?? null, + }; + + if (isMarathonMatch) { + normalized.provisionalScore = record.provisionalScore ?? null; + normalized.finalRank = record.finalRank ?? null; + return normalized; + } + + normalized.submissionScore = record.submissionScore ?? null; + return normalized; + }); + } } diff --git a/src/reports/challenges/dtos/challenge-users.dto.ts b/src/reports/challenges/dtos/challenge-users.dto.ts new file mode 100644 index 0000000..33bf7b3 --- /dev/null +++ b/src/reports/challenges/dtos/challenge-users.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString } from "class-validator"; + +/** + * Path parameters used to retrieve challenge user reports by challenge ID. + */ +export class ChallengeUsersPathParamDto { + @ApiProperty({ + required: true, + description: "Challenge ID to retrieve user report data for", + }) + @IsString() + @IsNotEmpty() + challengeId: string; +} + +/** + * User record returned by challenge user reports including resolved country. + * Standard challenge submission-based reports expose submissionScore. + * Marathon Match submission-based reports expose provisionalScore from the + * latest submission and finalRank by current effective score, breaking ties by + * earlier submission time. + */ +export interface ChallengeUserRecordDto { + userId: number; + handle: string; + email: string | null; + country: string | null; + submissionScore?: number | null; + provisionalScore?: number | null; + finalRank?: number | null; +} diff --git a/src/reports/challenges/dtos/registrants.dto.ts b/src/reports/challenges/dtos/registrants.dto.ts index 5abfb9c..09097d6 100644 --- a/src/reports/challenges/dtos/registrants.dto.ts +++ b/src/reports/challenges/dtos/registrants.dto.ts @@ -59,4 +59,5 @@ export interface ChallengeRegistrantsResponseDto { challengeCompletedDate: string | null; registrantFinalScore?: number; challengeStatus: string; + challengeType: string; } diff --git a/src/reports/identity/dtos/identity-users.dto.ts b/src/reports/identity/dtos/identity-users.dto.ts new file mode 100644 index 0000000..cc7ddf6 --- /dev/null +++ b/src/reports/identity/dtos/identity-users.dto.ts @@ -0,0 +1,61 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + ArrayNotEmpty, + IsArray, + IsInt, + IsOptional, + IsString, +} from "class-validator"; + +/** + * Query filters for exporting users by role. + */ +export class UsersByRoleQueryDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsInt() + @Type(() => Number) + roleId?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + roleName?: string; +} + +/** + * Query filters for exporting users by group. + */ +export class UsersByGroupQueryDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + groupId?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + groupName?: string; +} + +/** + * Shared identity user payload returned by identity report endpoints. + */ +export interface IdentityUserDto { + userId: number | null; + handle: string; + email: string | null; + country: string | null; +} + +/** + * Request payload for exporting users by handle list. + */ +export class UsersByHandlesBodyDto { + @ApiProperty({ type: [String], required: true }) + @IsArray() + @ArrayNotEmpty() + @IsString({ each: true }) + handles!: string[]; +} diff --git a/src/reports/identity/identity-reports.controller.ts b/src/reports/identity/identity-reports.controller.ts new file mode 100644 index 0000000..a009e76 --- /dev/null +++ b/src/reports/identity/identity-reports.controller.ts @@ -0,0 +1,140 @@ +import { + Body, + Controller, + Get, + Post, + Query, + UploadedFile, + UseGuards, + UseInterceptors, + ValidationPipe, +} from "@nestjs/common"; +import { + ApiBody, + ApiBearerAuth, + ApiConsumes, + ApiOperation, + ApiProduces, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { Scopes as AppScopes } from "src/app-constants"; +import { Scopes } from "src/auth/decorators/scopes.decorator"; +import { PermissionsGuard } from "src/auth/guards/permissions.guard"; +import { CsvResponseInterceptor } from "src/common/interceptors/csv-response.interceptor"; +import { + UsersByHandlesBodyDto, + UsersByGroupQueryDto, + UsersByRoleQueryDto, +} from "./dtos/identity-users.dto"; +import { IdentityReportsService } from "./identity-reports.service"; + +type UploadedHandlesFile = { + originalname?: string; + buffer?: Buffer; +}; + +/** + * Handles identity report endpoints and delegates query execution to the service layer. + */ +@ApiTags("Identity Reports") +@ApiProduces("application/json", "text/csv") +@UseInterceptors(CsvResponseInterceptor) +@Controller("/identity") +export class IdentityReportsController { + constructor(private readonly service: IdentityReportsService) {} + + /** + * Exports users matched by role ID and/or role name. + * @param query Query-string filters for role lookup. + * @returns List of matching identity users. + */ + @Get("/users-by-role") + @UseGuards(PermissionsGuard) + @Scopes(AppScopes.AllReports, AppScopes.Identity.UsersByRole) + @ApiBearerAuth() + @ApiOperation({ summary: "Export users for a given role (by ID or name)" }) + @ApiResponse({ status: 200, description: "Export successful." }) + async getUsersByRole(@Query() query: UsersByRoleQueryDto) { + return this.service.getUsersByRole(query); + } + + /** + * Exports users matched by group UUID/legacy ID and/or group name. + * @param query Query-string filters for group lookup. + * @returns List of matching identity users. + */ + @Get("/users-by-group") + @UseGuards(PermissionsGuard) + @Scopes(AppScopes.AllReports, AppScopes.Identity.UsersByGroup) + @ApiBearerAuth() + @ApiOperation({ + summary: "Export users for a given group (by UUID/legacy ID or name)", + }) + @ApiResponse({ status: 200, description: "Export successful." }) + async getUsersByGroup(@Query() query: UsersByGroupQueryDto) { + return this.service.getUsersByGroup(query); + } + + /** + * Exports user details for the provided list of handles. + * Unknown handles are included with null values for unmatched fields. + * @param body JSON payload containing handles to look up. + * @returns List with one row per requested handle. + */ + @Post("/users-by-handles") + @UseGuards(PermissionsGuard) + @UseInterceptors(FileInterceptor("file")) + @Scopes(AppScopes.AllReports, AppScopes.Identity.UsersByHandles) + @ApiBearerAuth() + @ApiOperation({ + summary: + "Export user details (ID, handle, email, country) for a list of handles", + }) + @ApiConsumes("application/json", "multipart/form-data") + @ApiBody({ + required: true, + schema: { + oneOf: [ + { + type: "object", + required: ["handles"], + properties: { + handles: { + type: "array", + items: { type: "string" }, + description: "List of handles to look up.", + }, + }, + }, + { + type: "object", + required: ["file"], + properties: { + file: { + type: "string", + format: "binary", + description: + "Upload a .txt or .csv file with handles (one per line or comma-separated).", + }, + }, + }, + ], + }, + }) + @ApiResponse({ status: 200, description: "Export successful." }) + async getUsersByHandles( + @Body( + new ValidationPipe({ + transform: true, + whitelist: true, + skipMissingProperties: true, + }), + ) + body: UsersByHandlesBodyDto, + @UploadedFile() file?: UploadedHandlesFile, + ) { + return this.service.getUsersByHandles(body, file); + } +} diff --git a/src/reports/identity/identity-reports.module.ts b/src/reports/identity/identity-reports.module.ts new file mode 100644 index 0000000..fda9f53 --- /dev/null +++ b/src/reports/identity/identity-reports.module.ts @@ -0,0 +1,20 @@ +import { Module } from "@nestjs/common"; +import { CsvSerializer } from "src/common/csv/csv-serializer"; +import { CsvResponseInterceptor } from "src/common/interceptors/csv-response.interceptor"; +import { SqlLoaderService } from "src/common/sql-loader.service"; +import { IdentityReportsController } from "./identity-reports.controller"; +import { IdentityReportsService } from "./identity-reports.service"; + +/** + * Nest module that wires identity report controller, service, and CSV helpers. + */ +@Module({ + controllers: [IdentityReportsController], + providers: [ + IdentityReportsService, + SqlLoaderService, + CsvSerializer, + CsvResponseInterceptor, + ], +}) +export class IdentityReportsModule {} diff --git a/src/reports/identity/identity-reports.service.ts b/src/reports/identity/identity-reports.service.ts new file mode 100644 index 0000000..e9976b4 --- /dev/null +++ b/src/reports/identity/identity-reports.service.ts @@ -0,0 +1,191 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import { extname } from "node:path"; +import { alpha3ToCountryName } from "src/common/country.util"; +import { Logger } from "src/common/logger"; +import { SqlLoaderService } from "src/common/sql-loader.service"; +import { DbService } from "src/db/db.service"; +import { + IdentityUserDto, + UsersByHandlesBodyDto, + UsersByGroupQueryDto, + UsersByRoleQueryDto, +} from "./dtos/identity-users.dto"; + +const SUPPORTED_HANDLES_UPLOAD_EXTENSIONS = new Set([".txt", ".csv"]); + +type UsersByHandlesRow = { + userId: number | null; + handle: string; + email: string | null; + country: string | null; +}; + +type UploadedHandlesFile = { + originalname?: string; + buffer?: Buffer; +}; + +/** + * Provides identity-focused report queries and maps HTTP filters to SQL parameters. + */ +@Injectable() +export class IdentityReportsService { + private readonly logger = new Logger(IdentityReportsService.name); + + /** + * Initializes the service with database access and SQL template loading. + * @param db PostgreSQL query service. + * @param sql SQL file loader for report templates. + */ + constructor( + private readonly db: DbService, + private readonly sql: SqlLoaderService, + ) {} + + /** + * Queries users assigned to a role by role ID and/or role name. + * @param filters Role filters from the request query string. + * @returns Matching users with ID, handle, and primary email. + */ + async getUsersByRole( + filters: UsersByRoleQueryDto, + ): Promise { + this.logger.debug("Starting getUsersByRole with filters:", filters); + const query = this.sql.load("reports/identity/users-by-role.sql"); + + try { + const results = await this.db.query(query, [ + filters.roleId ?? null, + filters.roleName ?? null, + ]); + + return results; + } catch (e) { + this.logger.error(e); + return []; + } + } + + /** + * Queries users in a group by group UUID/legacy ID and/or group name. + * @param filters Group filters from the request query string. + * @returns Matching users with ID, handle, and primary email. + */ + async getUsersByGroup( + filters: UsersByGroupQueryDto, + ): Promise { + this.logger.debug("Starting getUsersByGroup with filters:", filters); + const query = this.sql.load("reports/identity/users-by-group.sql"); + + try { + const results = await this.db.query(query, [ + filters.groupId ?? null, + filters.groupName ?? null, + ]); + + return results; + } catch (e) { + this.logger.error(e); + return []; + } + } + + /** + * Queries user details for each submitted handle, preserving all input handles. + * Unknown handles are returned with null user details. + * @param body Request body containing a non-empty list of handles. + * @param file Optional `.txt` or `.csv` upload with handles. + * @returns One row per input handle with user ID, handle, email, and country. + * @throws Does not throw; query failures are logged and return an empty array. + */ + async getUsersByHandles( + body: UsersByHandlesBodyDto, + file?: UploadedHandlesFile, + ): Promise { + const handles = this.resolveHandles(body, file); + this.logger.debug("Starting getUsersByHandles with handle count:", { + count: handles.length, + }); + const query = this.sql.load("reports/identity/users-by-handles.sql"); + + try { + const results = await this.db.query(query, [handles]); + + return results.map((row) => ({ + userId: row.userId, + handle: row.handle, + email: row.email, + country: alpha3ToCountryName(row.country) ?? row.country, + })); + } catch (e) { + this.logger.error(e); + return []; + } + } + + /** + * Resolves handles from either JSON body input or uploaded text/CSV content. + * @param body Request JSON body. + * @param file Optional uploaded file. + * @returns Normalized handle list in caller-provided order. + * @throws BadRequestException when neither mode provides at least one handle. + */ + private resolveHandles( + body: UsersByHandlesBodyDto | undefined, + file?: UploadedHandlesFile, + ): string[] { + if (Array.isArray(body?.handles) && body.handles.length > 0) { + return body.handles; + } + + if (file) { + return this.parseHandlesFromFile(file); + } + + throw new BadRequestException( + "Provide either a non-empty 'handles' array or a .txt/.csv file upload.", + ); + } + + /** + * Parses handles from uploaded `.txt`/`.csv` content. + * @param file Uploaded file provided by multipart form-data. + * @returns Ordered handles extracted from file contents. + * @throws BadRequestException when type is unsupported or file has no handles. + */ + private parseHandlesFromFile(file: UploadedHandlesFile): string[] { + const extension = extname(file.originalname ?? "").toLowerCase(); + if (!SUPPORTED_HANDLES_UPLOAD_EXTENSIONS.has(extension)) { + throw new BadRequestException( + "Unsupported file type. Only .txt and .csv uploads are allowed.", + ); + } + + const content = (file.buffer ?? Buffer.alloc(0)) + .toString("utf8") + .replace(/^\uFEFF/, ""); + + const handles = content + .split(/\r?\n/) + .flatMap((line) => line.split(",")) + .map((value) => + value + .trim() + .replace(/^"(.*)"$/, "$1") + .trim(), + ) + .filter((value) => value.length > 0); + + if (handles.length > 1 && /^handles?$/i.test(handles[0])) { + handles.shift(); + } + + if (handles.length === 0) { + throw new BadRequestException( + "Uploaded file does not contain any handles.", + ); + } + + return handles; + } +} diff --git a/src/reports/report-directory.data.spec.ts b/src/reports/report-directory.data.spec.ts new file mode 100644 index 0000000..1ad5bc4 --- /dev/null +++ b/src/reports/report-directory.data.spec.ts @@ -0,0 +1,97 @@ +import { AdminRoles, Scopes, UserRoles } from "../app-constants"; +import { + REPORTS_DIRECTORY, + getAccessibleReportsDirectory, +} from "./report-directory.data"; + +describe("getAccessibleReportsDirectory", () => { + it("returns the full directory for administrators", () => { + expect( + getAccessibleReportsDirectory({ + roles: [AdminRoles.Admin], + }), + ).toEqual(REPORTS_DIRECTORY); + }); + + it("returns public reports plus all challenge reports for product managers", () => { + const directory = getAccessibleReportsDirectory({ + roles: [UserRoles.ProductManager], + }); + + expect(Object.keys(directory).sort()).toEqual(["challenges", "statistics"]); + expect(directory.challenges?.reports).toHaveLength( + REPORTS_DIRECTORY.challenges?.reports.length ?? 0, + ); + expect(directory.identity).toBeUndefined(); + expect(directory.sfdc).toBeUndefined(); + expect(directory.topcoder).toBeUndefined(); + }); + + it("returns challenge, member, and role-mapped identity reports for talent managers", () => { + const directory = getAccessibleReportsDirectory({ + roles: [UserRoles.TalentManager], + }); + + expect(Object.keys(directory).sort()).toEqual([ + "challenges", + "identity", + "member", + "statistics", + ]); + expect(directory.identity?.reports.map((report) => report.path)).toEqual([ + "/identity/users-by-handles", + ]); + expect(directory.member?.reports.map((report) => report.path)).toEqual([ + "/member/recent-member-data", + ]); + }); + + it("returns bulk member lookup for topcoder project managers", () => { + const directory = getAccessibleReportsDirectory({ + roles: [UserRoles.ProjectManager], + }); + + expect(directory.identity?.reports.map((report) => report.path)).toEqual([ + "/identity/users-by-handles", + ]); + }); + + it("returns only scope-matched reports plus public reports for machine tokens", () => { + const directory = getAccessibleReportsDirectory({ + isMachine: true, + scopes: [Scopes.Challenge.SubmissionLinks], + }); + + expect(directory.challenges?.reports.map((report) => report.path)).toEqual([ + "/challenges/submission-links", + ]); + expect(directory.identity).toBeUndefined(); + expect(directory.sfdc).toBeUndefined(); + expect(directory.statistics?.reports.length).toBeGreaterThan(0); + expect(directory.topcoder).toBeUndefined(); + }); + + it("returns all topcoder-scoped report categories for machine tokens", () => { + const directory = getAccessibleReportsDirectory({ + isMachine: true, + scopes: [Scopes.TopcoderReports], + }); + + expect( + directory.topcoder?.reports.map((report) => report.path), + ).not.toContain("/topcoder/recent-member-data"); + expect( + directory.topcoder?.reports.map((report) => report.path), + ).not.toContain("/topcoder/member-payment-accrual"); + expect(directory.member?.reports.map((report) => report.path)).toEqual([ + "/member/recent-member-data", + ]); + expect(directory.admin?.reports.map((report) => report.path)).toEqual([ + "/admin/member-payment-accrual", + ]); + }); + + it("returns an empty directory when no JWT user is present", () => { + expect(getAccessibleReportsDirectory()).toEqual({}); + }); +}); diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index 03c8681..5e91355 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -1,10 +1,19 @@ +import { Scopes as AppScopes } from "../app-constants"; +import { AuthUserLike, hasAccessToScopes } from "../auth/permissions.util"; import { ChallengeStatus } from "./challenges/dtos/challenge-status.enum"; -export type ReportGroupKey = "challenges" | "sfdc" | "statistics" | "topcoder"; +export type ReportGroupKey = + | "challenges" + | "sfdc" + | "statistics" + | "topcoder" + | "member" + | "admin" + | "identity"; -type HttpMethod = "GET"; +type HttpMethod = "GET" | "POST"; -type ParameterLocation = "query" | "path"; +type ParameterLocation = "query" | "path" | "body"; type ReportParameterType = | "string" @@ -39,21 +48,111 @@ export type ReportGroup = { reports: AvailableReport[]; }; -export type ReportsDirectory = Record; +export type ReportsDirectory = Partial>; + +type RegisteredReport = AvailableReport & { + requiredScopes: readonly string[]; +}; + +type RegisteredReportGroup = Omit & { + reports: RegisteredReport[]; +}; + +type RegisteredReportsDirectory = Record; const report = ( name: string, path: string, description: string, + requiredScopes: readonly string[] = [], parameters: ReportParameter[] = [], -): AvailableReport => ({ +): RegisteredReport => ({ name, path, description, method: "GET", parameters, + requiredScopes, }); +const postReport = ( + name: string, + path: string, + description: string, + requiredScopes: readonly string[] = [], + parameters: ReportParameter[] = [], +): RegisteredReport => ({ + name, + path, + description, + method: "POST", + parameters, + requiredScopes, +}); + +const challengeReport = ( + name: string, + path: string, + description: string, + scope: string, + parameters: ReportParameter[] = [], +): RegisteredReport => + report(name, path, description, [AppScopes.AllReports, scope], parameters); + +const identityReport = ( + name: string, + path: string, + description: string, + scope: string, + parameters: ReportParameter[] = [], +): RegisteredReport => + report(name, path, description, [AppScopes.AllReports, scope], parameters); + +const identityPostReport = ( + name: string, + path: string, + description: string, + scope: string, + parameters: ReportParameter[] = [], +): RegisteredReport => + postReport( + name, + path, + description, + [AppScopes.AllReports, scope], + parameters, + ); + +const sfdcReport = ( + name: string, + path: string, + description: string, + scope: string, + parameters: ReportParameter[] = [], +): RegisteredReport => + report(name, path, description, [AppScopes.AllReports, scope], parameters); + +const topcoderReport = ( + name: string, + path: string, + description: string, + parameters: ReportParameter[] = [], +): RegisteredReport => + report( + name, + path, + description, + [AppScopes.AllReports, AppScopes.TopcoderReports], + parameters, + ); + +const publicReport = ( + name: string, + path: string, + description: string, + parameters: ReportParameter[] = [], +): RegisteredReport => report(name, path, description, [], parameters); + const challengeStatusParam: ReportParameter = { name: "challengeStatus", type: "enum[]", @@ -137,6 +236,14 @@ const handlesParam: ReportParameter = { location: "query", }; +const handlesBodyParam: ReportParameter = { + name: "handles", + type: "string[]", + description: "List of user handles to look up", + required: true, + location: "body", +}; + const minPaymentParam: ReportParameter = { name: "minPaymentAmount", type: "number", @@ -195,6 +302,22 @@ const registrantCountriesParam: ReportParameter = { required: true, }; +const challengeIdParam: ReportParameter = { + name: "challengeId", + type: "string", + description: "Challenge ID to retrieve report data for", + location: "path", + required: true, +}; + +const challengeSubmitterDataParam: ReportParameter = { + name: "challengeId", + type: "string", + description: "Challenge ID to retrieve submitter profile data for", + location: "query", + required: true, +}; + const marathonMatchHandleParam: ReportParameter = { name: "handle", type: "string", @@ -203,45 +326,133 @@ const marathonMatchHandleParam: ReportParameter = { required: true, }; -export const REPORTS_DIRECTORY: ReportsDirectory = { +const roleIdParam: ReportParameter = { + name: "roleId", + type: "number", + description: "Role ID", + location: "query", +}; + +const roleNameParam: ReportParameter = { + name: "roleName", + type: "string", + description: "Role name", + location: "query", +}; + +const groupIdParam: ReportParameter = { + name: "groupId", + type: "string", + description: "Group UUID or legacy numeric group ID", + location: "query", +}; + +const groupNameParam: ReportParameter = { + name: "groupName", + type: "string", + description: "Group name", + location: "query", +}; + +const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { challenges: { label: "Challenges Reports", basePath: "/challenges", reports: [ - report( + challengeReport( "Challenge History", "/challenges", "Return the challenge history report", + AppScopes.Challenge.History, challengeHistoryFilters, ), - report( + challengeReport( "Challenge Registrants", "/challenges/registrants", "Return the challenge registrants history report", + AppScopes.Challenge.Registrants, challengeHistoryFilters, ), - report( + challengeReport( "Submission Links", "/challenges/submission-links", "Return the submission links report", + AppScopes.Challenge.SubmissionLinks, submissionLinksFilters, ), + challengeReport( + "Challenge Registered Users", + "/challenges/:challengeId/registered-users", + "Return the challenge registered users report", + AppScopes.Challenge.RegisteredUsers, + [challengeIdParam], + ), + challengeReport( + "Challenge Submitters", + "/challenges/:challengeId/submitters", + "Return the challenge submitters report. Marathon Match exports use the latest submission provisionalScore and current effective rank, with earlier submission times winning score ties.", + AppScopes.Challenge.Submitters, + [challengeIdParam], + ), + challengeReport( + "Challenge Valid Submitters", + "/challenges/:challengeId/valid-submitters", + "Return the challenge valid submitters report. Marathon Match exports use the latest submission provisionalScore and current effective rank, with earlier submission times winning score ties.", + AppScopes.Challenge.ValidSubmitters, + [challengeIdParam], + ), + challengeReport( + "Challenge Winners", + "/challenges/:challengeId/winners", + "Return the challenge winners report with placement winners only. Marathon Match exports include provisionalScore and the challenge-result finalRank.", + AppScopes.Challenge.Winners, + [challengeIdParam], + ), + ], + }, + identity: { + label: "Identity Reports", + basePath: "/identity", + reports: [ + identityReport( + "Users by Role", + "/identity/users-by-role", + "Export user ID, handle, and email for all users assigned to the specified role", + AppScopes.Identity.UsersByRole, + [roleIdParam, roleNameParam], + ), + identityReport( + "Users by Group", + "/identity/users-by-group", + "Export user ID, handle, and email for all users belonging to the specified group", + AppScopes.Identity.UsersByGroup, + [groupIdParam, groupNameParam], + ), + identityPostReport( + "Users by Handles", + "/identity/users-by-handles", + "Export user ID, handle, email, and country for each supplied handle; unknown handles return empty fields", + AppScopes.Identity.UsersByHandles, + [handlesBodyParam], + ), ], }, sfdc: { label: "SFDC Reports", basePath: "/sfdc", reports: [ - report( + sfdcReport( "Payments", "/sfdc/payments", "SFDC Payments report", + AppScopes.SFDC.PaymentsReport, paymentsFilters, ), - report( + sfdcReport( "BA Fees", "/sfdc/ba-fees", "Report of BA to fee / member payment", + AppScopes.SFDC.BA, baFeesDateParams, ), ], @@ -250,162 +461,167 @@ export const REPORTS_DIRECTORY: ReportsDirectory = { label: "Statistics", basePath: "/statistics", reports: [ - report( + publicReport( "SRM Top Rated", "/statistics/srm/top-rated", "Highest rated SRMs (static)", ), - report( + publicReport( "SRM Country Ratings", "/statistics/srm/country-ratings", "SRM country ratings (static)", ), - report( + publicReport( "SRM Competitions Count", "/statistics/srm/competitions-count", "SRM number of competitions (static)", ), - report( + publicReport( "MM Top Rated", "/statistics/mm/top-rated", "Highest rated Marathon Matches (static)", ), - report( + publicReport( "MM Country Ratings", "/statistics/mm/country-ratings", "Marathon Match country ratings (static)", ), - report( + publicReport( "MM Top 10 Finishes", "/statistics/mm/top-10-finishes", "Marathon Match Top 10 finishes (static)", ), - report( + publicReport( "MM Competitions Count", "/statistics/mm/competitions-count", "Marathon Match number of competitions (static)", ), - report( + publicReport( "Member Count", "/statistics/general/member-count", "Total number of member records", ), - report( + publicReport( "Total Prizes", "/statistics/general/total-prizes", "Total amount of all payments", ), - report( + publicReport( "Completed Challenges", "/statistics/general/completed-challenges", "Total number of completed challenges", ), - report( + publicReport( "Countries Represented", "/statistics/general/countries-represented", "Member count by country (desc)", ), - report( + publicReport( "First Place by Country", "/statistics/general/first-place-by-country", "First place finishes by country (desc)", ), - report( + publicReport( "Copiloted Challenges", "/statistics/general/copiloted-challenges", "Copiloted challenges by member (desc)", ), - report( + publicReport( "Reviews by Member", "/statistics/general/reviews-by-member", "Review participation by member (desc)", ), - report( + publicReport( "UI Design Wins", "/statistics/design/ui-design-wins", "Design 'Challenge' wins by member (desc)", ), - report( + publicReport( "Design First2Finish Wins", "/statistics/design/f2f-wins", "Design First2Finish wins by member (desc)", ), - report( + publicReport( "LUX First Place Wins", "/statistics/design/lux-first-place-wins", "Design LUX first place wins by member (desc)", ), - report( + publicReport( "LUX Placements", "/statistics/design/lux-placements", "Design LUX placements by member (desc)", ), - report( + publicReport( "RUX Placements", "/statistics/design/rux-placements", "Design RUX placements by member (desc)", ), - report( + publicReport( "First-time Design Submitters", "/statistics/design/first-time-submitters", "First-time design submitters in last 3 months", ), - report( + publicReport( "Design Countries Represented", "/statistics/design/countries-represented", "Design submitters by country (desc)", ), - report( + publicReport( "Design First Place by Country", "/statistics/design/first-place-by-country", "Design first place finishes by country (desc)", ), - report( + publicReport( "RUX First Place Wins", "/statistics/design/rux-first-place-wins", "RUX first place design challenge wins by member (desc)", ), - report( + publicReport( "Wireframe Wins", "/statistics/design/wireframe-wins", "Design wireframe challenge wins by member (desc)", ), - report( + publicReport( "Development Challenge Wins", "/statistics/development/code-wins", "Development challenge wins by member (desc)", ), - report( + publicReport( "Development First2Finish Wins", "/statistics/development/f2f-wins", "Development First2Finish wins by member (desc)", ), - report( + publicReport( "Prototype Wins", "/statistics/development/prototype-wins", "Development prototype challenge wins by member (desc)", ), - report( + publicReport( "Development First Place Wins", "/statistics/development/first-place-wins", "Development overall wins by member (desc)", ), - report( + publicReport( "First-time Development Submitters", "/statistics/development/first-time-submitters", "First-time development submitters in last 3 months", ), - report( + publicReport( "Development Countries Represented", "/statistics/development/countries-represented", "Development submitters by country (desc)", ), - report( + publicReport( + "Development First Place by Country", + "/statistics/development/first-place-by-country", + "Development first place finishes by country (desc)", + ), + publicReport( "Development Challenges by Technology", "/statistics/development/challenges-technology", "Development challenges by standardized skill (desc)", ), - report( + publicReport( "QA Wins", "/statistics/qa/wins", "Quality Assurance challenge wins by member (desc)", @@ -416,136 +632,237 @@ export const REPORTS_DIRECTORY: ReportsDirectory = { label: "Topcoder Reports", basePath: "/topcoder", reports: [ - report( + topcoderReport( "Member Count", "/topcoder/member-count", "Total number of active members", ), - report( + topcoderReport( "Registrant Countries", "/topcoder/registrant-countries", "Countries of all registrants for the specified challenge", [registrantCountriesParam], ), - report( + topcoderReport( + "challenge_submitter_data", + "/topcoder/challenge_submitter_data", + "Submitter profile data for a challenge, with Marathon Match placements and scores", + [challengeSubmitterDataParam], + ), + topcoderReport( "Marathon Match Stats", "/topcoder/mm-stats/:handle", "Marathon match performance snapshot for a specific handle", [marathonMatchHandleParam], ), - report( + topcoderReport( "Total Copilots", "/topcoder/total-copilots", "Total number of Copilots", ), - report( + topcoderReport( "Weekly Active Copilots", "/topcoder/weekly-active-copilots", "Weekly challenge and copilot counts by track for the last six months", ), - report( + topcoderReport( "Weekly Member Participation", "/topcoder/weekly-member-participation", "Weekly distinct registrants and submitters for the provided date range (defaults to last five weeks)", [paymentsStartDateParam, paymentsEndDateParam], ), - report( - "Member Payment Accrual", - "/topcoder/member-payment-accrual", - "Member payment accruals for the provided date range (defaults to last 3 months)", - [paymentsStartDateParam, paymentsEndDateParam], - ), - report( - "Recent Member Data", - "/topcoder/recent-member-data", - "Members who registered and were paid since the start date (defaults to Jan 1, 2024)", - [paymentsStartDateParam], - ), - report( + topcoderReport( "90 Day Member Spend", "/topcoder/90-day-member-spend", "Total gross amount paid to members in the last 90 days", ), - report( + topcoderReport( "90 Day Members Paid", "/topcoder/90-day-members-paid", "Total number of distinct members paid in the last 90 days", ), - report( + topcoderReport( "90 Day New Members", "/topcoder/90-day-new-members", "Total number of new active members created in the last 90 days", ), - report( + topcoderReport( "90 Day Active Copilots", "/topcoder/90-day-active-copilots", "Total number of distinct copilots active in the last 90 days", ), - report( + topcoderReport( "90 Day User Login", "/topcoder/90-day-user-login", "Total number of active members who logged in during the last 90 days", ), - report( + topcoderReport( "90 Day Challenge Volume", "/topcoder/90-day-challenge-volume", "Total number of challenges launched in the last 90 days", ), - report( + topcoderReport( "90 Day Challenge Duration", "/topcoder/90-day-challenge-duration", "Total duration and count of completed challenges in the last 90 days", ), - report( + topcoderReport( "90 Day Challenge Registrants", "/topcoder/90-day-challenge-registrants", "Distinct challenge registrants and submitters in the last 90 days", ), - report( + topcoderReport( "90 Day Challenge Submitters", "/topcoder/90-day-challenge-submitters", "Distinct challenge registrants and submitters in the last 90 days", ), - report( + topcoderReport( "90 Day Challenge Member Cost", "/topcoder/90-day-challenge-member-cost", "Member payment totals and averages for challenges completed in the last 90 days", ), - report( + topcoderReport( "90 Day Task Member Cost", "/topcoder/90-day-task-member-cost", "Member payment totals and averages for tasks completed in the last 90 days", ), - report( + topcoderReport( "90 Day Fulfillment", "/topcoder/90-day-fulfillment", "Share of challenges completed versus cancelled in the last 90 days", ), - report( + topcoderReport( "90 Day Fulfillment With Tasks", "/topcoder/90-day-fulfillment-with-tasks", "Share of challenges and tasks completed versus cancelled in the last 90 days", ), - report( + topcoderReport( "Weekly Challenge Fulfillment", "/topcoder/weekly-challenge-fulfillment", "Weekly share of challenges completed versus cancelled for the last four weeks", ), - report( + topcoderReport( "Weekly Challenge Volume", "/topcoder/weekly-challenge-volume", "Weekly challenge counts by task indicator for the last four weeks", ), - report( + topcoderReport( "90 Day Membership Participation Funnel", "/topcoder/90-day-membership-participation-funnel", "New member counts with design and development participation indicators for the last 90 days", ), - report( + topcoderReport( "Membership Participation Funnel Data", "/topcoder/membership-participation-funnel-data", "Weekly new member counts with design and development participation indicators for the last four weeks", ), ], }, + member: { + label: "Member Reports", + basePath: "/member", + reports: [ + report( + "Recent Member Data", + "/member/recent-member-data", + "Members who registered and were paid since the start date (defaults to Jan 1, 2024)", + [ + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.Member.RecentMemberData, + ], + [paymentsStartDateParam], + ), + ], + }, + admin: { + label: "Admin Reports", + basePath: "/admin", + reports: [ + topcoderReport( + "Member Payment Accrual", + "/admin/member-payment-accrual", + "Member payment accruals for the provided date range (defaults to last 3 months)", + [paymentsStartDateParam, paymentsEndDateParam], + ), + ], + }, }; + +function toAvailableReport( + reportDefinition: RegisteredReport, +): AvailableReport { + return { + description: reportDefinition.description, + method: reportDefinition.method, + name: reportDefinition.name, + parameters: reportDefinition.parameters, + path: reportDefinition.path, + }; +} + +function toReportGroup(group: RegisteredReportGroup): ReportGroup { + return { + ...group, + reports: group.reports.map(toAvailableReport), + }; +} + +/** + * Lists every scope that can unlock at least one catalog entry. + * The directory endpoints use this to allow callers who can access any report. + */ +export const REPORTS_DIRECTORY_REQUIRED_SCOPES = Array.from( + new Set( + Object.values(REGISTERED_REPORTS_DIRECTORY).flatMap((group) => + group.reports.flatMap((reportDefinition) => + reportDefinition.requiredScopes.filter( + (scope) => scope !== AppScopes.AllReports, + ), + ), + ), + ), +); + +export const REPORTS_DIRECTORY: ReportsDirectory = Object.fromEntries( + Object.entries(REGISTERED_REPORTS_DIRECTORY).map(([key, group]) => [ + key, + toReportGroup(group), + ]), +) as ReportsDirectory; + +/** + * Returns the subset of the report catalog that the authenticated caller can run. + * Empty groups are omitted from the response. + */ +export function getAccessibleReportsDirectory( + authUser?: AuthUserLike, +): ReportsDirectory { + if (!authUser) { + return {}; + } + + const accessibleGroups = Object.entries(REGISTERED_REPORTS_DIRECTORY).flatMap( + ([key, group]) => { + const accessibleReports = group.reports.filter((reportDefinition) => + hasAccessToScopes(authUser, reportDefinition.requiredScopes), + ); + + if (!accessibleReports.length) { + return []; + } + + return [ + [ + key, + { + ...group, + reports: accessibleReports.map(toAvailableReport), + }, + ] as const, + ]; + }, + ); + + return Object.fromEntries(accessibleGroups) as ReportsDirectory; +} diff --git a/src/reports/reports.controller.ts b/src/reports/reports.controller.ts index 493ec76..eb3702c 100644 --- a/src/reports/reports.controller.ts +++ b/src/reports/reports.controller.ts @@ -1,33 +1,41 @@ -import { Controller, Get, UseGuards } from "@nestjs/common"; +import { Controller, Get, Req, UseGuards } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { PermissionsGuard } from "src/auth/guards/permissions.guard"; import { Scopes } from "src/auth/decorators/scopes.decorator"; import { Scopes as AppScopes } from "src/app-constants"; -import { REPORTS_DIRECTORY, ReportsDirectory } from "./report-directory.data"; +import { AuthUserLike } from "src/auth/permissions.util"; +import { + REPORTS_DIRECTORY_REQUIRED_SCOPES, + ReportsDirectory, + getAccessibleReportsDirectory, +} from "./report-directory.data"; @ApiTags("Reports") @Controller() export class ReportsController { @Get() @UseGuards(PermissionsGuard) - @Scopes(AppScopes.AllReports) + @Scopes(AppScopes.AllReports, ...REPORTS_DIRECTORY_REQUIRED_SCOPES) @ApiBearerAuth() @ApiOperation({ - summary: "List available report endpoints grouped by sub-path", + summary: + "List available report endpoints grouped by category and filtered by the caller's permissions", }) - getReports(): ReportsDirectory { - return REPORTS_DIRECTORY; + getReports(@Req() request: { authUser?: AuthUserLike }): ReportsDirectory { + return getAccessibleReportsDirectory(request.authUser); } @Get("/directory") @UseGuards(PermissionsGuard) - @Scopes(AppScopes.AllReports) + @Scopes(AppScopes.AllReports, ...REPORTS_DIRECTORY_REQUIRED_SCOPES) @ApiBearerAuth() @ApiOperation({ summary: - "List available report endpoints grouped by sub-path (alias for /v6/reports)", + "List available report endpoints grouped by category and filtered by the caller's permissions (alias for /v6/reports)", }) - getReportsDirectory(): ReportsDirectory { - return REPORTS_DIRECTORY; + getReportsDirectory( + @Req() request: { authUser?: AuthUserLike }, + ): ReportsDirectory { + return getAccessibleReportsDirectory(request.authUser); } } diff --git a/src/reports/topcoder/dto/challenge-submitter-data.dto.ts b/src/reports/topcoder/dto/challenge-submitter-data.dto.ts new file mode 100644 index 0000000..a1540d8 --- /dev/null +++ b/src/reports/topcoder/dto/challenge-submitter-data.dto.ts @@ -0,0 +1,9 @@ +import { Transform } from "class-transformer"; +import { IsNotEmpty, IsString } from "class-validator"; + +export class ChallengeSubmitterDataQueryDto { + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsString() + @IsNotEmpty() + challengeId!: string; +} diff --git a/src/reports/topcoder/dto/completed-profiles.dto.ts b/src/reports/topcoder/dto/completed-profiles.dto.ts new file mode 100644 index 0000000..f0d7ed4 --- /dev/null +++ b/src/reports/topcoder/dto/completed-profiles.dto.ts @@ -0,0 +1,65 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { + IsBoolean, + IsNumberString, + IsOptional, + IsString, +} from "class-validator"; + +export class CompletedProfilesQueryDto { + @ApiPropertyOptional({ + description: "Filter by country code (ISO 3166-1 alpha-2)", + example: "US", + }) + @IsOptional() + @IsString() + countryCode?: string; + + @ApiPropertyOptional({ + description: "Filter to members who are currently open to work", + example: true, + }) + @IsOptional() + @Transform(({ value }) => + value === undefined || value === null + ? undefined + : value === true || value === "true", + ) + @IsBoolean() + openToWork?: boolean; + + @ApiPropertyOptional({ + name: "skillId", + description: "Filter by member skill IDs", + type: String, + isArray: true, + example: ["4b0f5f0a-1234-5678-9abc-def012345678"], + }) + @IsOptional() + @Transform(({ value }) => { + if (value === undefined || value === null) { + return undefined; + } + const values = Array.isArray(value) ? value : [value]; + return values.map((v) => String(v)).filter((v) => v.trim().length > 0); + }) + @IsString({ each: true }) + skillId?: string[]; + + @ApiPropertyOptional({ + description: "Page number (1-based)", + example: "1", + }) + @IsOptional() + @IsNumberString() + page?: string; + + @ApiPropertyOptional({ + description: "Number of records per page", + example: "50", + }) + @IsOptional() + @IsNumberString() + perPage?: string; +} diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index 63aec82..4e9797a 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -8,28 +8,33 @@ import { } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { TopcoderReportsService } from "./topcoder-reports.service"; +import { ChallengeSubmitterDataQueryDto } from "./dto/challenge-submitter-data.dto"; import { RegistrantCountriesQueryDto } from "./dto/registrant-countries.dto"; import { MemberPaymentAccrualQueryDto } from "./dto/member-payment-accrual.dto"; import { RecentMemberDataQueryDto } from "./dto/recent-member-data.dto"; import { WeeklyMemberParticipationQueryDto } from "./dto/weekly-member-participation.dto"; +import { CompletedProfilesQueryDto } from "./dto/completed-profiles.dto"; import { TopcoderReportsGuard } from "../../auth/guards/topcoder-reports.guard"; import { CsvResponseInterceptor } from "../../common/interceptors/csv-response.interceptor"; +import { Scopes as RequiredScopes } from "../../auth/decorators/scopes.decorator"; +import { Scopes as AppScopes } from "../../app-constants"; @ApiTags("Topcoder Reports") @ApiBearerAuth() @UseGuards(TopcoderReportsGuard) @UseInterceptors(CsvResponseInterceptor) -@Controller("/topcoder") +@RequiredScopes(AppScopes.AllReports, AppScopes.TopcoderReports) +@Controller() export class TopcoderReportsController { constructor(private readonly reports: TopcoderReportsService) {} - @Get("/member-count") + @Get("/topcoder/member-count") @ApiOperation({ summary: "Total number of active members" }) getMemberCount() { return this.reports.getMemberCount(); } - @Get("/registrant-countries") + @Get("/topcoder/registrant-countries") @ApiOperation({ summary: "Countries of all registrants for the specified challenge", }) @@ -38,7 +43,17 @@ export class TopcoderReportsController { return this.reports.getRegistrantCountries(challengeId); } - @Get("/mm-stats/:handle") + @Get("/topcoder/challenge_submitter_data") + @ApiOperation({ + summary: + "Submitter profile data for a challenge, with Marathon Match placements and scores", + }) + getChallengeSubmitterData(@Query() query: ChallengeSubmitterDataQueryDto) { + const { challengeId } = query; + return this.reports.getChallengeSubmitterData(challengeId); + } + + @Get("/topcoder/mm-stats/:handle") @ApiOperation({ summary: "Marathon match performance snapshot for a specific handle", }) @@ -46,13 +61,13 @@ export class TopcoderReportsController { return this.reports.getMarathonMatchStats(handle); } - @Get("/total-copilots") + @Get("/topcoder/total-copilots") @ApiOperation({ summary: "Total number of Copilots" }) getTotalCopilots() { return this.reports.getTotalCopilots(); } - @Get("/weekly-active-copilots") + @Get("/topcoder/weekly-active-copilots") @ApiOperation({ summary: "Weekly challenge and copilot counts by track for the last six months", @@ -61,7 +76,7 @@ export class TopcoderReportsController { return this.reports.getWeeklyActiveCopilots(); } - @Get("/weekly-member-participation") + @Get("/topcoder/weekly-member-participation") @ApiOperation({ summary: "Weekly distinct registrants and submitters for the provided date range (defaults to last five weeks)", @@ -73,7 +88,7 @@ export class TopcoderReportsController { return this.reports.getWeeklyMemberParticipation(startDate, endDate); } - @Get("/member-payment-accrual") + @Get("/admin/member-payment-accrual") @ApiOperation({ summary: "Member payment accruals for the provided date range (defaults to last 3 months)", @@ -83,7 +98,12 @@ export class TopcoderReportsController { return this.reports.getMemberPaymentAccrual(startDate, endDate); } - @Get("/recent-member-data") + @Get("/member/recent-member-data") + @RequiredScopes( + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.Member.RecentMemberData, + ) @ApiOperation({ summary: "Members who registered and were paid since the start date (defaults to Jan 1, 2024)", @@ -93,7 +113,7 @@ export class TopcoderReportsController { return this.reports.getRecentMemberData(startDate); } - @Get("/90-day-member-spend") + @Get("/topcoder/90-day-member-spend") @ApiOperation({ summary: "Total gross amount paid to members in the last 90 days", }) @@ -101,7 +121,7 @@ export class TopcoderReportsController { return this.reports.get90DayMemberSpend(); } - @Get("/90-day-members-paid") + @Get("/topcoder/90-day-members-paid") @ApiOperation({ summary: "Total number of distinct members paid in the last 90 days", }) @@ -109,7 +129,7 @@ export class TopcoderReportsController { return this.reports.get90DayMembersPaid(); } - @Get("/90-day-new-members") + @Get("/topcoder/90-day-new-members") @ApiOperation({ summary: "Total number of new active members created in the last 90 days", }) @@ -117,7 +137,7 @@ export class TopcoderReportsController { return this.reports.get90DayNewMembers(); } - @Get("/90-day-active-copilots") + @Get("/topcoder/90-day-active-copilots") @ApiOperation({ summary: "Total number of distinct copilots active in the last 90 days", }) @@ -125,7 +145,7 @@ export class TopcoderReportsController { return this.reports.get90DayActiveCopilots(); } - @Get("/90-day-user-login") + @Get("/topcoder/90-day-user-login") @ApiOperation({ summary: "Total number of active members who logged in during the last 90 days", @@ -134,7 +154,7 @@ export class TopcoderReportsController { return this.reports.get90DayUserLogin(); } - @Get("/90-day-challenge-volume") + @Get("/topcoder/90-day-challenge-volume") @ApiOperation({ summary: "Total number of challenges launched in the last 90 days", }) @@ -142,7 +162,7 @@ export class TopcoderReportsController { return this.reports.get90DayChallengeVolume(); } - @Get("/90-day-challenge-duration") + @Get("/topcoder/90-day-challenge-duration") @ApiOperation({ summary: "Total duration and count of completed challenges in the last 90 days", @@ -151,7 +171,7 @@ export class TopcoderReportsController { return this.reports.get90DayChallengeDuration(); } - @Get("/90-day-challenge-registrants") + @Get("/topcoder/90-day-challenge-registrants") @ApiOperation({ summary: "Distinct challenge registrants and submitters in the last 90 days", @@ -160,7 +180,7 @@ export class TopcoderReportsController { return this.reports.get90DayChallengeRegistrants(); } - @Get("/90-day-challenge-submitters") + @Get("/topcoder/90-day-challenge-submitters") @ApiOperation({ summary: "Distinct challenge registrants and submitters in the last 90 days", @@ -169,7 +189,7 @@ export class TopcoderReportsController { return this.reports.get90DayChallengeSubmitters(); } - @Get("/90-day-challenge-member-cost") + @Get("/topcoder/90-day-challenge-member-cost") @ApiOperation({ summary: "Member payment totals and averages for challenges completed in the last 90 days", @@ -178,7 +198,7 @@ export class TopcoderReportsController { return this.reports.get90DayChallengeMemberCost(); } - @Get("/90-day-task-member-cost") + @Get("/topcoder/90-day-task-member-cost") @ApiOperation({ summary: "Member payment totals and averages for tasks completed in the last 90 days", @@ -187,7 +207,7 @@ export class TopcoderReportsController { return this.reports.get90DayTaskMemberCost(); } - @Get("/90-day-fulfillment") + @Get("/topcoder/90-day-fulfillment") @ApiOperation({ summary: "Share of challenges completed versus cancelled in the last 90 days", @@ -196,7 +216,7 @@ export class TopcoderReportsController { return this.reports.get90DayFulfillment(); } - @Get("/90-day-fulfillment-with-tasks") + @Get("/topcoder/90-day-fulfillment-with-tasks") @ApiOperation({ summary: "Share of challenges and tasks completed versus cancelled in the last 90 days", @@ -205,7 +225,7 @@ export class TopcoderReportsController { return this.reports.get90DayFulfillmentWithTasks(); } - @Get("/weekly-challenge-fulfillment") + @Get("/topcoder/weekly-challenge-fulfillment") @ApiOperation({ summary: "Weekly share of challenges completed versus cancelled for the last four weeks", @@ -214,7 +234,7 @@ export class TopcoderReportsController { return this.reports.getWeeklyChallengeFulfillment(); } - @Get("/weekly-challenge-volume") + @Get("/topcoder/weekly-challenge-volume") @ApiOperation({ summary: "Weekly challenge counts by task indicator for the last four weeks", @@ -223,7 +243,7 @@ export class TopcoderReportsController { return this.reports.getWeeklyChallengeVolume(); } - @Get("/90-day-membership-participation-funnel") + @Get("/topcoder/90-day-membership-participation-funnel") @ApiOperation({ summary: "New member counts with design and development participation indicators for the last 90 days", @@ -232,7 +252,7 @@ export class TopcoderReportsController { return this.reports.get90DayMembershipParticipationFunnel(); } - @Get("/membership-participation-funnel-data") + @Get("/topcoder/membership-participation-funnel-data") @ApiOperation({ summary: "Weekly new member counts with design and development participation indicators for the last four weeks", @@ -240,4 +260,29 @@ export class TopcoderReportsController { getMembershipParticipationFunnelData() { return this.reports.getMembershipParticipationFunnelData(); } + + @Get("/topcoder/completed-profiles") + @ApiOperation({ + summary: "List of members with 100% completed profiles", + }) + getCompletedProfiles(@Query() query: CompletedProfilesQueryDto) { + const { countryCode, page, perPage, openToWork, skillId } = query; + const parsedPage = Math.max(Number(page || 1), 1); + const parsedPerPage = Math.min(Math.max(Number(perPage || 50), 1), 200); + + const rawSkillIds = Array.isArray(skillId) + ? skillId + : skillId !== undefined && skillId !== null + ? [skillId] + : []; + const skillIds = rawSkillIds.filter((id) => id && id.trim().length > 0); + + return this.reports.getCompletedProfiles( + countryCode, + parsedPage, + parsedPerPage, + openToWork, + skillIds.length > 0 ? skillIds : undefined, + ); + } } diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index a298a3a..dd7cf31 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -86,6 +86,35 @@ type RecentMemberDataRow = { submissions_over_75: string | number | null; }; +type CompletedProfileRow = { + userId: string | number | null; + handle: string | null; + firstName: string | null; + lastName: string | null; + photoURL: string | null; + countryCode: string | null; + countryName: string | null; + city: string | null; + skillCount: string | number | null; + principalSkills: string[] | null; + isOpenToWork?: boolean | null; + openToWork?: { availability?: string; preferredRoles?: string[] } | null; +}; + +type CompletedProfilesCountRow = { + total: string | number | null; +}; + +type ChallengeSubmitterDataRow = { + userId: string | number | null; + handle: string | null; + email: string | null; + country: string | null; + place: string | number | null; + provisionalScores: unknown; + finalScore: string | number | null; +}; + @Injectable() export class TopcoderReportsService { constructor( @@ -571,6 +600,25 @@ export class TopcoderReportsService { })); } + async getChallengeSubmitterData(challengeId: string) { + const query = this.sql.load( + "reports/topcoder/challenge-submitter-data.sql", + ); + const rows = await this.db.query(query, [ + challengeId, + ]); + + return rows.map((row) => ({ + userId: this.toNullableNumber(row.userId), + handle: row.handle ?? null, + email: row.email ?? null, + country: row.country ?? null, + place: this.toNullableNumber(row.place), + provisionalScores: this.toNullableNumberArray(row.provisionalScores), + finalScore: this.toNullableNumber(row.finalScore), + })); + } + async getMarathonMatchStats(handle: string) { const query = this.sql.load("reports/topcoder/mm-stats.sql"); const rows = await this.db.query(query, [handle]); @@ -607,6 +655,102 @@ export class TopcoderReportsService { }; } + async getCompletedProfiles( + countryCode?: string, + page = 1, + perPage = 50, + openToWork?: boolean, + skillIds?: string[], + ) { + const safePage = Number.isFinite(page) ? Math.max(Math.floor(page), 1) : 1; + const safePerPage = Number.isFinite(perPage) + ? Math.min(Math.max(Math.floor(perPage), 1), 200) + : 50; + const offset = (safePage - 1) * safePerPage; + + const hasSkillIds = Array.isArray(skillIds) && skillIds.length > 0; + const skillIdsParam = hasSkillIds ? skillIds : null; + + const countQuery = this.sql.load( + "reports/topcoder/completed-profiles-count.sql", + ); + const countRows = await this.db.query( + countQuery, + [ + countryCode || null, + typeof openToWork === "boolean" ? openToWork : null, + skillIdsParam, + ], + ); + const total = Number(countRows?.[0]?.total ?? 0); + + const query = this.sql.load("reports/topcoder/completed-profiles.sql"); + const rows = await this.db.query(query, [ + countryCode || null, + typeof openToWork === "boolean" ? openToWork : null, + safePerPage, + offset, + skillIdsParam, + ]); + + const data = rows.map((row) => ({ + userId: row.userId ? Number(row.userId) : null, + handle: row.handle || "", + firstName: row.firstName || undefined, + lastName: row.lastName || undefined, + photoURL: row.photoURL || undefined, + countryCode: row.countryCode || undefined, + countryName: row.countryName || undefined, + city: row.city || undefined, + skillCount: + row.skillCount !== null && row.skillCount !== undefined + ? Number(row.skillCount) + : undefined, + principalSkills: row.principalSkills || undefined, + openToWork: row.openToWork ?? null, + isOpenToWork: + typeof row.isOpenToWork === "boolean" + ? row.isOpenToWork + : false, + })); + + return { + data, + page: safePage, + perPage: safePerPage, + total, + totalPages: total > 0 ? Math.ceil(total / safePerPage) : 1, + }; + } + + private toNullableNumberArray(value: unknown): number[] | null { + if (value === null || value === undefined) { + return null; + } + + let normalizedValue = value; + + if (typeof normalizedValue === "string") { + try { + normalizedValue = JSON.parse(normalizedValue); + } catch { + return null; + } + } + + if (!Array.isArray(normalizedValue)) { + return null; + } + + return normalizedValue.reduce((scores, item) => { + const numericValue = Number(item); + if (Number.isFinite(numericValue)) { + scores.push(numericValue); + } + return scores; + }, []); + } + private toNullableNumber(value: string | number | null | undefined) { if (value === null || value === undefined) { return null; diff --git a/src/statistics/mm-data.service.ts b/src/statistics/mm-data.service.ts index 0e74d6d..9063f46 100644 --- a/src/statistics/mm-data.service.ts +++ b/src/statistics/mm-data.service.ts @@ -2,28 +2,56 @@ import { Injectable } from "@nestjs/common"; import * as fs from "fs"; import * as path from "path"; +/** + * Serves Marathon Match statistics from checked-in JSON snapshots. + * + * These endpoints intentionally do not query `memberStats` or + * `memberStatsHistory` live. The files under `data/statistics/mm` are static + * report artifacts that can be refreshed independently from the API runtime. + */ @Injectable() export class MmDataService { private baseDir = path.resolve(process.cwd(), "data/statistics/mm"); + /** + * Load a Marathon Match statistics snapshot from disk. + * @param fileName snapshot file name under `data/statistics/mm` + * @returns parsed JSON payload exposed by the statistics endpoints + */ private loadJson(fileName: string): T { const fullPath = path.join(this.baseDir, fileName); const raw = fs.readFileSync(fullPath, "utf-8"); return JSON.parse(raw) as T; } + /** + * Get the highest-rated Marathon Match snapshot. + * @returns parsed contents of `highest-rated.json` + */ getTopRated() { return this.loadJson("highest-rated.json"); } + /** + * Get the Marathon Match country ratings snapshot. + * @returns parsed contents of `country-ratings.json` + */ getCountryRatings() { return this.loadJson("country-ratings.json"); } + /** + * Get the Marathon Match top-10 finishes snapshot. + * @returns parsed contents of `top-10-finishes.json` + */ getTop10Finishes() { return this.loadJson("top-10-finishes.json"); } + /** + * Get the Marathon Match competitions-count snapshot. + * @returns parsed contents of `competitions-count.json` + */ getCompetitionsCount() { return this.loadJson("competitions-count.json"); } diff --git a/src/statistics/srm-data.service.ts b/src/statistics/srm-data.service.ts index 934e951..22702cf 100644 --- a/src/statistics/srm-data.service.ts +++ b/src/statistics/srm-data.service.ts @@ -2,24 +2,48 @@ import { Injectable } from "@nestjs/common"; import * as fs from "fs"; import * as path from "path"; +/** + * Serves SRM statistics from checked-in JSON snapshots. + * + * These endpoints intentionally do not query `memberStats` or + * `memberStatsHistory` live. The files under `data/statistics/srm` are static + * report artifacts that can be refreshed independently from the API runtime. + */ @Injectable() export class SrmDataService { private baseDir = path.resolve(process.cwd(), "data/statistics/srm"); + /** + * Load an SRM statistics snapshot from disk. + * @param fileName snapshot file name under `data/statistics/srm` + * @returns parsed JSON payload exposed by the statistics endpoints + */ private loadJson(fileName: string): T { const fullPath = path.join(this.baseDir, fileName); const raw = fs.readFileSync(fullPath, "utf-8"); return JSON.parse(raw) as T; } + /** + * Get the highest-rated SRM snapshot. + * @returns parsed contents of `highest-rated.json` + */ getTopRated() { return this.loadJson("highest-rated.json"); } + /** + * Get the SRM country ratings snapshot. + * @returns parsed contents of `country-ratings.json` + */ getCountryRatings() { return this.loadJson("country-ratings.json"); } + /** + * Get the SRM competitions-count snapshot. + * @returns parsed contents of `competitions-count.json` + */ getCompetitionsCount() { return this.loadJson("competitions-count.json"); }