diff --git a/.circleci/config.yml b/.circleci/config.yml index f909fc6..b0dd89b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -65,7 +65,7 @@ workflows: only: - develop - PM-4931 - - improve-member-search-2 + - PM-4704 tags: only: /^dev-.*/ diff --git a/sql/reports/statistics/expert-skills/categories.sql b/sql/reports/statistics/expert-skills/categories.sql new file mode 100644 index 0000000..f28eb05 --- /dev/null +++ b/sql/reports/statistics/expert-skills/categories.sql @@ -0,0 +1,5 @@ +SELECT + sc.id::text AS id, + sc.name +FROM skills.skill_category sc +ORDER BY sc.name ASC; diff --git a/sql/reports/statistics/expert-skills/category-members.sql b/sql/reports/statistics/expert-skills/category-members.sql new file mode 100644 index 0000000..458069d --- /dev/null +++ b/sql/reports/statistics/expert-skills/category-members.sql @@ -0,0 +1,45 @@ +WITH category_wins AS ( + SELECT + se.user_id, + COUNT(*)::int AS wins + FROM skills.skill_event se + JOIN skills.skill sk + ON sk.id = se.skill_id + AND sk.deleted_at IS NULL + JOIN skills.skill_event_type set_t + ON set_t.id = se.skill_event_type_id + JOIN skills.source_type sest + ON sest.id = se.source_type_id + WHERE sk.category_id = $1::uuid + AND ( + LOWER(set_t.name) IN ( + 'challenge_win', + 'challenge_2nd_place', + 'challenge_3rd_place', + 'gig_completion' + ) + OR sest.name = 'engagement' + ) + GROUP BY se.user_id +) +SELECT + m.handle, + m."firstName" AS "firstName", + m."lastName" AS "lastName", + m."photoURL" AS "photoUrl", + COALESCE(m."homeCountryCode", m."competitionCountryCode", m.country, '') AS "countryCode", + COALESCE(mr.rating, 0) AS rating, + cw.wins +FROM category_wins cw +JOIN members.member m + ON m."userId" = cw.user_id + AND m.status = 'ACTIVE' +LEFT JOIN LATERAL ( + SELECT rating + FROM members."memberMaxRating" mmr + WHERE mmr."userId" = m."userId" + ORDER BY mmr.rating DESC NULLS LAST + LIMIT 1 +) mr ON TRUE +ORDER BY cw.wins DESC, m.handle ASC +LIMIT $2; diff --git a/sql/reports/statistics/expert-skills/category-stats.sql b/sql/reports/statistics/expert-skills/category-stats.sql new file mode 100644 index 0000000..7cfedd6 --- /dev/null +++ b/sql/reports/statistics/expert-skills/category-stats.sql @@ -0,0 +1,95 @@ +WITH requested_categories AS ( + SELECT DISTINCT id + FROM unnest($1::uuid[]) AS t(id) +), +skill_counts AS ( + SELECT + sk.category_id, + COUNT(*)::int AS total_skills + FROM skills.skill sk + WHERE sk.deleted_at IS NULL + AND sk.category_id = ANY($1::uuid[]) + GROUP BY sk.category_id +), +member_counts AS ( + SELECT + sk.category_id, + COUNT(DISTINCT us.user_id)::int AS total_members + FROM skills.user_skill us + JOIN skills.skill sk + ON sk.id = us.skill_id + AND sk.deleted_at IS NULL + WHERE sk.category_id = ANY($1::uuid[]) + GROUP BY sk.category_id +), +win_events AS ( + SELECT + sk.category_id, + sk.id AS skill_id, + sk.name AS skill_name, + se.user_id + FROM skills.skill_event se + JOIN skills.skill sk + ON sk.id = se.skill_id + AND sk.deleted_at IS NULL + JOIN skills.skill_event_type set_t + ON set_t.id = se.skill_event_type_id + JOIN skills.source_type sest + ON sest.id = se.source_type_id + WHERE sk.category_id = ANY($1::uuid[]) + AND ( + LOWER(set_t.name) IN ( + 'challenge_win', + 'challenge_2nd_place', + 'challenge_3rd_place', + 'gig_completion' + ) + OR sest.name = 'engagement' + ) +), +win_counts AS ( + SELECT + category_id, + COUNT(*)::int AS total_wins + FROM win_events + GROUP BY category_id +), +top_skills AS ( + SELECT + category_id, + skill_name AS name, + COUNT(*)::int AS skill_wins, + ROW_NUMBER() OVER ( + PARTITION BY category_id + ORDER BY COUNT(*) DESC, skill_name ASC + ) AS rn + FROM win_events + GROUP BY category_id, skill_id, skill_name +) +SELECT + rc.id::text AS id, + COALESCE(mc.total_members, 0) AS "totalMembers", + COALESCE(sc.total_skills, 0) AS "totalSkills", + COALESCE(wc.total_wins, 0) AS "totalWins", + COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'name', ts.name, + 'percentage', CASE + WHEN COALESCE(wc.total_wins, 0) = 0 THEN 0 + ELSE ROUND((ts.skill_wins::numeric / wc.total_wins::numeric) * 100)::int + END + ) + ORDER BY ts.rn + ) + FROM top_skills ts + WHERE ts.category_id = rc.id + AND ts.rn <= 3 + ), + '[]'::jsonb + ) AS "skillsBreakdown" +FROM requested_categories rc +LEFT JOIN skill_counts sc ON sc.category_id = rc.id +LEFT JOIN member_counts mc ON mc.category_id = rc.id +LEFT JOIN win_counts wc ON wc.category_id = rc.id; diff --git a/sql/reports/statistics/general/country-member-details.sql b/sql/reports/statistics/general/country-member-details.sql index 57efe12..da4b1e9 100644 --- a/sql/reports/statistics/general/country-member-details.sql +++ b/sql/reports/statistics/general/country-member-details.sql @@ -78,6 +78,8 @@ stats_wins AS ( AND history."typeId" = stats."typeId" LEFT JOIN challenges."ChallengeTrack" track ON track.id::text = stats."trackId" + LEFT JOIN challenges."ChallengeType" ct + ON ct.id::text = stats."typeId" WHERE stats."isPrivate" = false AND ( UPPER(COALESCE(track.name, stats."trackId")) LIKE '%DEVELOP%' @@ -87,6 +89,7 @@ stats_wins AS ( OR UPPER(COALESCE(track.name, stats."trackId")) LIKE '%QUALITY%ASSURANCE%' OR UPPER(COALESCE(track.name, stats."trackId")) LIKE '%COPILOT%' ) + AND (ct.name IS NULL OR NOT ct.name = ANY($1)) GROUP BY stats."userId" ), winner_counts AS ( diff --git a/sql/reports/statistics/general/first-place-by-country.sql b/sql/reports/statistics/general/first-place-by-country.sql index e074b38..82677f8 100644 --- a/sql/reports/statistics/general/first-place-by-country.sql +++ b/sql/reports/statistics/general/first-place-by-country.sql @@ -19,6 +19,8 @@ member_wins AS ( AND history."typeId" = stats."typeId" LEFT JOIN challenges."ChallengeTrack" track ON track.id::text = stats."trackId" + LEFT JOIN challenges."ChallengeType" ct + ON ct.id::text = stats."typeId" WHERE stats."isPrivate" = false AND ( UPPER(COALESCE(track.name, stats."trackId")) LIKE '%DEVELOP%' @@ -28,6 +30,7 @@ member_wins AS ( OR UPPER(COALESCE(track.name, stats."trackId")) LIKE '%QUALITY%ASSURANCE%' OR UPPER(COALESCE(track.name, stats."trackId")) LIKE '%COPILOT%' ) + AND (ct.name IS NULL OR NOT ct.name = ANY($1)) GROUP BY stats."userId" ), winners_country AS ( diff --git a/sql/reports/statistics/general/top-winners-by-country.sql b/sql/reports/statistics/general/top-winners-by-country.sql index 8cd411a..61ce573 100644 --- a/sql/reports/statistics/general/top-winners-by-country.sql +++ b/sql/reports/statistics/general/top-winners-by-country.sql @@ -19,6 +19,8 @@ stats_wins AS ( AND history."typeId" = stats."typeId" LEFT JOIN challenges."ChallengeTrack" track ON track.id::text = stats."trackId" + LEFT JOIN challenges."ChallengeType" ct + ON ct.id::text = stats."typeId" WHERE stats."isPrivate" = false AND ( UPPER(COALESCE(track.name, stats."trackId")) LIKE '%DEVELOP%' @@ -28,6 +30,7 @@ stats_wins AS ( OR UPPER(COALESCE(track.name, stats."trackId")) LIKE '%QUALITY%ASSURANCE%' OR UPPER(COALESCE(track.name, stats."trackId")) LIKE '%COPILOT%' ) + AND (ct.name IS NULL OR NOT ct.name = ANY($1)) GROUP BY stats."userId" ), winner_profiles AS ( diff --git a/sql/reports/topcoder/campus-leaderboard-group.sql b/sql/reports/topcoder/campus-leaderboard-group.sql new file mode 100644 index 0000000..1e36c5c --- /dev/null +++ b/sql/reports/topcoder/campus-leaderboard-group.sql @@ -0,0 +1,48 @@ +-- Resolves a campus program group by name (or id / legacy id) and reports whether +-- the caller ($2, an optional user id) belongs to it, directly or via a sub-group. +-- $1 = group name (case insensitive), $2 = caller user id (nullable) +WITH RECURSIVE params AS ( + SELECT + LOWER(BTRIM($1)) AS group_key, + NULLIF(BTRIM(COALESCE($2, '')), '') AS caller_id +), +root_group AS ( + SELECT + g.id, + g.name, + g."oldId", + g."privateGroup" + FROM groups."Group" AS g + CROSS JOIN params AS p + WHERE LOWER(g.name) = p.group_key + OR LOWER(g.id) = p.group_key + OR LOWER(COALESCE(g."oldId", '')) = p.group_key + ORDER BY (LOWER(g.name) = p.group_key) DESC, g."createdAt" ASC + LIMIT 1 +), +group_tree AS ( + SELECT rg.id + FROM root_group AS rg + UNION + SELECT gm."memberId" + FROM groups."GroupMember" AS gm + JOIN group_tree AS gt + ON gt.id = gm."groupId" + WHERE LOWER(gm."membershipType") = 'group' +) +SELECT + rg.id AS "groupId", + rg.name AS "groupName", + rg."oldId" AS "groupOldId", + rg."privateGroup" AS "privateGroup", + EXISTS ( + SELECT 1 + FROM groups."GroupMember" AS gm + JOIN group_tree AS gt + ON gt.id = gm."groupId" + CROSS JOIN params AS p + WHERE LOWER(gm."membershipType") = 'user' + AND p.caller_id IS NOT NULL + AND gm."memberId" = p.caller_id + ) AS "callerIsMember" +FROM root_group AS rg; diff --git a/sql/reports/topcoder/campus-leaderboard.sql b/sql/reports/topcoder/campus-leaderboard.sql new file mode 100644 index 0000000..0c6b30d --- /dev/null +++ b/sql/reports/topcoder/campus-leaderboard.sql @@ -0,0 +1,254 @@ +-- Campus program leaderboard: every member of the requested group (including +-- members with no challenge activity at all) with one row per challenge they +-- registered for, submitted to, or won. Tasks and First2Finish challenges are +-- excluded. Members without activity come back as a single row with a NULL +-- "challengeId". +-- $1 = group name (case insensitive, also accepts the group id / legacy id) +WITH RECURSIVE params AS ( + SELECT LOWER(BTRIM($1)) AS group_key +), +root_group AS ( + SELECT + g.id, + g.name, + g."oldId" + FROM groups."Group" AS g + CROSS JOIN params AS p + WHERE LOWER(g.name) = p.group_key + OR LOWER(g.id) = p.group_key + OR LOWER(COALESCE(g."oldId", '')) = p.group_key + ORDER BY (LOWER(g.name) = p.group_key) DESC, g."createdAt" ASC + LIMIT 1 +), +group_tree AS ( + SELECT rg.id + FROM root_group AS rg + UNION + SELECT gm."memberId" + FROM groups."GroupMember" AS gm + JOIN group_tree AS gt + ON gt.id = gm."groupId" + WHERE LOWER(gm."membershipType") = 'group' +), +group_identifiers AS ( + SELECT ARRAY( + SELECT DISTINCT identifier + FROM ( + SELECT rg.id AS identifier FROM root_group AS rg + UNION ALL + SELECT rg."oldId" FROM root_group AS rg WHERE NULLIF(BTRIM(rg."oldId"), '') IS NOT NULL + ) AS identifiers + ) AS identifiers +), +group_members AS ( + SELECT + gm."memberId" AS member_id, + MIN(gm."createdAt") AS joined_at + FROM groups."GroupMember" AS gm + JOIN group_tree AS gt + ON gt.id = gm."groupId" + WHERE LOWER(gm."membershipType") = 'user' + AND gm."memberId" ~ '^[0-9]+$' + GROUP BY gm."memberId" +), +registrant_roles AS ( + SELECT rr.id + FROM resources."ResourceRole" AS rr + WHERE rr."nameLower" IN ('submitter', 'registrant') +), +member_registrations AS ( + SELECT + r."memberId" AS member_id, + r."challengeId" AS challenge_id, + MIN(r."createdAt") AS registered_at + FROM resources."Resource" AS r + JOIN group_members AS gm + ON gm.member_id = r."memberId" + WHERE r."roleId" IN (SELECT id FROM registrant_roles) + GROUP BY r."memberId", r."challengeId" +), +scored_submissions AS ( + SELECT + s."memberId" AS member_id, + s."challengeId" AS challenge_id, + COALESCE(s."submittedDate", s."createdAt") AS submitted_date, + COALESCE( + CASE + WHEN challenge_reviewers.is_ai_only_challenge + THEN ai_decision."totalScore" + ELSE final_review."aggregateScore" + END, + s."finalScore"::double precision, + s."initialScore"::double precision + ) AS score, + CASE + WHEN challenge_reviewers.is_ai_only_challenge + THEN UPPER(COALESCE(ai_decision.status::text, '')) = 'PASSED' + ELSE COALESCE( + final_review."isPassing", + CASE + WHEN COALESCE( + final_review."aggregateScore", + s."finalScore"::double precision, + s."initialScore"::double precision + ) IS NULL + THEN FALSE + ELSE COALESCE( + final_review."aggregateScore", + s."finalScore"::double precision, + s."initialScore"::double precision + ) >= COALESCE(sc."minimumPassingScore", sc."minScore", 0) + END + ) + END AS is_passing + FROM reviews.submission AS s + JOIN group_members AS gm + ON gm.member_id = s."memberId" + LEFT JOIN LATERAL ( + SELECT rs."aggregateScore", rs."scorecardId", rs."isPassing" + 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 + BOOL_AND(cr."aiWorkflowId" IS NOT NULL AND cr."isMemberReview" = FALSE) + AND COUNT(*) > 0 AS is_ai_only_challenge + FROM challenges."ChallengeReviewer" AS cr + WHERE cr."challengeId" = s."challengeId" + ) AS challenge_reviewers ON TRUE + LEFT JOIN LATERAL ( + SELECT d."totalScore", d.status + FROM reviews."aiReviewDecision" AS d + WHERE d."submissionId" = s.id + AND UPPER(d.status::text) != 'PENDING' + ORDER BY d."updatedAt" DESC NULLS LAST + LIMIT 1 + ) AS ai_decision ON TRUE + LEFT JOIN reviews.scorecard AS sc + ON sc.id = final_review."scorecardId" + WHERE s."challengeId" IS NOT NULL + AND s.type = 'CONTEST_SUBMISSION' + AND s.status <> 'DELETED' +), +-- At most one submission (and one passing submission) is counted per member per challenge. +member_submissions AS ( + SELECT + ss.member_id, + ss.challenge_id, + MIN(ss.submitted_date) AS first_submitted_date, + BOOL_OR(COALESCE(ss.is_passing, FALSE)) AS has_passing_submission, + MAX(ss.score) FILTER (WHERE ss.score IS NOT NULL) AS best_score + FROM scored_submissions AS ss + GROUP BY ss.member_id, ss.challenge_id +), +member_placements AS ( + SELECT + cw."userId"::text AS member_id, + cw."challengeId" AS challenge_id, + MIN(cw.placement) AS placement + FROM challenges."ChallengeWinner" AS cw + JOIN group_members AS gm + ON gm.member_id = cw."userId"::text + WHERE cw.type = 'PLACEMENT' + GROUP BY cw."userId", cw."challengeId" +), +participation AS ( + SELECT member_id, challenge_id FROM member_registrations + UNION + SELECT member_id, challenge_id FROM member_submissions + UNION + SELECT member_id, challenge_id FROM member_placements +), +member_participation AS ( + SELECT + p.member_id, + c.id AS challenge_id, + c.name AS challenge_name, + c.status::text AS challenge_status, + ct.name AS challenge_type, + ctr.name AS challenge_track, + COALESCE(c."endDate", c."submissionEndDate", c."startDate") AS challenge_end_date, + (c.groups && gi.identifiers) AS is_campus_challenge, + (COALESCE(ARRAY_LENGTH(c.groups, 1), 0) = 0) AS is_public_challenge, + reg.registered_at, + (reg.member_id IS NOT NULL) AS registered, + (sub.member_id IS NOT NULL) AS submitted, + COALESCE(sub.has_passing_submission, FALSE) AS passed_review, + sub.first_submitted_date, + sub.best_score, + (win.member_id IS NOT NULL AND win.placement = 1) AS won, + win.placement + FROM participation AS p + CROSS JOIN group_identifiers AS gi + JOIN challenges."Challenge" AS c + ON c.id = p.challenge_id + LEFT JOIN challenges."ChallengeType" AS ct + ON ct.id = c."typeId" + LEFT JOIN challenges."ChallengeTrack" AS ctr + ON ctr.id = c."trackId" + LEFT JOIN member_registrations AS reg + ON reg.member_id = p.member_id + AND reg.challenge_id = p.challenge_id + LEFT JOIN member_submissions AS sub + ON sub.member_id = p.member_id + AND sub.challenge_id = p.challenge_id + LEFT JOIN member_placements AS win + ON win.member_id = p.member_id + AND win.challenge_id = p.challenge_id + -- Tasks and First2Finish challenges do not count towards the leaderboard. + WHERE COALESCE(c."taskIsTask", FALSE) = FALSE + AND COALESCE(ct."isTask", FALSE) = FALSE + AND UPPER(COALESCE(ct.abbreviation, '')) NOT IN ('TSK', 'F2F') + AND UPPER(COALESCE(ct.name, '')) NOT IN ('TASK', 'FIRST2FINISH') +), +max_rating AS ( + SELECT DISTINCT ON (mmr."userId") + mmr."userId", + mmr.rating, + mmr."ratingColor" + FROM members."memberMaxRating" AS mmr + ORDER BY mmr."userId", mmr.rating DESC +) +SELECT + gm.member_id AS "userId", + COALESCE( + NULLIF(BTRIM(u.handle), ''), + NULLIF(BTRIM(mem.handle), '') + ) AS handle, + mem."firstName" AS "firstName", + mem."lastName" AS "lastName", + mem."photoURL" AS "photoURL", + mr.rating AS rating, + mr."ratingColor" AS "ratingColor", + gm.joined_at AS "groupJoinedAt", + u.create_date AS "memberSince", + mp.challenge_id AS "challengeId", + mp.challenge_name AS "challengeName", + mp.challenge_status AS "challengeStatus", + mp.challenge_type AS "challengeType", + mp.challenge_track AS "challengeTrack", + mp.challenge_end_date AS "challengeEndDate", + mp.is_campus_challenge AS "isCampusChallenge", + mp.is_public_challenge AS "isPublicChallenge", + mp.registered_at AS "registeredAt", + mp.registered AS registered, + mp.submitted AS submitted, + mp.passed_review AS "passedReview", + mp.first_submitted_date AS "submittedDate", + mp.best_score AS score, + mp.won AS won, + mp.placement AS placement +FROM group_members AS gm +LEFT JOIN members."member" AS mem + ON mem."userId" = gm.member_id::bigint +LEFT JOIN identity."user" AS u + ON u.user_id = gm.member_id::numeric +LEFT JOIN max_rating AS mr + ON mr."userId" = gm.member_id::bigint +LEFT JOIN member_participation AS mp + ON mp.member_id = gm.member_id +ORDER BY gm.member_id, mp.challenge_end_date DESC NULLS LAST, mp.challenge_id; diff --git a/src/auth/guards/topcoder-reports.guard.spec.ts b/src/auth/guards/topcoder-reports.guard.spec.ts index aa89333..8125ecd 100644 --- a/src/auth/guards/topcoder-reports.guard.spec.ts +++ b/src/auth/guards/topcoder-reports.guard.spec.ts @@ -51,6 +51,15 @@ class TestTopcoderReportsController { getCompletedProfiles(): undefined { return undefined; } + + /** + * Represents the campus leaderboard route, which any authenticated caller may read. + * Returns no value because the handler body is not exercised in these unit tests. + */ + @RequiredScopes() + getCampusLeaderboard(): undefined { + return undefined; + } } type TestHandlerName = keyof TestTopcoderReportsController; @@ -109,6 +118,12 @@ describe("TopcoderReportsGuard", () => { ).toThrow(ForbiddenException); }); + it("lets any authenticated caller reach the campus leaderboard", () => { + expect( + guard.canActivate(createExecutionContext("getCampusLeaderboard", {})), + ).toBe(true); + }); + it("preserves the completed profiles talent manager exception", () => { expect( guard.canActivate( diff --git a/src/common/country.util.ts b/src/common/country.util.ts index 66071e9..6afb486 100644 --- a/src/common/country.util.ts +++ b/src/common/country.util.ts @@ -31,3 +31,22 @@ export function alpha3ToCountryName(code?: string | null): string | null { const upper = trimmed.toUpperCase(); return countryUtil.getName(upper, "en") || null; } + +export function toAlpha2CountryCode(code?: string | null): string { + const trimmed = String(code || "") + .trim() + .toUpperCase(); + if (!trimmed) { + return ""; + } + + if (trimmed.length === 2) { + return trimmed; + } + + if (trimmed.length === 3) { + return countryUtil.alpha3ToAlpha2(trimmed) || ""; + } + + return ""; +} diff --git a/src/reports/report-directory.data.spec.ts b/src/reports/report-directory.data.spec.ts index f233bd3..ce04977 100644 --- a/src/reports/report-directory.data.spec.ts +++ b/src/reports/report-directory.data.spec.ts @@ -34,6 +34,8 @@ describe("getAccessibleReportsDirectory", () => { expect.arrayContaining([ "/statistics/general/country-member-details", "/statistics/general/top-winners-by-country", + "/statistics/expert-skills/categories", + "/statistics/expert-skills/category-members", ]), ); }); @@ -61,6 +63,7 @@ describe("getAccessibleReportsDirectory", () => { "challenges", "identity", "member", + "sfdc", "statistics", ]); expect(directory.identity?.reports.map((report) => report.path)).toEqual([ diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index 8a095e3..1fc78c1 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -684,6 +684,26 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { "/statistics/qa/wins", "Quality Assurance challenge wins by member (desc)", ), + publicReport( + "Expert Skill Categories", + "/statistics/expert-skills/categories", + "Skill categories from skills.skill_category with win-normalized bubble sizes", + ), + publicReport( + "Expert Skill Category Members", + "/statistics/expert-skills/category-members", + "Top 100 members in a skill category, sorted by wins", + [ + { + name: "selectedcategory", + type: "string", + description: + "Category name from the standardized-skills catalog, or the category UUID", + required: true, + location: "query", + }, + ], + ), ], }, topcoder: { diff --git a/src/reports/topcoder/campus-leaderboard.service.spec.ts b/src/reports/topcoder/campus-leaderboard.service.spec.ts new file mode 100644 index 0000000..d57b299 --- /dev/null +++ b/src/reports/topcoder/campus-leaderboard.service.spec.ts @@ -0,0 +1,339 @@ +import { ForbiddenException, NotFoundException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { DbService } from "../../db/db.service"; +import { SqlLoaderService } from "../../common/sql-loader.service"; +import { TopcoderReportsService } from "./topcoder-reports.service"; +import { CampusChallengeFilter } from "./dto/campus-leaderboard.dto"; + +const GROUP_QUERY = "reports/topcoder/campus-leaderboard-group.sql"; +const LEADERBOARD_QUERY = "reports/topcoder/campus-leaderboard.sql"; + +type Row = Record; + +const participationRow = (overrides: Row = {}): Row => ({ + userId: "1", + handle: "member1", + firstName: "Member", + lastName: "One", + photoURL: null, + rating: null, + ratingColor: null, + groupJoinedAt: "2026-01-01T00:00:00.000Z", + memberSince: "2025-01-01T00:00:00.000Z", + challengeId: "c1", + challengeName: "Challenge 1", + challengeStatus: "COMPLETED", + challengeType: "Challenge", + challengeTrack: "Development", + challengeEndDate: "2026-02-01T00:00:00.000Z", + isCampusChallenge: true, + isPublicChallenge: false, + registeredAt: "2026-01-05T00:00:00.000Z", + registered: true, + submitted: false, + passedReview: false, + submittedDate: null, + score: null, + won: false, + placement: null, + ...overrides, +}); + +describe("TopcoderReportsService.getCampusLeaderboard", () => { + let service: TopcoderReportsService; + let groupRows: Row[]; + let leaderboardRows: Row[]; + + beforeEach(() => { + groupRows = [ + { + groupId: "group-1", + groupName: "MECW", + groupOldId: null, + privateGroup: false, + callerIsMember: false, + }, + ]; + leaderboardRows = []; + + const db = { + query: jest.fn((query: string) => { + if (query === GROUP_QUERY) { + return groupRows; + } + + if (query === LEADERBOARD_QUERY) { + return leaderboardRows; + } + + throw new Error(`Unexpected query: ${query}`); + }), + }; + + service = new TopcoderReportsService( + db as unknown as DbService, + { + load: jest.fn((query: string) => query), + } as unknown as SqlLoaderService, + { + get: jest.fn((_key: string, defaultValue?: string) => defaultValue), + } as unknown as ConfigService, + ); + }); + + it("throws when the group does not exist", async () => { + groupRows = []; + + await expect( + service.getCampusLeaderboard({ groupName: "nope" }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it("rejects non-members without report access for private groups", async () => { + groupRows = [ + { + groupId: "group-1", + groupName: "MECW", + groupOldId: null, + privateGroup: true, + callerIsMember: false, + }, + ]; + + await expect( + service.getCampusLeaderboard( + { groupName: "mecw" }, + { userId: 999, hasReportAccess: false }, + ), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it("allows group members and report readers to read private group leaderboards", async () => { + groupRows = [ + { + groupId: "group-1", + groupName: "MECW", + groupOldId: null, + privateGroup: true, + callerIsMember: true, + }, + ]; + leaderboardRows = [participationRow()]; + + await expect( + service.getCampusLeaderboard({ groupName: "mecw" }, { userId: 1 }), + ).resolves.toMatchObject({ group: { id: "group-1", name: "MECW" } }); + }); + + it("includes members with no activity and counts one submission per challenge", async () => { + leaderboardRows = [ + // Two submission rows collapse to a single submitted challenge in SQL, so + // the service receives one row per member per challenge. + participationRow({ + challengeId: "c1", + submitted: true, + passedReview: true, + won: true, + placement: 1, + }), + participationRow({ challengeId: "c2", submitted: true }), + participationRow({ challengeId: "c3" }), + participationRow({ + userId: "2", + handle: "member2", + challengeId: null, + challengeName: null, + registered: null, + submitted: null, + passedReview: null, + won: null, + groupJoinedAt: "2026-03-01T00:00:00.000Z", + }), + ]; + + const result = await service.getCampusLeaderboard({ groupName: "mecw" }); + + expect(result.summary).toEqual({ + totalMembers: 2, + membersRegistered: 1, + membersSubmitted: 1, + }); + expect(result.members).toHaveLength(2); + expect(result.members[0]).toMatchObject({ + rank: 1, + handle: "member1", + registrations: 3, + submissions: 2, + passingSubmissions: 1, + wins: 1, + hasActivity: true, + }); + expect(result.members[0].challenges).toHaveLength(3); + expect(result.members[1]).toMatchObject({ + rank: 2, + handle: "member2", + registrations: 0, + submissions: 0, + passingSubmissions: 0, + wins: 0, + hasActivity: false, + challenges: [], + }); + }); + + it("returns non-winning placements such as 2nd and 3rd place", async () => { + leaderboardRows = [ + participationRow({ + challengeId: "c1", + submitted: true, + passedReview: true, + won: false, + placement: 2, + }), + participationRow({ + challengeId: "c2", + submitted: true, + passedReview: true, + won: false, + placement: 3, + }), + ]; + + const result = await service.getCampusLeaderboard({ groupName: "mecw" }); + + expect(result.members).toHaveLength(1); + expect(result.members[0].challenges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ placement: 2 }), + expect.objectContaining({ placement: 3 }), + ]), + ); + }); + + it("ranks by wins, then passing submissions, then registrations, then signup time", async () => { + leaderboardRows = [ + // One win, fewest registrations -> first. + participationRow({ + userId: "1", + handle: "winner", + challengeId: "c1", + submitted: true, + passedReview: true, + won: true, + placement: 1, + }), + // No wins but a passing submission -> second. + participationRow({ + userId: "2", + handle: "passer", + challengeId: "c1", + submitted: true, + passedReview: true, + }), + participationRow({ userId: "2", handle: "passer", challengeId: "c2" }), + // No wins, no passing submissions, two registrations -> third. + participationRow({ userId: "3", handle: "regular", challengeId: "c1" }), + participationRow({ userId: "3", handle: "regular", challengeId: "c2" }), + // Same activity as "later" but signed up earlier -> fourth. + participationRow({ + userId: "4", + handle: "earlier", + challengeId: "c1", + groupJoinedAt: "2026-01-01T00:00:00.000Z", + }), + participationRow({ + userId: "5", + handle: "later", + challengeId: "c1", + groupJoinedAt: "2026-06-01T00:00:00.000Z", + }), + ]; + + const result = await service.getCampusLeaderboard({ groupName: "mecw" }); + + expect( + result.members.map((member) => [member.handle, member.rank]), + ).toEqual([ + ["winner", 1], + ["passer", 2], + ["regular", 3], + ["earlier", 4], + ["later", 5], + ]); + }); + + it("shares a rank between members whose ranking criteria are identical", async () => { + leaderboardRows = [ + participationRow({ + userId: "1", + handle: "aaa", + challengeId: "c1", + groupJoinedAt: "2026-01-01T00:00:00.000Z", + }), + participationRow({ + userId: "2", + handle: "bbb", + challengeId: "c1", + groupJoinedAt: "2026-01-01T00:00:00.000Z", + }), + ]; + + const result = await service.getCampusLeaderboard({ groupName: "mecw" }); + + expect(result.members.map((member) => member.rank)).toEqual([1, 1]); + }); + + it("filters counts and participation history by challenge visibility", async () => { + leaderboardRows = [ + participationRow({ + challengeId: "campus-1", + isCampusChallenge: true, + isPublicChallenge: false, + submitted: true, + passedReview: true, + won: true, + }), + participationRow({ + challengeId: "public-1", + isCampusChallenge: false, + isPublicChallenge: true, + submitted: true, + }), + ]; + + const campusOnly = await service.getCampusLeaderboard({ + groupName: "mecw", + challengeFilter: CampusChallengeFilter.Campus, + }); + expect(campusOnly.members[0]).toMatchObject({ + registrations: 1, + submissions: 1, + passingSubmissions: 1, + wins: 1, + }); + expect( + campusOnly.members[0].challenges.map((entry) => entry.challengeId), + ).toEqual(["campus-1"]); + + const publicOnly = await service.getCampusLeaderboard({ + groupName: "mecw", + challengeFilter: CampusChallengeFilter.Public, + }); + expect(publicOnly.members[0]).toMatchObject({ + registrations: 1, + submissions: 1, + passingSubmissions: 0, + wins: 0, + }); + expect( + publicOnly.members[0].challenges.map((entry) => entry.challengeId), + ).toEqual(["public-1"]); + + // The summary tiles always describe activity across every challenge. + expect(publicOnly.summary).toEqual({ + totalMembers: 1, + membersRegistered: 1, + membersSubmitted: 1, + }); + }); +}); diff --git a/src/reports/topcoder/dto/campus-leaderboard.dto.spec.ts b/src/reports/topcoder/dto/campus-leaderboard.dto.spec.ts new file mode 100644 index 0000000..92ee599 --- /dev/null +++ b/src/reports/topcoder/dto/campus-leaderboard.dto.spec.ts @@ -0,0 +1,40 @@ +import { plainToInstance } from "class-transformer"; +import { validate } from "class-validator"; +import { + CampusChallengeFilter, + CampusLeaderboardQueryDto, +} from "./campus-leaderboard.dto"; + +const toDto = (query: Record) => + plainToInstance(CampusLeaderboardQueryDto, query); + +describe("CampusLeaderboardQueryDto", () => { + it("trims the group name and normalizes the challenge filter", async () => { + const dto = toDto({ groupName: " mecw ", challengeFilter: " Campus " }); + + await expect(validate(dto)).resolves.toEqual([]); + expect(dto.groupName).toBe("mecw"); + expect(dto.challengeFilter).toBe(CampusChallengeFilter.Campus); + }); + + it("allows the challenge filter to be omitted", async () => { + const dto = toDto({ groupName: "mecw" }); + + await expect(validate(dto)).resolves.toEqual([]); + expect(dto.challengeFilter).toBeUndefined(); + }); + + it("requires a group name", async () => { + const errors = await validate(toDto({ groupName: " " })); + + expect(errors.map((error) => error.property)).toContain("groupName"); + }); + + it("rejects unknown challenge filters", async () => { + const errors = await validate( + toDto({ groupName: "mecw", challengeFilter: "private" }), + ); + + expect(errors.map((error) => error.property)).toContain("challengeFilter"); + }); +}); diff --git a/src/reports/topcoder/dto/campus-leaderboard.dto.ts b/src/reports/topcoder/dto/campus-leaderboard.dto.ts new file mode 100644 index 0000000..abe4562 --- /dev/null +++ b/src/reports/topcoder/dto/campus-leaderboard.dto.ts @@ -0,0 +1,22 @@ +import { Transform } from "class-transformer"; +import { IsEnum, IsNotEmpty, IsOptional, IsString } from "class-validator"; + +export enum CampusChallengeFilter { + All = "all", + Public = "public", + Campus = "campus", +} + +export class CampusLeaderboardQueryDto { + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsString() + @IsNotEmpty() + groupName!: string; + + @Transform(({ value }) => + typeof value === "string" ? value.trim().toLowerCase() : value, + ) + @IsOptional() + @IsEnum(CampusChallengeFilter) + challengeFilter?: CampusChallengeFilter; +} diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index 20ebfad..3d2d8cd 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -3,12 +3,14 @@ import { Get, Param, Query, + Req, UseGuards, UseInterceptors, } 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 { CampusLeaderboardQueryDto } from "./dto/campus-leaderboard.dto"; import { LeaderboardGenericQueryDto } from "./dto/leaderboard-generic.dto"; import { LeaderboardMmQueryDto } from "./dto/leaderboard-mm.dto"; import { RegistrantCountriesQueryDto } from "./dto/registrant-countries.dto"; @@ -19,6 +21,7 @@ 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"; +import { AuthUserLike, hasAccessToScopes } from "../../auth/permissions.util"; @ApiTags("Topcoder Reports") @ApiBearerAuth() @@ -65,6 +68,30 @@ export class TopcoderReportsController { return this.reports.getLeaderboardGeneric(query); } + @Get("/topcoder/leaderboard/campus") + // Campus members read their own program leaderboard, so no report scope is + // required; access to private groups is enforced in the service instead. + @RequiredScopes() + @ApiOperation({ + summary: + "Campus program leaderboard for every member of the requested group, including members with no challenge activity", + }) + getCampusLeaderboard( + @Query() query: CampusLeaderboardQueryDto, + @Req() request: { authUser?: AuthUserLike & { userId?: string | number } }, + ) { + const authUser = request.authUser; + + return this.reports.getCampusLeaderboard(query, { + userId: authUser?.userId, + hasReportAccess: hasAccessToScopes(authUser, [ + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.TopcoderLeaderboardReports, + ]), + }); + } + @Get("/topcoder/leaderboard/mm") @RequiredScopes( AppScopes.AllReports, diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 8ce77e7..6866a86 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -1,6 +1,12 @@ -import { Injectable, NotFoundException, OnModuleDestroy } from "@nestjs/common"; +import { + ForbiddenException, + Injectable, + NotFoundException, + OnModuleDestroy, +} from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { DbService } from "../../db/db.service"; +import { CampusChallengeFilter } from "./dto/campus-leaderboard.dto"; import { SqlLoaderService } from "../../common/sql-loader.service"; import { alpha3ToCountryName } from "../../common/country.util"; import { Pool } from "pg"; @@ -124,6 +130,80 @@ type LeaderboardGenericRow = { userSubmissionsCount: number; }; +type CampusGroupRow = { + groupId: string; + groupName: string; + groupOldId: string | null; + privateGroup: boolean; + callerIsMember: boolean; +}; + +type CampusLeaderboardRow = { + userId: string; + handle: string | null; + firstName: string | null; + lastName: string | null; + photoURL: string | null; + rating: string | number | null; + ratingColor: string | null; + groupJoinedAt: Date | string | null; + memberSince: Date | string | null; + challengeId: string | null; + challengeName: string | null; + challengeStatus: string | null; + challengeType: string | null; + challengeTrack: string | null; + challengeEndDate: Date | string | null; + isCampusChallenge: boolean | null; + isPublicChallenge: boolean | null; + registeredAt: Date | string | null; + registered: boolean | null; + submitted: boolean | null; + passedReview: boolean | null; + submittedDate: Date | string | null; + score: string | number | null; + won: boolean | null; + placement: number | null; +}; + +type CampusParticipationEntry = { + challengeId: string; + challengeName: string | null; + challengeStatus: string | null; + challengeType: string | null; + challengeTrack: string | null; + challengeEndDate: string | null; + isCampusChallenge: boolean; + isPublicChallenge: boolean; + registered: boolean; + registeredAt: string | null; + submitted: boolean; + submittedDate: string | null; + passedReview: boolean; + score: number | null; + won: boolean; + placement: number | null; +}; + +type CampusLeaderboardEntry = { + rank: number; + userId: string; + handle: string | null; + firstName: string | null; + lastName: string | null; + photoURL: string | null; + rating: number | null; + ratingColor: string | null; + signupDate: string | null; + memberSince: string | null; + registrations: number; + submissions: number; + passingSubmissions: number; + wins: number; + hasActivity: boolean; + challenges: CampusParticipationEntry[]; +}; + type LeaderboardMmRow = { challengeId: string; challengeName: string; @@ -1174,6 +1254,217 @@ export class TopcoderReportsService implements OnModuleDestroy { }; } + /** + * Campus program leaderboard for every member of the requested group. + * + * Members with no challenge activity are included (ranked last). Submissions and + * passing submissions are counted at most once per member per challenge. Tasks + * and First2Finish challenges are excluded. + * + * @param filters Group name and challenge visibility filter. + * @param caller Caller identity used to authorize access to private groups. + */ + async getCampusLeaderboard( + filters: { + groupName: string; + challengeFilter?: CampusChallengeFilter; + }, + caller: { + userId?: string | number | null; + hasReportAccess?: boolean; + } = {}, + ) { + const groupName = filters.groupName?.trim() ?? ""; + const callerId = + caller.userId === null || caller.userId === undefined + ? null + : String(caller.userId).trim() || null; + + const groupQuery = this.sql.load( + "reports/topcoder/campus-leaderboard-group.sql", + ); + const [group] = await this.db.query(groupQuery, [ + groupName, + callerId, + ]); + + if (!group) { + throw new NotFoundException(`Group "${groupName}" was not found.`); + } + + if ( + group.privateGroup && + !caller.hasReportAccess && + !group.callerIsMember + ) { + throw new ForbiddenException( + "You do not have the required permissions to access this leaderboard.", + ); + } + + const challengeFilter = + filters.challengeFilter ?? CampusChallengeFilter.All; + const query = this.sql.load("reports/topcoder/campus-leaderboard.sql"); + const rows = await this.db.query(query, [groupName]); + + const membersById = new Map(); + // Unfiltered participation, kept aside so the summary tiles can report + // activity across every challenge regardless of the visibility filter. + const allChallengesByUser = new Map(); + + rows.forEach((row) => { + const userId = String(row.userId); + let member = membersById.get(userId); + + if (!member) { + member = { + rank: 0, + userId, + handle: this.toOptionalString(row.handle), + firstName: this.toOptionalString(row.firstName), + lastName: this.toOptionalString(row.lastName), + photoURL: this.toOptionalString(row.photoURL), + rating: this.toNullableNumber(row.rating), + ratingColor: this.toOptionalString(row.ratingColor), + signupDate: + this.normalizeDate(row.groupJoinedAt) ?? + this.normalizeDate(row.memberSince), + memberSince: this.normalizeDate(row.memberSince), + registrations: 0, + submissions: 0, + passingSubmissions: 0, + wins: 0, + hasActivity: false, + challenges: [], + }; + membersById.set(userId, member); + allChallengesByUser.set(userId, []); + } + + if (!row.challengeId) { + return; + } + + allChallengesByUser.get(userId)!.push({ + challengeId: row.challengeId, + challengeName: this.toOptionalString(row.challengeName), + challengeStatus: this.toOptionalString(row.challengeStatus), + challengeType: this.toOptionalString(row.challengeType), + challengeTrack: this.toOptionalString(row.challengeTrack), + challengeEndDate: this.normalizeDate(row.challengeEndDate), + isCampusChallenge: row.isCampusChallenge === true, + isPublicChallenge: row.isPublicChallenge === true, + registered: row.registered === true, + registeredAt: this.normalizeDate(row.registeredAt), + submitted: row.submitted === true, + submittedDate: this.normalizeDate(row.submittedDate), + passedReview: row.passedReview === true, + score: this.toNullableNumber(row.score), + won: row.won === true, + placement: row.placement ?? null, + }); + }); + + const members = Array.from(membersById.values()); + + const summary = { + totalMembers: members.length, + membersRegistered: members.filter((member) => + allChallengesByUser + .get(member.userId)! + .some((entry) => entry.registered), + ).length, + membersSubmitted: members.filter((member) => + allChallengesByUser + .get(member.userId)! + .some((entry) => entry.submitted), + ).length, + }; + + members.forEach((member) => { + const challenges = allChallengesByUser + .get(member.userId)! + .filter((entry) => + this.matchesCampusChallengeFilter(entry, challengeFilter), + ); + + member.challenges = challenges; + member.hasActivity = challenges.length > 0; + member.registrations = challenges.filter( + (entry) => entry.registered, + ).length; + member.submissions = challenges.filter((entry) => entry.submitted).length; + member.passingSubmissions = challenges.filter( + (entry) => entry.submitted && entry.passedReview, + ).length; + member.wins = challenges.filter((entry) => entry.won).length; + }); + + const sortKeys = (member: CampusLeaderboardEntry) => [ + -member.wins, + -member.passingSubmissions, + -member.registrations, + member.signupDate && Number.isFinite(Date.parse(member.signupDate)) + ? Date.parse(member.signupDate) + : Number.MAX_SAFE_INTEGER, + ]; + + const sortedMembers = members.sort((left, right) => { + const leftKeys = sortKeys(left); + const rightKeys = sortKeys(right); + + for (let index = 0; index < leftKeys.length; index += 1) { + if (leftKeys[index] !== rightKeys[index]) { + return leftKeys[index] - rightKeys[index]; + } + } + + return (left.handle ?? "").localeCompare(right.handle ?? ""); + }); + + let previousKeys: number[] | null = null; + let previousRank = 0; + + const leaderboard = sortedMembers.map((member, index) => { + const keys = sortKeys(member); + const tiedWithPrevious = + !!previousKeys && previousKeys.every((key, i) => key === keys[i]); + const rank = tiedWithPrevious ? previousRank : index + 1; + + previousKeys = keys; + previousRank = rank; + + return { ...member, rank }; + }); + + return { + group: { + id: group.groupId, + name: group.groupName, + oldId: group.groupOldId ?? null, + privateGroup: group.privateGroup === true, + }, + challengeFilter, + summary, + members: leaderboard, + }; + } + + private matchesCampusChallengeFilter( + entry: CampusParticipationEntry, + challengeFilter: CampusChallengeFilter, + ): boolean { + if (challengeFilter === CampusChallengeFilter.Public) { + return entry.isPublicChallenge; + } + + if (challengeFilter === CampusChallengeFilter.Campus) { + return entry.isCampusChallenge; + } + + return true; + } + private calculateWriterTesterBonuses( placementData: Record, writersAndTesters: string[] | undefined, diff --git a/src/statistics/expert-skills-statistics.data.spec.ts b/src/statistics/expert-skills-statistics.data.spec.ts new file mode 100644 index 0000000..c16903c --- /dev/null +++ b/src/statistics/expert-skills-statistics.data.spec.ts @@ -0,0 +1,35 @@ +import { + MAX_CATEGORY_SIZE, + MIN_CATEGORY_SIZE, + getCategoryAppearance, + normalizeCategoryName, + normalizeCategorySizes, +} from "./expert-skills-statistics.data"; + +describe("expert-skills-statistics.data", () => { + it("normalizes catalog names for lookup", () => { + expect(normalizeCategoryName("Programming & Development")).toBe( + normalizeCategoryName("Programming and Development"), + ); + }); + + it("assigns a stable color and icon from the category id", () => { + const first = getCategoryAppearance("481b5ebc-2fe6-45ed-a90c-736936d458d7"); + const second = getCategoryAppearance( + "481b5ebc-2fe6-45ed-a90c-736936d458d7", + ); + + expect(first).toEqual(second); + expect(first.color).toMatch(/^#[0-9A-F]{6}$/i); + expect(first.icon).toMatch(/Icon$/); + }); + + it("normalizes sizes on sqrt(wins) into the bubble range", () => { + const sizes = normalizeCategorySizes([10000, 100, 4]); + + expect(sizes[0]).toBe(MAX_CATEGORY_SIZE); + expect(sizes[sizes.length - 1]).toBe(MIN_CATEGORY_SIZE); + expect(sizes[1]).toBeGreaterThan(MIN_CATEGORY_SIZE); + expect(sizes[1]).toBeLessThan(MAX_CATEGORY_SIZE); + }); +}); diff --git a/src/statistics/expert-skills-statistics.data.ts b/src/statistics/expert-skills-statistics.data.ts new file mode 100644 index 0000000..33a090f --- /dev/null +++ b/src/statistics/expert-skills-statistics.data.ts @@ -0,0 +1,114 @@ +/** + * Visual tokens for Skill Statistics bubbles. + * Category names/ids come from standardized-skills; these palettes are + * assigned by hashing the category id so new catalog entries still render. + */ +const CATEGORY_COLORS = [ + "#1B4F72", + "#3D8B8F", + "#5B9BD5", + "#5EB3C4", + "#1A3D3D", + "#2D4A3E", + "#4A5D4A", + "#3D7EA6", + "#2C5F8A", + "#7EB8C4", + "#2C4A6E", + "#3D5C5C", + "#6B7C4A", + "#2D5A8A", + "#5A8A8A", + "#4EC4C4", + "#5A8A9A", + "#4A9A9A", + "#3D6A8A", + "#5EB8B0", + "#3D5A6E", + "#4A6A7A", +]; + +const CATEGORY_ICONS = [ + "TerminalIcon", + "RssIcon", + "GlobeAltIcon", + "ShieldCheckIcon", + "CloudIcon", + "RefreshIcon", + "DuplicateIcon", + "ChipIcon", + "ChartBarIcon", + "SparklesIcon", + "ServerIcon", + "PencilAltIcon", + "CalculatorIcon", + "CubeTransparentIcon", + "MapIcon", + "DesktopComputerIcon", + "ClipboardCheckIcon", + "DatabaseIcon", + "DeviceMobileIcon", + "ShareIcon", + "WifiIcon", + "ClipboardListIcon", + "CodeIcon", +]; + +export type CategoryAppearance = { + color: string; + icon: string; +}; + +export function getCategoryAppearance(categoryId: string): CategoryAppearance { + const hash = hashString(categoryId); + + return { + color: CATEGORY_COLORS[hash % CATEGORY_COLORS.length], + icon: CATEGORY_ICONS[hash % CATEGORY_ICONS.length], + }; +} + +export function normalizeCategoryName(value: string): string { + return value + .toLowerCase() + .replace(/&/g, "and") + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +export const MIN_CATEGORY_SIZE = 3; +export const MAX_CATEGORY_SIZE = 10; + +export function normalizeCategorySizes(wins: number[]): number[] { + if (!wins.length) { + return []; + } + + const roots = wins.map((value) => Math.sqrt(Math.max(value, 0))); + const minRoot = Math.min(...roots); + const maxRoot = Math.max(...roots); + + if (maxRoot <= minRoot) { + return wins.map(() => (MIN_CATEGORY_SIZE + MAX_CATEGORY_SIZE) / 2); + } + + return roots.map((root) => { + const size = + MIN_CATEGORY_SIZE + + ((MAX_CATEGORY_SIZE - MIN_CATEGORY_SIZE) * (root - minRoot)) / + (maxRoot - minRoot); + + return Number(size.toFixed(1)); + }); +} + +function hashString(value: string): number { + let hash = 2166136261; + + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + + return hash >>> 0; +} diff --git a/src/statistics/expert-skills-statistics.service.spec.ts b/src/statistics/expert-skills-statistics.service.spec.ts new file mode 100644 index 0000000..a508e8d --- /dev/null +++ b/src/statistics/expert-skills-statistics.service.spec.ts @@ -0,0 +1,154 @@ +import { NotFoundException } from "@nestjs/common"; +import { DbService } from "../db/db.service"; +import { SqlLoaderService } from "../common/sql-loader.service"; +import { ExpertSkillsStatisticsService } from "./expert-skills-statistics.service"; + +describe("ExpertSkillsStatisticsService", () => { + const db = { + query: jest.fn(), + }; + const sql = { + load: jest.fn().mockReturnValue("SELECT expert skills"), + }; + const service = new ExpertSkillsStatisticsService( + db as unknown as DbService, + sql as unknown as SqlLoaderService, + ); + + beforeEach(() => { + jest.clearAllMocks(); + sql.load.mockReturnValue("SELECT expert skills"); + }); + + it("maps catalog categories to stats and normalized sizes", async () => { + db.query + .mockResolvedValueOnce([ + { + id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", + name: "Programming and Development", + }, + { + id: "1f5ed3e8-8d22-44ea-b75d-ea85147a04da", + name: "Scripting and Automation", + }, + ]) + .mockResolvedValueOnce([ + { + id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", + totalMembers: 101, + totalSkills: 50, + totalWins: 10000, + skillsBreakdown: [{ name: "JavaScript", percentage: 40 }], + }, + { + id: "1f5ed3e8-8d22-44ea-b75d-ea85147a04da", + totalMembers: 10, + totalSkills: 4, + totalWins: 4, + skillsBreakdown: [], + }, + ]); + + const result = await service.getCategories(); + + expect(sql.load).toHaveBeenCalledWith( + "reports/statistics/expert-skills/categories.sql", + ); + expect(sql.load).toHaveBeenCalledWith( + "reports/statistics/expert-skills/category-stats.sql", + ); + expect(db.query).toHaveBeenNthCalledWith(1, "SELECT expert skills"); + expect(db.query).toHaveBeenNthCalledWith(2, "SELECT expert skills", [ + [ + "481b5ebc-2fe6-45ed-a90c-736936d458d7", + "1f5ed3e8-8d22-44ea-b75d-ea85147a04da", + ], + ]); + expect(result[0]).toEqual( + expect.objectContaining({ + id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", + name: "Programming and Development", + officialName: "Programming and Development", + size: 10, + totalMembers: 101, + totalSkills: 50, + }), + ); + expect(result[0].color).toMatch(/^#[0-9A-F]{6}$/i); + expect(result[1].name).toBe("Scripting and Automation"); + expect(result[1].size).toBe(3); + }); + + it("returns an empty list when the catalog has no categories", async () => { + db.query.mockResolvedValueOnce([]); + + await expect(service.getCategories()).resolves.toEqual([]); + expect(sql.load).toHaveBeenCalledWith( + "reports/statistics/expert-skills/categories.sql", + ); + expect(sql.load).not.toHaveBeenCalledWith( + "reports/statistics/expert-skills/category-stats.sql", + ); + }); + + it("returns top members for a catalog category name", async () => { + db.query + .mockResolvedValueOnce([ + { + id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", + name: "Programming and Development", + }, + ]) + .mockResolvedValueOnce([ + { + handle: "billzedison", + firstName: "Honghan", + lastName: "Wu", + photoUrl: null, + countryCode: "IND", + rating: 2000, + wins: 376, + }, + ]); + + const result = await service.getCategoryMembers( + "Programming & Development", + ); + + expect(sql.load).toHaveBeenCalledWith( + "reports/statistics/expert-skills/categories.sql", + ); + expect(sql.load).toHaveBeenCalledWith( + "reports/statistics/expert-skills/category-members.sql", + ); + expect(db.query).toHaveBeenNthCalledWith(2, "SELECT expert skills", [ + "481b5ebc-2fe6-45ed-a90c-736936d458d7", + 100, + ]); + expect(result).toEqual([ + { + countryCode: "IN", + countryName: "India", + handle: "billzedison", + name: "Honghan W", + photoURL: null, + rating: 2000, + wins: 376, + }, + ]); + }); + + it("throws when the selected category is not in the catalog", async () => { + db.query.mockResolvedValueOnce([ + { + id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", + name: "Programming and Development", + }, + ]); + + await expect( + service.getCategoryMembers("Test Cat QA 1"), + ).rejects.toBeInstanceOf(NotFoundException); + expect(db.query).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/statistics/expert-skills-statistics.service.ts b/src/statistics/expert-skills-statistics.service.ts new file mode 100644 index 0000000..b99f508 --- /dev/null +++ b/src/statistics/expert-skills-statistics.service.ts @@ -0,0 +1,187 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { + alpha3ToCountryName, + toAlpha2CountryCode, +} from "../common/country.util"; +import { SqlLoaderService } from "../common/sql-loader.service"; +import { DbService } from "../db/db.service"; +import { + getCategoryAppearance, + normalizeCategoryName, + normalizeCategorySizes, +} from "./expert-skills-statistics.data"; + +const MEMBERS_LIMIT = 100; +const COUNTRY_DISPLAY_NAMES: Record = { + US: "USA", + GB: "UK", +}; + +type SkillBreakdown = { + name: string; + percentage: number; +}; + +type CategoryStatsRow = { + id: string; + totalMembers: number; + totalSkills: number; + totalWins: number; + skillsBreakdown: SkillBreakdown[] | null; +}; + +type MemberRow = { + handle: string; + firstName: string | null; + lastName: string | null; + photoUrl: string | null; + countryCode: string | null; + rating: number | null; + wins: number | null; +}; + +type SkillCategoryRow = { + id: string; + name: string; +}; + +function toCountryName(countryCode: string, rawValue?: string | null): string { + if (COUNTRY_DISPLAY_NAMES[countryCode]) { + return COUNTRY_DISPLAY_NAMES[countryCode]; + } + + const named = + alpha3ToCountryName(countryCode) || alpha3ToCountryName(rawValue); + if (named) { + return named; + } + + return String(rawValue || "").trim(); +} + +function formatMemberName( + firstName: string | null, + lastName: string | null, + handle: string, +): string { + const first = String(firstName || "").trim(); + const last = String(lastName || "").trim(); + + if (first && last) { + return `${first} ${last.charAt(0).toUpperCase()}`; + } + + return first || handle; +} + +@Injectable() +export class ExpertSkillsStatisticsService { + constructor( + private readonly db: DbService, + private readonly sql: SqlLoaderService, + ) {} + + async getCategories() { + const categories = await this.loadCategories(); + if (!categories.length) { + return []; + } + + const statsById = await this.loadCategoryStats( + categories.map((category) => category.id), + ); + const sizes = normalizeCategorySizes( + categories.map( + (category) => Number(statsById.get(category.id)?.totalWins) || 0, + ), + ); + + return categories.map((category, index) => { + const stats = statsById.get(category.id); + const appearance = getCategoryAppearance(category.id); + + return { + id: category.id, + name: category.name, + officialName: category.name, + color: appearance.color, + icon: appearance.icon, + size: sizes[index], + totalMembers: Number(stats?.totalMembers) || 0, + totalSkills: Number(stats?.totalSkills) || 0, + skillsBreakdown: stats?.skillsBreakdown ?? [], + }; + }); + } + + async getCategoryMembers(selectedCategory: string) { + const category = await this.findCategory(selectedCategory); + const q = this.sql.load( + "reports/statistics/expert-skills/category-members.sql", + ); + const rows = await this.db.query(q, [ + category.id, + MEMBERS_LIMIT, + ]); + + return rows.map((row) => { + const countryCode = toAlpha2CountryCode(row.countryCode); + + return { + countryCode, + countryName: toCountryName(countryCode, row.countryCode), + handle: row.handle, + name: formatMemberName(row.firstName, row.lastName, row.handle), + photoURL: row.photoUrl ?? null, + rating: Number(row.rating) || 0, + wins: Number(row.wins) || 0, + }; + }); + } + + private async findCategory( + selectedCategory: string, + ): Promise { + const requested = String(selectedCategory || "").trim(); + if (!requested) { + throw new NotFoundException("Skill category not found."); + } + + const categories = await this.loadCategories(); + const normalizedRequested = normalizeCategoryName(requested); + const match = categories.find( + (category) => + category.id === requested || + normalizeCategoryName(category.name) === normalizedRequested, + ); + + if (!match) { + throw new NotFoundException(`Skill category not found: ${requested}`); + } + + return match; + } + + private async loadCategories(): Promise { + const q = this.sql.load("reports/statistics/expert-skills/categories.sql"); + const rows = await this.db.query(q); + + return rows + .map((row) => ({ + id: String(row.id || "").trim(), + name: String(row.name || "").trim(), + })) + .filter((row) => row.id && row.name); + } + + private async loadCategoryStats( + categoryIds: string[], + ): Promise> { + const q = this.sql.load( + "reports/statistics/expert-skills/category-stats.sql", + ); + const rows = await this.db.query(q, [categoryIds]); + + return new Map(rows.map((row) => [row.id, row])); + } +} diff --git a/src/statistics/general-statistics.service.spec.ts b/src/statistics/general-statistics.service.spec.ts index 044617b..235c9be 100644 --- a/src/statistics/general-statistics.service.spec.ts +++ b/src/statistics/general-statistics.service.spec.ts @@ -1,3 +1,4 @@ +import { ConfigService } from "@nestjs/config"; import { DbService } from "../db/db.service"; import { SqlLoaderService } from "../common/sql-loader.service"; import { GeneralStatisticsService } from "./general-statistics.service"; @@ -9,9 +10,13 @@ describe("GeneralStatisticsService", () => { const sql = { load: jest.fn().mockReturnValue("SELECT tooltip data"), }; + const config = { + get: jest.fn().mockReturnValue('["Task","First2Finish"]'), + }; const service = new GeneralStatisticsService( db as unknown as DbService, sql as unknown as SqlLoaderService, + config as unknown as ConfigService, ); beforeEach(() => { @@ -65,6 +70,10 @@ describe("GeneralStatisticsService", () => { expect(sql.load).toHaveBeenCalledWith( "reports/statistics/general/top-winners-by-country.sql", ); + expect(db.query).toHaveBeenCalledWith( + "SELECT tooltip data", + [["Task", "First2Finish"]], + ); }); it("uses safe defaults when winner details are absent", async () => { @@ -85,6 +94,10 @@ describe("GeneralStatisticsService", () => { topWinners: [], }, ]); + expect(db.query).toHaveBeenCalledWith( + "SELECT tooltip data", + [["Task", "First2Finish"]], + ); }); it("normalizes country member, skill, and top-member details", async () => { @@ -178,5 +191,9 @@ describe("GeneralStatisticsService", () => { expect(sql.load).toHaveBeenCalledWith( "reports/statistics/general/country-member-details.sql", ); + expect(db.query).toHaveBeenCalledWith( + "SELECT tooltip data", + [["Task", "First2Finish"]], + ); }); }); diff --git a/src/statistics/general-statistics.service.ts b/src/statistics/general-statistics.service.ts index ece3315..da878db 100644 --- a/src/statistics/general-statistics.service.ts +++ b/src/statistics/general-statistics.service.ts @@ -1,4 +1,5 @@ import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { DbService } from "../db/db.service"; import { SqlLoaderService } from "../common/sql-loader.service"; import { alpha3ToCountryName } from "../common/country.util"; @@ -28,10 +29,44 @@ type CountryMemberDetailRow = { @Injectable() export class GeneralStatisticsService { + private readonly excludedChallengeTypes: string[]; + constructor( private readonly db: DbService, private readonly sql: SqlLoaderService, - ) {} + private readonly config: ConfigService, + ) { + this.excludedChallengeTypes = this.parseExcludedChallengeTypes(); + } + + private parseExcludedChallengeTypes(): string[] { + const raw = this.config + .get( + "REPORTS_EXCLUDED_CHALLENGE_TYPES", + '["Task","First2Finish"]', + ) + .trim(); + + if (!raw) { + return []; + } + + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed + .filter((item) => typeof item === "string" && item.trim()) + .map((item) => item.trim()); + } + } catch { + // ignore JSON parse failure and fall back to comma-separated values + } + + return raw + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } async getMemberCount() { const q = this.sql.load("reports/statistics/general/member-count.sql"); @@ -81,7 +116,9 @@ export class GeneralStatisticsService { const q = this.sql.load( "reports/statistics/general/country-member-details.sql", ); - const rows = await this.db.query(q); + const rows = await this.db.query(q, [ + this.excludedChallengeTypes, + ]); const countries = new Map< string, { @@ -208,7 +245,7 @@ export class GeneralStatisticsService { country_code: string | null; "challenge_stats.count": number | string | null; rank: number | string | null; - }>(q); + }>(q, [this.excludedChallengeTypes]); return rows.map((row) => { const countryName = alpha3ToCountryName(row.country_code) ?? row.country_code ?? ""; @@ -235,7 +272,7 @@ export class GeneralStatisticsService { photoURL?: string | null; wins?: number | string | null; }> | null; - }>(q); + }>(q, [this.excludedChallengeTypes]); return rows.map((row) => { const countryName = diff --git a/src/statistics/statistics-expert-skills.controller.ts b/src/statistics/statistics-expert-skills.controller.ts new file mode 100644 index 0000000..2dfc21f --- /dev/null +++ b/src/statistics/statistics-expert-skills.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Query } from "@nestjs/common"; +import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; +import { ExpertSkillsStatisticsService } from "./expert-skills-statistics.service"; + +@ApiTags("Statistics") +@Controller("/statistics/expert-skills") +export class StatisticsExpertSkillsController { + constructor(private readonly expertSkills: ExpertSkillsStatisticsService) {} + + @Get("/categories") + @ApiOperation({ + summary: + "Skill categories from skills.skill_category with win-normalized bubble sizes", + }) + getCategories() { + return this.expertSkills.getCategories(); + } + + @Get("/category-members") + @ApiOperation({ + summary: "Top 100 members in a skill category, sorted by wins", + }) + @ApiQuery({ + name: "selectedcategory", + required: true, + description: "Standardized-skills category name or UUID", + }) + getCategoryMembers(@Query("selectedcategory") selectedcategory: string) { + return this.expertSkills.getCategoryMembers(selectedcategory); + } +} diff --git a/src/statistics/statistics-expert-skills.sql.spec.ts b/src/statistics/statistics-expert-skills.sql.spec.ts new file mode 100644 index 0000000..0e9a0ee --- /dev/null +++ b/src/statistics/statistics-expert-skills.sql.spec.ts @@ -0,0 +1,42 @@ +import { SqlLoaderService } from "../common/sql-loader.service"; + +describe("Expert skills statistics SQL", () => { + const sqlLoader = new SqlLoaderService(); + + it("loads skill categories from the skills catalog table", () => { + const sql = sqlLoader.load( + "reports/statistics/expert-skills/categories.sql", + ); + + expect(sql).toContain("FROM skills.skill_category sc"); + expect(sql).toContain("ORDER BY sc.name ASC"); + expect(sql).not.toContain("NOT ILIKE 'Test Cat%'"); + }); + + it("aggregates category members, skills, and top win breakdown", () => { + const sql = sqlLoader.load( + "reports/statistics/expert-skills/category-stats.sql", + ); + + expect(sql).toContain("unnest($1::uuid[])"); + expect(sql).toContain("FROM skills.skill sk"); + expect(sql).toContain("FROM skills.user_skill us"); + expect(sql).toContain("FROM skills.skill_event se"); + expect(sql).toContain("challenge_win"); + expect(sql).toContain("gig_completion"); + expect(sql).toContain("ts.rn <= 3"); + expect(sql).not.toContain("NOT ILIKE 'Test Cat%'"); + }); + + it("returns top members by category wins", () => { + const sql = sqlLoader.load( + "reports/statistics/expert-skills/category-members.sql", + ); + + expect(sql).toContain("sk.category_id = $1::uuid"); + expect(sql).toContain("JOIN members.member m"); + expect(sql).toContain('members."memberMaxRating"'); + expect(sql).toContain("ORDER BY cw.wins DESC, m.handle ASC"); + expect(sql).toContain("LIMIT $2"); + }); +}); diff --git a/src/statistics/statistics.module.ts b/src/statistics/statistics.module.ts index dfacba6..1eb7c17 100644 --- a/src/statistics/statistics.module.ts +++ b/src/statistics/statistics.module.ts @@ -10,6 +10,8 @@ import { StatisticsDevelopmentController } from "./statistics-development.contro import { DevelopmentStatisticsService } from "./development-statistics.service"; import { StatisticsQaController } from "./statistics-qa.controller"; import { QaStatisticsService } from "./qa-statistics.service"; +import { StatisticsExpertSkillsController } from "./statistics-expert-skills.controller"; +import { ExpertSkillsStatisticsService } from "./expert-skills-statistics.service"; import { SqlLoaderService } from "../common/sql-loader.service"; @Module({ @@ -19,6 +21,7 @@ import { SqlLoaderService } from "../common/sql-loader.service"; StatisticsDesignController, StatisticsDevelopmentController, StatisticsQaController, + StatisticsExpertSkillsController, ], providers: [ SrmDataService, @@ -27,6 +30,7 @@ import { SqlLoaderService } from "../common/sql-loader.service"; DesignStatisticsService, DevelopmentStatisticsService, QaStatisticsService, + ExpertSkillsStatisticsService, SqlLoaderService, ], })