From c490ad5d7fc79617caa6bdcba5b49fc73c9513bd Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 17 Aug 2026 14:15:10 +0300 Subject: [PATCH 1/9] PM-5370 - Campus leaderboard --- .../topcoder/campus-leaderboard-group.sql | 48 +++ sql/reports/topcoder/campus-leaderboard.sql | 248 ++++++++++++++ .../guards/topcoder-reports.guard.spec.ts | 15 + .../campus-leaderboard.service.spec.ts | 310 ++++++++++++++++++ .../dto/campus-leaderboard.dto.spec.ts | 40 +++ .../topcoder/dto/campus-leaderboard.dto.ts | 22 ++ .../topcoder/topcoder-reports.controller.ts | 27 ++ .../topcoder/topcoder-reports.service.ts | 292 ++++++++++++++++- 8 files changed, 1001 insertions(+), 1 deletion(-) create mode 100644 sql/reports/topcoder/campus-leaderboard-group.sql create mode 100644 sql/reports/topcoder/campus-leaderboard.sql create mode 100644 src/reports/topcoder/campus-leaderboard.service.spec.ts create mode 100644 src/reports/topcoder/dto/campus-leaderboard.dto.spec.ts create mode 100644 src/reports/topcoder/dto/campus-leaderboard.dto.ts 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..ea7604a --- /dev/null +++ b/sql/reports/topcoder/campus-leaderboard.sql @@ -0,0 +1,248 @@ +-- 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. 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_wins 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.placement = 1 + 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_wins +), +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) 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_wins AS win + ON win.member_id = p.member_id + AND win.challenge_id = p.challenge_id +), +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/reports/topcoder/campus-leaderboard.service.spec.ts b/src/reports/topcoder/campus-leaderboard.service.spec.ts new file mode 100644 index 0000000..3b39cdb --- /dev/null +++ b/src/reports/topcoder/campus-leaderboard.service.spec.ts @@ -0,0 +1,310 @@ +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("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..8b962fb 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,216 @@ 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. + * + * @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 + ? 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, From e31db22e203330171369c121aaee9ecf37feda91 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 18 Aug 2026 15:22:49 +0300 Subject: [PATCH 2/9] PM-5370 - campus leaderboard Fix date check --- src/reports/topcoder/topcoder-reports.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 8b962fb..c97f913 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -1403,7 +1403,7 @@ export class TopcoderReportsService implements OnModuleDestroy { -member.wins, -member.passingSubmissions, -member.registrations, - member.signupDate + member.signupDate && Number.isFinite(Date.parse(member.signupDate)) ? Date.parse(member.signupDate) : Number.MAX_SAFE_INTEGER, ]; From c59b0c38233f48965dd7685dc7cdf2b3500ca705 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 19 Aug 2026 09:27:11 +0300 Subject: [PATCH 3/9] PM-5867 - exclude F2F, Task from general statistics --- .../general/country-member-details.sql | 3 ++ .../general/first-place-by-country.sql | 3 ++ .../general/top-winners-by-country.sql | 3 ++ .../general-statistics.service.spec.ts | 17 +++++++ src/statistics/general-statistics.service.ts | 45 +++++++++++++++++-- 5 files changed, 67 insertions(+), 4 deletions(-) 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/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 = From 525a34a80838f992b8363304e2a8757084db4ba0 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 19 Aug 2026 15:07:29 +0300 Subject: [PATCH 4/9] PM-5370 - include only "challengeWinenrs" with type=Placement --- sql/reports/topcoder/campus-leaderboard.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/sql/reports/topcoder/campus-leaderboard.sql b/sql/reports/topcoder/campus-leaderboard.sql index ea7604a..9433ecc 100644 --- a/sql/reports/topcoder/campus-leaderboard.sql +++ b/sql/reports/topcoder/campus-leaderboard.sql @@ -153,6 +153,7 @@ member_wins AS ( JOIN group_members AS gm ON gm.member_id = cw."userId"::text WHERE cw.placement = 1 + AND cw.type = 'PLACEMENT' GROUP BY cw."userId", cw."challengeId" ), participation AS ( From ef6234448e0de44442b2a69986c34cc8da103a85 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 20 Aug 2026 18:11:08 +0300 Subject: [PATCH 5/9] PM-5370 - return winner placement --- sql/reports/topcoder/campus-leaderboard.sql | 11 ++++--- .../campus-leaderboard.service.spec.ts | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/sql/reports/topcoder/campus-leaderboard.sql b/sql/reports/topcoder/campus-leaderboard.sql index 9433ecc..fd9f622 100644 --- a/sql/reports/topcoder/campus-leaderboard.sql +++ b/sql/reports/topcoder/campus-leaderboard.sql @@ -144,7 +144,7 @@ member_submissions AS ( FROM scored_submissions AS ss GROUP BY ss.member_id, ss.challenge_id ), -member_wins AS ( +member_placements AS ( SELECT cw."userId"::text AS member_id, cw."challengeId" AS challenge_id, @@ -152,8 +152,7 @@ member_wins AS ( FROM challenges."ChallengeWinner" AS cw JOIN group_members AS gm ON gm.member_id = cw."userId"::text - WHERE cw.placement = 1 - AND cw.type = 'PLACEMENT' + WHERE cw.type = 'PLACEMENT' GROUP BY cw."userId", cw."challengeId" ), participation AS ( @@ -161,7 +160,7 @@ participation AS ( UNION SELECT member_id, challenge_id FROM member_submissions UNION - SELECT member_id, challenge_id FROM member_wins + SELECT member_id, challenge_id FROM member_placements ), member_participation AS ( SELECT @@ -180,7 +179,7 @@ member_participation AS ( COALESCE(sub.has_passing_submission, FALSE) AS passed_review, sub.first_submitted_date, sub.best_score, - (win.member_id IS NOT NULL) AS won, + (win.member_id IS NOT NULL AND win.placement = 1) AS won, win.placement FROM participation AS p CROSS JOIN group_identifiers AS gi @@ -196,7 +195,7 @@ member_participation AS ( LEFT JOIN member_submissions AS sub ON sub.member_id = p.member_id AND sub.challenge_id = p.challenge_id - LEFT JOIN member_wins AS win + LEFT JOIN member_placements AS win ON win.member_id = p.member_id AND win.challenge_id = p.challenge_id ), diff --git a/src/reports/topcoder/campus-leaderboard.service.spec.ts b/src/reports/topcoder/campus-leaderboard.service.spec.ts index 3b39cdb..d57b299 100644 --- a/src/reports/topcoder/campus-leaderboard.service.spec.ts +++ b/src/reports/topcoder/campus-leaderboard.service.spec.ts @@ -181,6 +181,35 @@ describe("TopcoderReportsService.getCampusLeaderboard", () => { }); }); + 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. From 82f4c6e6f900e39edba1a70782eac838e5223776 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Sun, 23 Aug 2026 20:52:43 +0530 Subject: [PATCH 6/9] PM-4704 Expert skill categories api --- .circleci/config.yml | 2 +- src/app-constants.ts | 2 + src/app.module.ts | 2 + src/auth/permissions.util.spec.ts | 11 + src/common/country.util.ts | 19 ++ ...expert-skill-category-members-query.dto.ts | 14 + .../dto/expert-skill-category.dto.ts | 78 +++++ .../expert-skill-categories.data.spec.ts | 35 ++ .../expert-skill-categories.data.ts | 114 +++++++ .../expert-skills.controller.spec.ts | 88 +++++ .../expert-skills/expert-skills.controller.ts | 58 ++++ .../expert-skills/expert-skills.module.ts | 11 + .../expert-skills.service.spec.ts | 163 +++++++++ .../expert-skills/expert-skills.service.ts | 320 ++++++++++++++++++ .../guards/expert-skills.guard.spec.ts | 97 ++++++ .../guards/expert-skills.guard.ts | 55 +++ .../standardized-skills.client.spec.ts | 117 +++++++ .../standardized-skills.client.ts | 188 ++++++++++ src/reports/report-directory.data.spec.ts | 4 + src/reports/report-directory.data.ts | 30 ++ 20 files changed, 1407 insertions(+), 1 deletion(-) create mode 100644 src/reports/expert-skills/dto/expert-skill-category-members-query.dto.ts create mode 100644 src/reports/expert-skills/dto/expert-skill-category.dto.ts create mode 100644 src/reports/expert-skills/expert-skill-categories.data.spec.ts create mode 100644 src/reports/expert-skills/expert-skill-categories.data.ts create mode 100644 src/reports/expert-skills/expert-skills.controller.spec.ts create mode 100644 src/reports/expert-skills/expert-skills.controller.ts create mode 100644 src/reports/expert-skills/expert-skills.module.ts create mode 100644 src/reports/expert-skills/expert-skills.service.spec.ts create mode 100644 src/reports/expert-skills/expert-skills.service.ts create mode 100644 src/reports/expert-skills/guards/expert-skills.guard.spec.ts create mode 100644 src/reports/expert-skills/guards/expert-skills.guard.ts create mode 100644 src/reports/expert-skills/standardized-skills.client.spec.ts create mode 100644 src/reports/expert-skills/standardized-skills.client.ts 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/src/app-constants.ts b/src/app-constants.ts index 72ca01d..28258cd 100644 --- a/src/app-constants.ts +++ b/src/app-constants.ts @@ -11,6 +11,7 @@ export const Scopes = { EngagementData: "reports:member-engagement-data", RecentMemberData: "reports:member-recent-member-data", MemberSearch: "reports:member-search", + ExpertSkills: "reports:expert-skills", }, TopgearChallengeTechnology: "reports:topgear-challenge-technology", TopgearChallengeStatsByUser: "reports:topgear-challenge-stats-by-user", @@ -77,6 +78,7 @@ export const ScopeRoleAccess: Record = { [Scopes.Member.EngagementData]: [UserRoles.TalentManager], [Scopes.Member.RecentMemberData]: [UserRoles.TalentManager], [Scopes.Member.MemberSearch]: [UserRoles.TalentManager], + [Scopes.Member.ExpertSkills]: [UserRoles.TalentManager], [Scopes.Identity.UsersByHandles]: [ UserRoles.TalentManager, UserRoles.ProjectManager, diff --git a/src/app.module.ts b/src/app.module.ts index 1601f5e..7fb6b6f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,6 +12,7 @@ import { ChallengesReportsModule } from "./reports/challenges/challenges-reports import { IdentityReportsModule } from "./reports/identity/identity-reports.module"; import { ReportsModule } from "./reports/reports.module"; import { MemberSearchModule } from "./reports/member/member-search.module"; +import { ExpertSkillsModule } from "./reports/expert-skills/expert-skills.module"; import { PaymentReportsModule } from "./reports/payment/payment-reports.module"; import { DashboardReportsModule } from "./reports/dashboard/dashboard-reports.module"; @@ -27,6 +28,7 @@ import { DashboardReportsModule } from "./reports/dashboard/dashboard-reports.mo IdentityReportsModule, ReportsModule, MemberSearchModule, + ExpertSkillsModule, PaymentReportsModule, DashboardReportsModule, HealthModule, diff --git a/src/auth/permissions.util.spec.ts b/src/auth/permissions.util.spec.ts index 5ac4dc1..501d081 100644 --- a/src/auth/permissions.util.spec.ts +++ b/src/auth/permissions.util.spec.ts @@ -24,6 +24,17 @@ describe("permissions.util", () => { ).toBe(true); }); + it("allows topcoder-prefixed talent manager roles for expert skills", () => { + expect( + hasAccessToScopes( + { + roles: ["Topcoder Talent Manager"], + }, + [Scopes.Member.ExpertSkills], + ), + ).toBe(true); + }); + it("allows topcoder-prefixed talent manager roles for recent member data", () => { expect( hasAccessToScopes( 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/expert-skills/dto/expert-skill-category-members-query.dto.ts b/src/reports/expert-skills/dto/expert-skill-category-members-query.dto.ts new file mode 100644 index 0000000..d38010a --- /dev/null +++ b/src/reports/expert-skills/dto/expert-skill-category-members-query.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString } from "class-validator"; + +export class ExpertSkillCategoryMembersQueryDto { + @ApiProperty({ + name: "selectedcategory", + description: + "Category name from the standardized-skills catalog, or the category UUID", + example: "Programming and Development", + }) + @IsString() + @IsNotEmpty() + selectedcategory!: string; +} diff --git a/src/reports/expert-skills/dto/expert-skill-category.dto.ts b/src/reports/expert-skills/dto/expert-skill-category.dto.ts new file mode 100644 index 0000000..5cc7f26 --- /dev/null +++ b/src/reports/expert-skills/dto/expert-skill-category.dto.ts @@ -0,0 +1,78 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; + +export class ExpertSkillBreakdownDto { + @ApiProperty({ description: "Skill name" }) + name!: string; + + @ApiProperty({ + description: "Share of category wins for this skill, rounded to an integer", + }) + percentage!: number; +} + +export class ExpertSkillCategoryDto { + @ApiProperty({ description: "Skill category UUID" }) + id!: string; + + @ApiProperty({ + description: "Category name from the standardized-skills catalog", + }) + name!: string; + + @ApiProperty({ + description: "Official category name from the standardized-skills catalog", + }) + officialName!: string; + + @ApiProperty({ description: "Hex color for the bubble" }) + color!: string; + + @ApiProperty({ + description: "Heroicon outline name used by the bubble UI", + example: "TerminalIcon", + }) + icon!: string; + + @ApiProperty({ + description: + "Bubble size from 3–10, min-max normalized on sqrt(category wins) so outliers do not dominate", + }) + size!: number; + + @ApiProperty({ + description: "Distinct members with a skill in this category", + }) + totalMembers!: number; + + @ApiProperty({ description: "Active skills in this category" }) + totalSkills!: number; + + @ApiProperty({ type: [ExpertSkillBreakdownDto] }) + skillsBreakdown!: ExpertSkillBreakdownDto[]; +} + +export class ExpertSkillCategoryMemberDto { + @ApiProperty({ + description: "ISO 3166-1 alpha-2 country code", + example: "US", + }) + countryCode!: string; + + @ApiProperty({ description: "Country display name", example: "USA" }) + countryName!: string; + + @ApiProperty({ description: "Member handle" }) + handle!: string; + + @ApiProperty({ description: "Display name (first name and last initial)" }) + name!: string; + + @ApiPropertyOptional({ description: "Profile photo URL", nullable: true }) + photoURL?: string | null; + + @ApiProperty({ description: "Maximum member rating" }) + rating!: number; + + @ApiProperty({ description: "Wins in the selected skill category" }) + wins!: number; +} diff --git a/src/reports/expert-skills/expert-skill-categories.data.spec.ts b/src/reports/expert-skills/expert-skill-categories.data.spec.ts new file mode 100644 index 0000000..235d7d9 --- /dev/null +++ b/src/reports/expert-skills/expert-skill-categories.data.spec.ts @@ -0,0 +1,35 @@ +import { + MAX_CATEGORY_SIZE, + MIN_CATEGORY_SIZE, + getCategoryAppearance, + normalizeCategoryName, + normalizeCategorySizes, +} from "./expert-skill-categories.data"; + +describe("expert-skill-categories.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/reports/expert-skills/expert-skill-categories.data.ts b/src/reports/expert-skills/expert-skill-categories.data.ts new file mode 100644 index 0000000..33a090f --- /dev/null +++ b/src/reports/expert-skills/expert-skill-categories.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/reports/expert-skills/expert-skills.controller.spec.ts b/src/reports/expert-skills/expert-skills.controller.spec.ts new file mode 100644 index 0000000..18581a5 --- /dev/null +++ b/src/reports/expert-skills/expert-skills.controller.spec.ts @@ -0,0 +1,88 @@ +import { NotFoundException } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { ExpertSkillsController } from "./expert-skills.controller"; +import { ExpertSkillsService } from "./expert-skills.service"; + +describe("ExpertSkillsController", () => { + let controller: ExpertSkillsController; + + const mockExpertSkillsService = { + getCategories: jest.fn(), + getCategoryMembers: jest.fn(), + }; + + beforeEach(async () => { + mockExpertSkillsService.getCategories.mockReset(); + mockExpertSkillsService.getCategoryMembers.mockReset(); + + const moduleRef: TestingModule = await Test.createTestingModule({ + controllers: [ExpertSkillsController], + providers: [ + { + provide: ExpertSkillsService, + useValue: mockExpertSkillsService, + }, + ], + }).compile(); + + controller = moduleRef.get(ExpertSkillsController); + }); + + it("creates the controller", () => { + expect(controller).toBeDefined(); + }); + + it("delegates category requests to the service", async () => { + const response = [ + { + id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", + name: "Programming & Development", + officialName: "Programming and Development", + color: "#1B4F72", + icon: "TerminalIcon", + size: 10, + totalMembers: 10, + totalSkills: 5, + skillsBreakdown: [{ name: "JavaScript", percentage: 40 }], + }, + ]; + mockExpertSkillsService.getCategories.mockResolvedValue(response); + + await expect(controller.getCategories()).resolves.toEqual(response); + expect(mockExpertSkillsService.getCategories).toHaveBeenCalledTimes(1); + }); + + it("delegates member requests with selectedcategory", async () => { + const response = [ + { + countryCode: "IN", + countryName: "India", + handle: "billzedison", + name: "Honghan W", + photoURL: null, + rating: 2000, + wins: 376, + }, + ]; + mockExpertSkillsService.getCategoryMembers.mockResolvedValue(response); + + await expect( + controller.getCategoryMembers({ + selectedcategory: "Programming & Development", + }), + ).resolves.toEqual(response); + expect(mockExpertSkillsService.getCategoryMembers).toHaveBeenCalledWith( + "Programming & Development", + ); + }); + + it("propagates missing category errors", async () => { + mockExpertSkillsService.getCategoryMembers.mockRejectedValue( + new NotFoundException("Skill category not found"), + ); + + await expect( + controller.getCategoryMembers({ selectedcategory: "Unknown" }), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/src/reports/expert-skills/expert-skills.controller.ts b/src/reports/expert-skills/expert-skills.controller.ts new file mode 100644 index 0000000..45403f6 --- /dev/null +++ b/src/reports/expert-skills/expert-skills.controller.ts @@ -0,0 +1,58 @@ +import { Controller, Get, Query, UseGuards } from "@nestjs/common"; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; +import { ExpertSkillCategoryMembersQueryDto } from "./dto/expert-skill-category-members-query.dto"; +import { + ExpertSkillCategoryDto, + ExpertSkillCategoryMemberDto, +} from "./dto/expert-skill-category.dto"; +import { ExpertSkillsGuard } from "./guards/expert-skills.guard"; +import { ExpertSkillsService } from "./expert-skills.service"; + +@ApiTags("Expert Skills") +@ApiBearerAuth() +@UseGuards(ExpertSkillsGuard) +@Controller() +export class ExpertSkillsController { + constructor(private readonly expertSkillsService: ExpertSkillsService) {} + + @Get("expert-skill-categories") + @ApiOperation({ + summary: "List skill categories for the Skill Statistics bubble view", + description: + "Returns skill categories from the standardized-skills catalog, with win-normalized bubble sizes (3–10). Accessible by Administrator and Talent Manager roles only.", + }) + @ApiResponse({ status: 200, type: [ExpertSkillCategoryDto] }) + @ApiResponse({ status: 401, description: "Unauthenticated" }) + @ApiResponse({ status: 403, description: "Forbidden – insufficient role" }) + getCategories(): Promise { + return this.expertSkillsService.getCategories(); + } + + @Get("expert-skill-category-members") + @ApiOperation({ + summary: "List top members for a skill category", + description: + "Returns up to 100 members with wins in the selected skill category, sorted by wins descending. selectedcategory is the standardized-skills category name or UUID. Accessible by Administrator and Talent Manager roles only.", + }) + @ApiQuery({ + name: "selectedcategory", + required: true, + description: "Standardized-skills category name or UUID", + }) + @ApiResponse({ status: 200, type: [ExpertSkillCategoryMemberDto] }) + @ApiResponse({ status: 400, description: "Validation error" }) + @ApiResponse({ status: 401, description: "Unauthenticated" }) + @ApiResponse({ status: 403, description: "Forbidden – insufficient role" }) + @ApiResponse({ status: 404, description: "Skill category not found" }) + getCategoryMembers( + @Query() query: ExpertSkillCategoryMembersQueryDto, + ): Promise { + return this.expertSkillsService.getCategoryMembers(query.selectedcategory); + } +} diff --git a/src/reports/expert-skills/expert-skills.module.ts b/src/reports/expert-skills/expert-skills.module.ts new file mode 100644 index 0000000..be8f192 --- /dev/null +++ b/src/reports/expert-skills/expert-skills.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { ExpertSkillsController } from "./expert-skills.controller"; +import { ExpertSkillsService } from "./expert-skills.service"; +import { ExpertSkillsGuard } from "./guards/expert-skills.guard"; +import { StandardizedSkillsClient } from "./standardized-skills.client"; + +@Module({ + controllers: [ExpertSkillsController], + providers: [ExpertSkillsService, ExpertSkillsGuard, StandardizedSkillsClient], +}) +export class ExpertSkillsModule {} diff --git a/src/reports/expert-skills/expert-skills.service.spec.ts b/src/reports/expert-skills/expert-skills.service.spec.ts new file mode 100644 index 0000000..cbd12c9 --- /dev/null +++ b/src/reports/expert-skills/expert-skills.service.spec.ts @@ -0,0 +1,163 @@ +import { NotFoundException } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { DbService } from "../../db/db.service"; +import { ExpertSkillsService } from "./expert-skills.service"; +import { StandardizedSkillsClient } from "./standardized-skills.client"; + +describe("ExpertSkillsService", () => { + let service: ExpertSkillsService; + + const mockDbService = { + query: jest.fn(), + }; + const mockStandardizedSkillsClient = { + fetchCategories: jest.fn(), + }; + + beforeEach(async () => { + mockDbService.query.mockReset(); + mockStandardizedSkillsClient.fetchCategories.mockReset(); + + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + ExpertSkillsService, + { provide: DbService, useValue: mockDbService }, + { + provide: StandardizedSkillsClient, + useValue: mockStandardizedSkillsClient, + }, + ], + }).compile(); + + service = moduleRef.get(ExpertSkillsService); + }); + + it("creates the service", () => { + expect(service).toBeDefined(); + }); + + it("maps catalog categories to stats and normalized sizes", async () => { + mockStandardizedSkillsClient.fetchCategories.mockResolvedValue([ + { + id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", + name: "Programming and Development", + }, + { + id: "1f5ed3e8-8d22-44ea-b75d-ea85147a04da", + name: "Scripting and Automation", + }, + ]); + mockDbService.query.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(); + const sql = mockDbService.query.mock.calls[0][0] as string; + const params = mockDbService.query.mock.calls[0][1] as unknown[]; + + expect(mockStandardizedSkillsClient.fetchCategories).toHaveBeenCalledTimes( + 1, + ); + expect(sql).toContain("unnest($1::uuid[])"); + expect(sql).toContain("challenge_win"); + expect(sql).not.toContain("NOT ILIKE 'Test Cat%'"); + expect(params).toEqual([ + [ + "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[0].icon).toMatch(/Icon$/); + 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 () => { + mockStandardizedSkillsClient.fetchCategories.mockResolvedValue([]); + + await expect(service.getCategories()).resolves.toEqual([]); + expect(mockDbService.query).not.toHaveBeenCalled(); + }); + + it("returns top members for a catalog category name", async () => { + mockStandardizedSkillsClient.fetchCategories.mockResolvedValue([ + { + id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", + name: "Programming and Development", + }, + ]); + mockDbService.query.mockResolvedValueOnce([ + { + handle: "billzedison", + firstName: "Honghan", + lastName: "Wu", + photoUrl: null, + countryCode: "IND", + rating: 2000, + wins: 376, + }, + ]); + + const result = await service.getCategoryMembers( + "Programming & Development", + ); + const membersSql = mockDbService.query.mock.calls[0][0] as string; + const membersParams = mockDbService.query.mock.calls[0][1] as unknown[]; + + expect(membersSql).toContain("ORDER BY cw.wins DESC, m.handle ASC"); + expect(membersSql).toContain("LIMIT $2"); + expect(membersParams).toEqual([ + "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 () => { + mockStandardizedSkillsClient.fetchCategories.mockResolvedValue([ + { + id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", + name: "Programming and Development", + }, + ]); + + await expect( + service.getCategoryMembers("Test Cat QA 1"), + ).rejects.toBeInstanceOf(NotFoundException); + expect(mockDbService.query).not.toHaveBeenCalled(); + }); +}); diff --git a/src/reports/expert-skills/expert-skills.service.ts b/src/reports/expert-skills/expert-skills.service.ts new file mode 100644 index 0000000..a2d4f3c --- /dev/null +++ b/src/reports/expert-skills/expert-skills.service.ts @@ -0,0 +1,320 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { + alpha3ToCountryName, + toAlpha2CountryCode, +} from "../../common/country.util"; +import { DbService } from "../../db/db.service"; +import { + ExpertSkillBreakdownDto, + ExpertSkillCategoryDto, + ExpertSkillCategoryMemberDto, +} from "./dto/expert-skill-category.dto"; +import { + getCategoryAppearance, + normalizeCategoryName, + normalizeCategorySizes, +} from "./expert-skill-categories.data"; +import { + StandardizedSkillCategory, + StandardizedSkillsClient, +} from "./standardized-skills.client"; + +const MEMBERS_LIMIT = 100; +const COUNTRY_DISPLAY_NAMES: Record = { + US: "USA", + GB: "UK", +}; + +type CategoryStatsRow = { + id: string; + totalMembers: number; + totalSkills: number; + totalWins: number; + skillsBreakdown: ExpertSkillBreakdownDto[] | null; +}; + +type MemberRow = { + handle: string; + firstName: string | null; + lastName: string | null; + photoUrl: string | null; + countryCode: string | null; + rating: number | null; + wins: number | null; +}; + +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 ExpertSkillsService { + constructor( + private readonly db: DbService, + private readonly standardizedSkills: StandardizedSkillsClient, + ) {} + + async getCategories(): Promise { + const categories = await this.standardizedSkills.fetchCategories(); + 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, + ): Promise { + const category = await this.findCategory(selectedCategory); + const rows = await this.db.query(this.buildMembersQuery(), [ + 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.standardizedSkills.fetchCategories(); + 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 loadCategoryStats( + categoryIds: string[], + ): Promise> { + const rows = await this.db.query( + this.buildCategoriesQuery(), + [categoryIds], + ); + + return new Map(rows.map((row) => [row.id, row])); + } + + private buildCategoriesQuery(): string { + return ` +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`; + } + + private buildMembersQuery(): string { + return ` +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/src/reports/expert-skills/guards/expert-skills.guard.spec.ts b/src/reports/expert-skills/guards/expert-skills.guard.spec.ts new file mode 100644 index 0000000..8173987 --- /dev/null +++ b/src/reports/expert-skills/guards/expert-skills.guard.spec.ts @@ -0,0 +1,97 @@ +import { + ExecutionContext, + ForbiddenException, + UnauthorizedException, +} from "@nestjs/common"; +import { Scopes, UserRoles } from "src/app-constants"; +import { ExpertSkillsGuard } from "./expert-skills.guard"; + +type AuthUserFixture = { + isMachine?: boolean; + roles?: string[]; + role?: string | string[]; + scopes?: string[]; +}; + +function createExecutionContext(authUser?: AuthUserFixture): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => ({ + authUser, + }), + }), + } as unknown as ExecutionContext; +} + +describe("ExpertSkillsGuard", () => { + const guard = new ExpertSkillsGuard(); + + it("throws when no auth user is present", () => { + expect(() => guard.canActivate(createExecutionContext())).toThrow( + UnauthorizedException, + ); + }); + + it("allows talent manager role access", () => { + expect( + guard.canActivate( + createExecutionContext({ + roles: [UserRoles.TalentManager], + }), + ), + ).toBe(true); + }); + + it("allows admin role access", () => { + expect( + guard.canActivate( + createExecutionContext({ + roles: ["Administrator"], + }), + ), + ).toBe(true); + }); + + it("allows machine clients with expert skills scope", () => { + expect( + guard.canActivate( + createExecutionContext({ + isMachine: true, + scopes: [Scopes.Member.ExpertSkills], + }), + ), + ).toBe(true); + }); + + it("allows machine clients with all reports scope", () => { + expect( + guard.canActivate( + createExecutionContext({ + isMachine: true, + scopes: [Scopes.AllReports], + }), + ), + ).toBe(true); + }); + + it("denies machine clients without required scopes", () => { + expect(() => + guard.canActivate( + createExecutionContext({ + isMachine: true, + scopes: [Scopes.Member.MemberSearch], + }), + ), + ).toThrow(ForbiddenException); + }); + + it("denies non-admin users without required role", () => { + expect(() => + guard.canActivate( + createExecutionContext({ + roles: [UserRoles.ProjectManager], + }), + ), + ).toThrow(ForbiddenException); + }); +}); diff --git a/src/reports/expert-skills/guards/expert-skills.guard.ts b/src/reports/expert-skills/guards/expert-skills.guard.ts new file mode 100644 index 0000000..0b1db71 --- /dev/null +++ b/src/reports/expert-skills/guards/expert-skills.guard.ts @@ -0,0 +1,55 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + UnauthorizedException, +} from "@nestjs/common"; +import { + AuthUserLike, + getNormalizedRoles, + hasAccessToScopes, + hasAdminRole, +} from "../../../auth/permissions.util"; +import { Scopes, UserRoles } from "src/app-constants"; + +const ALLOWED_ROLES = new Set([UserRoles.TalentManager.toLowerCase()]); + +const MACHINE_REQUIRED_SCOPES = [ + Scopes.AllReports, + Scopes.TopcoderReports, + Scopes.Member.ExpertSkills, +]; + +@Injectable() +export class ExpertSkillsGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const authUser: AuthUserLike | undefined = context + .switchToHttp() + .getRequest().authUser; + + if (!authUser) { + throw new UnauthorizedException("You are not authenticated."); + } + + if (authUser.isMachine) { + if (hasAccessToScopes(authUser, MACHINE_REQUIRED_SCOPES)) { + return true; + } + + throw new ForbiddenException( + "You do not have the required permissions to access this resource.", + ); + } + + const roles = getNormalizedRoles(authUser); + + if (hasAdminRole(roles) || roles.some((role) => ALLOWED_ROLES.has(role))) { + return true; + } + + throw new ForbiddenException( + "You do not have the required permissions to access this resource.", + ); + } +} diff --git a/src/reports/expert-skills/standardized-skills.client.spec.ts b/src/reports/expert-skills/standardized-skills.client.spec.ts new file mode 100644 index 0000000..7319df1 --- /dev/null +++ b/src/reports/expert-skills/standardized-skills.client.spec.ts @@ -0,0 +1,117 @@ +import { + BadGatewayException, + InternalServerErrorException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Test, TestingModule } from "@nestjs/testing"; +import { StandardizedSkillsClient } from "./standardized-skills.client"; + +const API_BASE_URL = "https://api.example.test"; +const CATEGORIES_URL = `${API_BASE_URL}/v5/standardized-skills/categories?disablePagination=true&sortBy=name`; + +describe("StandardizedSkillsClient", () => { + let client: StandardizedSkillsClient; + const configValues: Record = { + TOPCODER_API_URL_BASE: API_BASE_URL, + }; + const mockConfigService = { + get: jest.fn((key: string, defaultValue?: string) => { + return configValues[key] ?? defaultValue; + }), + }; + const fetchMock = jest.fn(); + const originalFetch = global.fetch; + + beforeEach(async () => { + fetchMock.mockReset(); + mockConfigService.get.mockClear(); + Object.keys(configValues).forEach((key) => { + delete configValues[key]; + }); + configValues.TOPCODER_API_URL_BASE = API_BASE_URL; + global.fetch = fetchMock as unknown as typeof fetch; + + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + StandardizedSkillsClient, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + client = moduleRef.get(StandardizedSkillsClient); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("fetches categories from the standardized-skills catalog", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve([ + { + id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", + name: "Programming and Development", + }, + { id: " ", name: "Ignored" }, + { + id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", + name: "Duplicate", + }, + ]), + }); + + await expect(client.fetchCategories()).resolves.toEqual([ + { + id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", + name: "Programming and Development", + }, + ]); + expect(fetchMock).toHaveBeenCalledWith(CATEGORIES_URL, { + headers: { Accept: "application/json" }, + }); + }); + + it("reads wrapped category payloads", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + categories: [ + { + id: "1f5ed3e8-8d22-44ea-b75d-ea85147a04da", + name: "Scripting and Automation", + }, + ], + }), + }); + + await expect(client.fetchCategories()).resolves.toEqual([ + { + id: "1f5ed3e8-8d22-44ea-b75d-ea85147a04da", + name: "Scripting and Automation", + }, + ]); + }); + + it("throws when the catalog request fails", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + }); + + await expect(client.fetchCategories()).rejects.toBeInstanceOf( + BadGatewayException, + ); + }); + + it("throws when TOPCODER_API_URL_BASE is not configured", async () => { + delete configValues.TOPCODER_API_URL_BASE; + + await expect(client.fetchCategories()).rejects.toBeInstanceOf( + InternalServerErrorException, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/reports/expert-skills/standardized-skills.client.ts b/src/reports/expert-skills/standardized-skills.client.ts new file mode 100644 index 0000000..7b24462 --- /dev/null +++ b/src/reports/expert-skills/standardized-skills.client.ts @@ -0,0 +1,188 @@ +import { + BadGatewayException, + Injectable, + InternalServerErrorException, + Logger, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import * as core from "tc-core-library-js"; + +export type StandardizedSkillCategory = { + id: string; + name: string; +}; + +type M2MClient = { + getMachineToken: (clientId: string, clientSecret: string) => Promise; +}; + +const CATEGORIES_PATH = + "/v5/standardized-skills/categories?disablePagination=true&sortBy=name"; + +@Injectable() +export class StandardizedSkillsClient { + private readonly logger = new Logger(StandardizedSkillsClient.name); + private m2m?: M2MClient; + + constructor(private readonly config: ConfigService) {} + + async fetchCategories(): Promise { + const url = this.buildCategoriesUrl(); + const headers: Record = { Accept: "application/json" }; + const token = await this.getOptionalM2MToken(); + + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + let response: Response; + try { + response = await fetch(url, { headers }); + } catch (error) { + this.logger.error( + "Standardized-skills categories request failed.", + error instanceof Error ? error.message : error, + ); + throw new BadGatewayException( + "Failed to fetch skill categories from standardized-skills.", + ); + } + + if (!response.ok) { + this.logger.error( + `Standardized-skills categories request failed with status ${response.status}.`, + ); + throw new BadGatewayException( + "Failed to fetch skill categories from standardized-skills.", + ); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch (error) { + this.logger.error( + "Standardized-skills categories response was not valid JSON.", + error instanceof Error ? error.message : error, + ); + throw new BadGatewayException( + "Failed to fetch skill categories from standardized-skills.", + ); + } + + return this.parseCategories(payload); + } + + private buildCategoriesUrl(): string { + const baseUrl = String( + this.config.get("TOPCODER_API_URL_BASE") || "", + ) + .trim() + .replace(/\/$/, ""); + + if (!baseUrl) { + this.logger.error("TOPCODER_API_URL_BASE is not configured."); + throw new InternalServerErrorException( + "TOPCODER_API_URL_BASE is not configured.", + ); + } + + return `${baseUrl}${CATEGORIES_PATH}`; + } + + private async getOptionalM2MToken(): Promise { + const clientId = + this.config.get("M2M_CLIENT_ID") || + this.config.get("AUTH0_CLIENT_ID"); + const clientSecret = + this.config.get("M2M_CLIENT_SECRET") || + this.config.get("AUTH0_CLIENT_SECRET"); + + if (!clientId || !clientSecret) { + return undefined; + } + + try { + return await this.getM2MClient().getMachineToken(clientId, clientSecret); + } catch (error) { + this.logger.error( + "Failed to obtain an M2M token for standardized-skills.", + error instanceof Error ? error.message : error, + ); + throw new BadGatewayException( + "Failed to authenticate with standardized-skills.", + ); + } + } + + private getM2MClient(): M2MClient { + if (this.m2m) { + return this.m2m; + } + + this.m2m = core.auth.m2m({ + AUTH0_URL: this.config.get("AUTH0_URL"), + AUTH0_AUDIENCE: this.config.get("AUTH0_AUDIENCE"), + AUTH0_PROXY_SERVER_URL: this.config.get("AUTH0_PROXY_SERVER_URL"), + }) as M2MClient; + + return this.m2m; + } + + private parseCategories(payload: unknown): StandardizedSkillCategory[] { + const rows = this.extractCategoryList(payload); + const seen = new Set(); + const categories: StandardizedSkillCategory[] = []; + + rows.forEach((row) => { + const id = this.normalizeText(row.id); + const name = this.normalizeText(row.name); + + if (!id || !name || seen.has(id)) { + return; + } + + seen.add(id); + categories.push({ id, name }); + }); + + return categories; + } + + private extractCategoryList(payload: unknown): Array<{ + id?: unknown; + name?: unknown; + }> { + if (Array.isArray(payload)) { + return payload; + } + + if (!payload || typeof payload !== "object") { + return []; + } + + const record = payload as { + categories?: unknown; + data?: unknown; + }; + + if (Array.isArray(record.categories)) { + return record.categories; + } + + if (Array.isArray(record.data)) { + return record.data; + } + + return []; + } + + private normalizeText(value: unknown): string | undefined { + if (typeof value !== "string" && typeof value !== "number") { + return undefined; + } + + const normalized = String(value).trim(); + return normalized || undefined; + } +} diff --git a/src/reports/report-directory.data.spec.ts b/src/reports/report-directory.data.spec.ts index f233bd3..4432438 100644 --- a/src/reports/report-directory.data.spec.ts +++ b/src/reports/report-directory.data.spec.ts @@ -70,6 +70,8 @@ describe("getAccessibleReportsDirectory", () => { "/member/engagement-data", "/member/recent-member-data", "/member/search", + "/expert-skill-categories", + "/expert-skill-category-members", ]); }); @@ -115,6 +117,8 @@ describe("getAccessibleReportsDirectory", () => { "/member/engagement-data", "/member/recent-member-data", "/member/search", + "/expert-skill-categories", + "/expert-skill-category-members", ]); }); diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index 8a095e3..ae68b0f 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -851,6 +851,36 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { AppScopes.Member.MemberSearch, ], ), + report( + "Expert Skill Categories", + "/expert-skill-categories", + "Skill categories from the standardized-skills catalog for the Skill Statistics bubble view, including win-normalized sizes", + [ + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.Member.ExpertSkills, + ], + ), + report( + "Expert Skill Category Members", + "/expert-skill-category-members", + "Top 100 members in a skill category, sorted by wins in that category", + [ + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.Member.ExpertSkills, + ], + [ + { + name: "selectedcategory", + type: "string", + description: + "Category name from the standardized-skills catalog, or the category UUID", + required: true, + location: "query", + }, + ], + ), ], }, payment: { From c010c7512c41eef974cbc3f7935bdbb38256f31c Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 24 Aug 2026 08:51:50 +0530 Subject: [PATCH 7/9] Better place for expert skills api --- .../expert-skills/category-members.sql | 45 +++ .../expert-skills/category-stats.sql | 95 ++++++ src/app-constants.ts | 2 - src/app.module.ts | 2 - src/auth/permissions.util.spec.ts | 11 - ...expert-skill-category-members-query.dto.ts | 14 - .../dto/expert-skill-category.dto.ts | 78 ----- .../expert-skills.controller.spec.ts | 88 ----- .../expert-skills/expert-skills.controller.ts | 58 ---- .../expert-skills/expert-skills.module.ts | 11 - .../expert-skills/expert-skills.service.ts | 320 ------------------ .../guards/expert-skills.guard.spec.ts | 97 ------ .../guards/expert-skills.guard.ts | 55 --- src/reports/report-directory.data.spec.ts | 7 +- src/reports/report-directory.data.ts | 50 ++- .../expert-skills-statistics.data.spec.ts} | 4 +- .../expert-skills-statistics.data.ts} | 0 .../expert-skills-statistics.service.spec.ts} | 84 ++--- .../expert-skills-statistics.service.ts | 175 ++++++++++ .../standardized-skills.client.spec.ts | 19 +- .../standardized-skills.client.ts | 79 ++++- .../statistics-expert-skills.controller.ts | 31 ++ .../statistics-expert-skills.sql.spec.ts | 32 ++ src/statistics/statistics.module.ts | 6 + 24 files changed, 527 insertions(+), 836 deletions(-) create mode 100644 sql/reports/statistics/expert-skills/category-members.sql create mode 100644 sql/reports/statistics/expert-skills/category-stats.sql delete mode 100644 src/reports/expert-skills/dto/expert-skill-category-members-query.dto.ts delete mode 100644 src/reports/expert-skills/dto/expert-skill-category.dto.ts delete mode 100644 src/reports/expert-skills/expert-skills.controller.spec.ts delete mode 100644 src/reports/expert-skills/expert-skills.controller.ts delete mode 100644 src/reports/expert-skills/expert-skills.module.ts delete mode 100644 src/reports/expert-skills/expert-skills.service.ts delete mode 100644 src/reports/expert-skills/guards/expert-skills.guard.spec.ts delete mode 100644 src/reports/expert-skills/guards/expert-skills.guard.ts rename src/{reports/expert-skills/expert-skill-categories.data.spec.ts => statistics/expert-skills-statistics.data.spec.ts} (92%) rename src/{reports/expert-skills/expert-skill-categories.data.ts => statistics/expert-skills-statistics.data.ts} (100%) rename src/{reports/expert-skills/expert-skills.service.spec.ts => statistics/expert-skills-statistics.service.spec.ts} (56%) create mode 100644 src/statistics/expert-skills-statistics.service.ts rename src/{reports/expert-skills => statistics}/standardized-skills.client.spec.ts (85%) rename src/{reports/expert-skills => statistics}/standardized-skills.client.ts (70%) create mode 100644 src/statistics/statistics-expert-skills.controller.ts create mode 100644 src/statistics/statistics-expert-skills.sql.spec.ts 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/src/app-constants.ts b/src/app-constants.ts index 28258cd..72ca01d 100644 --- a/src/app-constants.ts +++ b/src/app-constants.ts @@ -11,7 +11,6 @@ export const Scopes = { EngagementData: "reports:member-engagement-data", RecentMemberData: "reports:member-recent-member-data", MemberSearch: "reports:member-search", - ExpertSkills: "reports:expert-skills", }, TopgearChallengeTechnology: "reports:topgear-challenge-technology", TopgearChallengeStatsByUser: "reports:topgear-challenge-stats-by-user", @@ -78,7 +77,6 @@ export const ScopeRoleAccess: Record = { [Scopes.Member.EngagementData]: [UserRoles.TalentManager], [Scopes.Member.RecentMemberData]: [UserRoles.TalentManager], [Scopes.Member.MemberSearch]: [UserRoles.TalentManager], - [Scopes.Member.ExpertSkills]: [UserRoles.TalentManager], [Scopes.Identity.UsersByHandles]: [ UserRoles.TalentManager, UserRoles.ProjectManager, diff --git a/src/app.module.ts b/src/app.module.ts index 7fb6b6f..1601f5e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,7 +12,6 @@ import { ChallengesReportsModule } from "./reports/challenges/challenges-reports import { IdentityReportsModule } from "./reports/identity/identity-reports.module"; import { ReportsModule } from "./reports/reports.module"; import { MemberSearchModule } from "./reports/member/member-search.module"; -import { ExpertSkillsModule } from "./reports/expert-skills/expert-skills.module"; import { PaymentReportsModule } from "./reports/payment/payment-reports.module"; import { DashboardReportsModule } from "./reports/dashboard/dashboard-reports.module"; @@ -28,7 +27,6 @@ import { DashboardReportsModule } from "./reports/dashboard/dashboard-reports.mo IdentityReportsModule, ReportsModule, MemberSearchModule, - ExpertSkillsModule, PaymentReportsModule, DashboardReportsModule, HealthModule, diff --git a/src/auth/permissions.util.spec.ts b/src/auth/permissions.util.spec.ts index 501d081..5ac4dc1 100644 --- a/src/auth/permissions.util.spec.ts +++ b/src/auth/permissions.util.spec.ts @@ -24,17 +24,6 @@ describe("permissions.util", () => { ).toBe(true); }); - it("allows topcoder-prefixed talent manager roles for expert skills", () => { - expect( - hasAccessToScopes( - { - roles: ["Topcoder Talent Manager"], - }, - [Scopes.Member.ExpertSkills], - ), - ).toBe(true); - }); - it("allows topcoder-prefixed talent manager roles for recent member data", () => { expect( hasAccessToScopes( diff --git a/src/reports/expert-skills/dto/expert-skill-category-members-query.dto.ts b/src/reports/expert-skills/dto/expert-skill-category-members-query.dto.ts deleted file mode 100644 index d38010a..0000000 --- a/src/reports/expert-skills/dto/expert-skill-category-members-query.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsNotEmpty, IsString } from "class-validator"; - -export class ExpertSkillCategoryMembersQueryDto { - @ApiProperty({ - name: "selectedcategory", - description: - "Category name from the standardized-skills catalog, or the category UUID", - example: "Programming and Development", - }) - @IsString() - @IsNotEmpty() - selectedcategory!: string; -} diff --git a/src/reports/expert-skills/dto/expert-skill-category.dto.ts b/src/reports/expert-skills/dto/expert-skill-category.dto.ts deleted file mode 100644 index 5cc7f26..0000000 --- a/src/reports/expert-skills/dto/expert-skill-category.dto.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; - -export class ExpertSkillBreakdownDto { - @ApiProperty({ description: "Skill name" }) - name!: string; - - @ApiProperty({ - description: "Share of category wins for this skill, rounded to an integer", - }) - percentage!: number; -} - -export class ExpertSkillCategoryDto { - @ApiProperty({ description: "Skill category UUID" }) - id!: string; - - @ApiProperty({ - description: "Category name from the standardized-skills catalog", - }) - name!: string; - - @ApiProperty({ - description: "Official category name from the standardized-skills catalog", - }) - officialName!: string; - - @ApiProperty({ description: "Hex color for the bubble" }) - color!: string; - - @ApiProperty({ - description: "Heroicon outline name used by the bubble UI", - example: "TerminalIcon", - }) - icon!: string; - - @ApiProperty({ - description: - "Bubble size from 3–10, min-max normalized on sqrt(category wins) so outliers do not dominate", - }) - size!: number; - - @ApiProperty({ - description: "Distinct members with a skill in this category", - }) - totalMembers!: number; - - @ApiProperty({ description: "Active skills in this category" }) - totalSkills!: number; - - @ApiProperty({ type: [ExpertSkillBreakdownDto] }) - skillsBreakdown!: ExpertSkillBreakdownDto[]; -} - -export class ExpertSkillCategoryMemberDto { - @ApiProperty({ - description: "ISO 3166-1 alpha-2 country code", - example: "US", - }) - countryCode!: string; - - @ApiProperty({ description: "Country display name", example: "USA" }) - countryName!: string; - - @ApiProperty({ description: "Member handle" }) - handle!: string; - - @ApiProperty({ description: "Display name (first name and last initial)" }) - name!: string; - - @ApiPropertyOptional({ description: "Profile photo URL", nullable: true }) - photoURL?: string | null; - - @ApiProperty({ description: "Maximum member rating" }) - rating!: number; - - @ApiProperty({ description: "Wins in the selected skill category" }) - wins!: number; -} diff --git a/src/reports/expert-skills/expert-skills.controller.spec.ts b/src/reports/expert-skills/expert-skills.controller.spec.ts deleted file mode 100644 index 18581a5..0000000 --- a/src/reports/expert-skills/expert-skills.controller.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { NotFoundException } from "@nestjs/common"; -import { Test, TestingModule } from "@nestjs/testing"; -import { ExpertSkillsController } from "./expert-skills.controller"; -import { ExpertSkillsService } from "./expert-skills.service"; - -describe("ExpertSkillsController", () => { - let controller: ExpertSkillsController; - - const mockExpertSkillsService = { - getCategories: jest.fn(), - getCategoryMembers: jest.fn(), - }; - - beforeEach(async () => { - mockExpertSkillsService.getCategories.mockReset(); - mockExpertSkillsService.getCategoryMembers.mockReset(); - - const moduleRef: TestingModule = await Test.createTestingModule({ - controllers: [ExpertSkillsController], - providers: [ - { - provide: ExpertSkillsService, - useValue: mockExpertSkillsService, - }, - ], - }).compile(); - - controller = moduleRef.get(ExpertSkillsController); - }); - - it("creates the controller", () => { - expect(controller).toBeDefined(); - }); - - it("delegates category requests to the service", async () => { - const response = [ - { - id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", - name: "Programming & Development", - officialName: "Programming and Development", - color: "#1B4F72", - icon: "TerminalIcon", - size: 10, - totalMembers: 10, - totalSkills: 5, - skillsBreakdown: [{ name: "JavaScript", percentage: 40 }], - }, - ]; - mockExpertSkillsService.getCategories.mockResolvedValue(response); - - await expect(controller.getCategories()).resolves.toEqual(response); - expect(mockExpertSkillsService.getCategories).toHaveBeenCalledTimes(1); - }); - - it("delegates member requests with selectedcategory", async () => { - const response = [ - { - countryCode: "IN", - countryName: "India", - handle: "billzedison", - name: "Honghan W", - photoURL: null, - rating: 2000, - wins: 376, - }, - ]; - mockExpertSkillsService.getCategoryMembers.mockResolvedValue(response); - - await expect( - controller.getCategoryMembers({ - selectedcategory: "Programming & Development", - }), - ).resolves.toEqual(response); - expect(mockExpertSkillsService.getCategoryMembers).toHaveBeenCalledWith( - "Programming & Development", - ); - }); - - it("propagates missing category errors", async () => { - mockExpertSkillsService.getCategoryMembers.mockRejectedValue( - new NotFoundException("Skill category not found"), - ); - - await expect( - controller.getCategoryMembers({ selectedcategory: "Unknown" }), - ).rejects.toBeInstanceOf(NotFoundException); - }); -}); diff --git a/src/reports/expert-skills/expert-skills.controller.ts b/src/reports/expert-skills/expert-skills.controller.ts deleted file mode 100644 index 45403f6..0000000 --- a/src/reports/expert-skills/expert-skills.controller.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Controller, Get, Query, UseGuards } from "@nestjs/common"; -import { - ApiBearerAuth, - ApiOperation, - ApiQuery, - ApiResponse, - ApiTags, -} from "@nestjs/swagger"; -import { ExpertSkillCategoryMembersQueryDto } from "./dto/expert-skill-category-members-query.dto"; -import { - ExpertSkillCategoryDto, - ExpertSkillCategoryMemberDto, -} from "./dto/expert-skill-category.dto"; -import { ExpertSkillsGuard } from "./guards/expert-skills.guard"; -import { ExpertSkillsService } from "./expert-skills.service"; - -@ApiTags("Expert Skills") -@ApiBearerAuth() -@UseGuards(ExpertSkillsGuard) -@Controller() -export class ExpertSkillsController { - constructor(private readonly expertSkillsService: ExpertSkillsService) {} - - @Get("expert-skill-categories") - @ApiOperation({ - summary: "List skill categories for the Skill Statistics bubble view", - description: - "Returns skill categories from the standardized-skills catalog, with win-normalized bubble sizes (3–10). Accessible by Administrator and Talent Manager roles only.", - }) - @ApiResponse({ status: 200, type: [ExpertSkillCategoryDto] }) - @ApiResponse({ status: 401, description: "Unauthenticated" }) - @ApiResponse({ status: 403, description: "Forbidden – insufficient role" }) - getCategories(): Promise { - return this.expertSkillsService.getCategories(); - } - - @Get("expert-skill-category-members") - @ApiOperation({ - summary: "List top members for a skill category", - description: - "Returns up to 100 members with wins in the selected skill category, sorted by wins descending. selectedcategory is the standardized-skills category name or UUID. Accessible by Administrator and Talent Manager roles only.", - }) - @ApiQuery({ - name: "selectedcategory", - required: true, - description: "Standardized-skills category name or UUID", - }) - @ApiResponse({ status: 200, type: [ExpertSkillCategoryMemberDto] }) - @ApiResponse({ status: 400, description: "Validation error" }) - @ApiResponse({ status: 401, description: "Unauthenticated" }) - @ApiResponse({ status: 403, description: "Forbidden – insufficient role" }) - @ApiResponse({ status: 404, description: "Skill category not found" }) - getCategoryMembers( - @Query() query: ExpertSkillCategoryMembersQueryDto, - ): Promise { - return this.expertSkillsService.getCategoryMembers(query.selectedcategory); - } -} diff --git a/src/reports/expert-skills/expert-skills.module.ts b/src/reports/expert-skills/expert-skills.module.ts deleted file mode 100644 index be8f192..0000000 --- a/src/reports/expert-skills/expert-skills.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from "@nestjs/common"; -import { ExpertSkillsController } from "./expert-skills.controller"; -import { ExpertSkillsService } from "./expert-skills.service"; -import { ExpertSkillsGuard } from "./guards/expert-skills.guard"; -import { StandardizedSkillsClient } from "./standardized-skills.client"; - -@Module({ - controllers: [ExpertSkillsController], - providers: [ExpertSkillsService, ExpertSkillsGuard, StandardizedSkillsClient], -}) -export class ExpertSkillsModule {} diff --git a/src/reports/expert-skills/expert-skills.service.ts b/src/reports/expert-skills/expert-skills.service.ts deleted file mode 100644 index a2d4f3c..0000000 --- a/src/reports/expert-skills/expert-skills.service.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; -import { - alpha3ToCountryName, - toAlpha2CountryCode, -} from "../../common/country.util"; -import { DbService } from "../../db/db.service"; -import { - ExpertSkillBreakdownDto, - ExpertSkillCategoryDto, - ExpertSkillCategoryMemberDto, -} from "./dto/expert-skill-category.dto"; -import { - getCategoryAppearance, - normalizeCategoryName, - normalizeCategorySizes, -} from "./expert-skill-categories.data"; -import { - StandardizedSkillCategory, - StandardizedSkillsClient, -} from "./standardized-skills.client"; - -const MEMBERS_LIMIT = 100; -const COUNTRY_DISPLAY_NAMES: Record = { - US: "USA", - GB: "UK", -}; - -type CategoryStatsRow = { - id: string; - totalMembers: number; - totalSkills: number; - totalWins: number; - skillsBreakdown: ExpertSkillBreakdownDto[] | null; -}; - -type MemberRow = { - handle: string; - firstName: string | null; - lastName: string | null; - photoUrl: string | null; - countryCode: string | null; - rating: number | null; - wins: number | null; -}; - -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 ExpertSkillsService { - constructor( - private readonly db: DbService, - private readonly standardizedSkills: StandardizedSkillsClient, - ) {} - - async getCategories(): Promise { - const categories = await this.standardizedSkills.fetchCategories(); - 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, - ): Promise { - const category = await this.findCategory(selectedCategory); - const rows = await this.db.query(this.buildMembersQuery(), [ - 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.standardizedSkills.fetchCategories(); - 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 loadCategoryStats( - categoryIds: string[], - ): Promise> { - const rows = await this.db.query( - this.buildCategoriesQuery(), - [categoryIds], - ); - - return new Map(rows.map((row) => [row.id, row])); - } - - private buildCategoriesQuery(): string { - return ` -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`; - } - - private buildMembersQuery(): string { - return ` -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/src/reports/expert-skills/guards/expert-skills.guard.spec.ts b/src/reports/expert-skills/guards/expert-skills.guard.spec.ts deleted file mode 100644 index 8173987..0000000 --- a/src/reports/expert-skills/guards/expert-skills.guard.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - ExecutionContext, - ForbiddenException, - UnauthorizedException, -} from "@nestjs/common"; -import { Scopes, UserRoles } from "src/app-constants"; -import { ExpertSkillsGuard } from "./expert-skills.guard"; - -type AuthUserFixture = { - isMachine?: boolean; - roles?: string[]; - role?: string | string[]; - scopes?: string[]; -}; - -function createExecutionContext(authUser?: AuthUserFixture): ExecutionContext { - return { - switchToHttp: () => ({ - getRequest: () => ({ - authUser, - }), - }), - } as unknown as ExecutionContext; -} - -describe("ExpertSkillsGuard", () => { - const guard = new ExpertSkillsGuard(); - - it("throws when no auth user is present", () => { - expect(() => guard.canActivate(createExecutionContext())).toThrow( - UnauthorizedException, - ); - }); - - it("allows talent manager role access", () => { - expect( - guard.canActivate( - createExecutionContext({ - roles: [UserRoles.TalentManager], - }), - ), - ).toBe(true); - }); - - it("allows admin role access", () => { - expect( - guard.canActivate( - createExecutionContext({ - roles: ["Administrator"], - }), - ), - ).toBe(true); - }); - - it("allows machine clients with expert skills scope", () => { - expect( - guard.canActivate( - createExecutionContext({ - isMachine: true, - scopes: [Scopes.Member.ExpertSkills], - }), - ), - ).toBe(true); - }); - - it("allows machine clients with all reports scope", () => { - expect( - guard.canActivate( - createExecutionContext({ - isMachine: true, - scopes: [Scopes.AllReports], - }), - ), - ).toBe(true); - }); - - it("denies machine clients without required scopes", () => { - expect(() => - guard.canActivate( - createExecutionContext({ - isMachine: true, - scopes: [Scopes.Member.MemberSearch], - }), - ), - ).toThrow(ForbiddenException); - }); - - it("denies non-admin users without required role", () => { - expect(() => - guard.canActivate( - createExecutionContext({ - roles: [UserRoles.ProjectManager], - }), - ), - ).toThrow(ForbiddenException); - }); -}); diff --git a/src/reports/expert-skills/guards/expert-skills.guard.ts b/src/reports/expert-skills/guards/expert-skills.guard.ts deleted file mode 100644 index 0b1db71..0000000 --- a/src/reports/expert-skills/guards/expert-skills.guard.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - CanActivate, - ExecutionContext, - ForbiddenException, - Injectable, - UnauthorizedException, -} from "@nestjs/common"; -import { - AuthUserLike, - getNormalizedRoles, - hasAccessToScopes, - hasAdminRole, -} from "../../../auth/permissions.util"; -import { Scopes, UserRoles } from "src/app-constants"; - -const ALLOWED_ROLES = new Set([UserRoles.TalentManager.toLowerCase()]); - -const MACHINE_REQUIRED_SCOPES = [ - Scopes.AllReports, - Scopes.TopcoderReports, - Scopes.Member.ExpertSkills, -]; - -@Injectable() -export class ExpertSkillsGuard implements CanActivate { - canActivate(context: ExecutionContext): boolean { - const authUser: AuthUserLike | undefined = context - .switchToHttp() - .getRequest().authUser; - - if (!authUser) { - throw new UnauthorizedException("You are not authenticated."); - } - - if (authUser.isMachine) { - if (hasAccessToScopes(authUser, MACHINE_REQUIRED_SCOPES)) { - return true; - } - - throw new ForbiddenException( - "You do not have the required permissions to access this resource.", - ); - } - - const roles = getNormalizedRoles(authUser); - - if (hasAdminRole(roles) || roles.some((role) => ALLOWED_ROLES.has(role))) { - return true; - } - - throw new ForbiddenException( - "You do not have the required permissions to access this resource.", - ); - } -} diff --git a/src/reports/report-directory.data.spec.ts b/src/reports/report-directory.data.spec.ts index 4432438..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([ @@ -70,8 +73,6 @@ describe("getAccessibleReportsDirectory", () => { "/member/engagement-data", "/member/recent-member-data", "/member/search", - "/expert-skill-categories", - "/expert-skill-category-members", ]); }); @@ -117,8 +118,6 @@ describe("getAccessibleReportsDirectory", () => { "/member/engagement-data", "/member/recent-member-data", "/member/search", - "/expert-skill-categories", - "/expert-skill-category-members", ]); }); diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index ae68b0f..ea08e8c 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 the standardized-skills catalog 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: { @@ -851,36 +871,6 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { AppScopes.Member.MemberSearch, ], ), - report( - "Expert Skill Categories", - "/expert-skill-categories", - "Skill categories from the standardized-skills catalog for the Skill Statistics bubble view, including win-normalized sizes", - [ - AppScopes.AllReports, - AppScopes.TopcoderReports, - AppScopes.Member.ExpertSkills, - ], - ), - report( - "Expert Skill Category Members", - "/expert-skill-category-members", - "Top 100 members in a skill category, sorted by wins in that category", - [ - AppScopes.AllReports, - AppScopes.TopcoderReports, - AppScopes.Member.ExpertSkills, - ], - [ - { - name: "selectedcategory", - type: "string", - description: - "Category name from the standardized-skills catalog, or the category UUID", - required: true, - location: "query", - }, - ], - ), ], }, payment: { diff --git a/src/reports/expert-skills/expert-skill-categories.data.spec.ts b/src/statistics/expert-skills-statistics.data.spec.ts similarity index 92% rename from src/reports/expert-skills/expert-skill-categories.data.spec.ts rename to src/statistics/expert-skills-statistics.data.spec.ts index 235d7d9..c16903c 100644 --- a/src/reports/expert-skills/expert-skill-categories.data.spec.ts +++ b/src/statistics/expert-skills-statistics.data.spec.ts @@ -4,9 +4,9 @@ import { getCategoryAppearance, normalizeCategoryName, normalizeCategorySizes, -} from "./expert-skill-categories.data"; +} from "./expert-skills-statistics.data"; -describe("expert-skill-categories.data", () => { +describe("expert-skills-statistics.data", () => { it("normalizes catalog names for lookup", () => { expect(normalizeCategoryName("Programming & Development")).toBe( normalizeCategoryName("Programming and Development"), diff --git a/src/reports/expert-skills/expert-skill-categories.data.ts b/src/statistics/expert-skills-statistics.data.ts similarity index 100% rename from src/reports/expert-skills/expert-skill-categories.data.ts rename to src/statistics/expert-skills-statistics.data.ts diff --git a/src/reports/expert-skills/expert-skills.service.spec.ts b/src/statistics/expert-skills-statistics.service.spec.ts similarity index 56% rename from src/reports/expert-skills/expert-skills.service.spec.ts rename to src/statistics/expert-skills-statistics.service.spec.ts index cbd12c9..6772c07 100644 --- a/src/reports/expert-skills/expert-skills.service.spec.ts +++ b/src/statistics/expert-skills-statistics.service.spec.ts @@ -1,43 +1,32 @@ import { NotFoundException } from "@nestjs/common"; -import { Test, TestingModule } from "@nestjs/testing"; -import { DbService } from "../../db/db.service"; -import { ExpertSkillsService } from "./expert-skills.service"; +import { DbService } from "../db/db.service"; +import { SqlLoaderService } from "../common/sql-loader.service"; +import { ExpertSkillsStatisticsService } from "./expert-skills-statistics.service"; import { StandardizedSkillsClient } from "./standardized-skills.client"; -describe("ExpertSkillsService", () => { - let service: ExpertSkillsService; - - const mockDbService = { +describe("ExpertSkillsStatisticsService", () => { + const db = { query: jest.fn(), }; - const mockStandardizedSkillsClient = { + const sql = { + load: jest.fn().mockReturnValue("SELECT expert skills"), + }; + const standardizedSkills = { fetchCategories: jest.fn(), }; - - beforeEach(async () => { - mockDbService.query.mockReset(); - mockStandardizedSkillsClient.fetchCategories.mockReset(); - - const moduleRef: TestingModule = await Test.createTestingModule({ - providers: [ - ExpertSkillsService, - { provide: DbService, useValue: mockDbService }, - { - provide: StandardizedSkillsClient, - useValue: mockStandardizedSkillsClient, - }, - ], - }).compile(); - - service = moduleRef.get(ExpertSkillsService); - }); - - it("creates the service", () => { - expect(service).toBeDefined(); + const service = new ExpertSkillsStatisticsService( + db as unknown as DbService, + sql as unknown as SqlLoaderService, + standardizedSkills as unknown as StandardizedSkillsClient, + ); + + beforeEach(() => { + jest.clearAllMocks(); + sql.load.mockReturnValue("SELECT expert skills"); }); it("maps catalog categories to stats and normalized sizes", async () => { - mockStandardizedSkillsClient.fetchCategories.mockResolvedValue([ + standardizedSkills.fetchCategories.mockResolvedValue([ { id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", name: "Programming and Development", @@ -47,7 +36,7 @@ describe("ExpertSkillsService", () => { name: "Scripting and Automation", }, ]); - mockDbService.query.mockResolvedValueOnce([ + db.query.mockResolvedValueOnce([ { id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", totalMembers: 101, @@ -65,16 +54,11 @@ describe("ExpertSkillsService", () => { ]); const result = await service.getCategories(); - const sql = mockDbService.query.mock.calls[0][0] as string; - const params = mockDbService.query.mock.calls[0][1] as unknown[]; - expect(mockStandardizedSkillsClient.fetchCategories).toHaveBeenCalledTimes( - 1, + expect(sql.load).toHaveBeenCalledWith( + "reports/statistics/expert-skills/category-stats.sql", ); - expect(sql).toContain("unnest($1::uuid[])"); - expect(sql).toContain("challenge_win"); - expect(sql).not.toContain("NOT ILIKE 'Test Cat%'"); - expect(params).toEqual([ + expect(db.query).toHaveBeenCalledWith("SELECT expert skills", [ [ "481b5ebc-2fe6-45ed-a90c-736936d458d7", "1f5ed3e8-8d22-44ea-b75d-ea85147a04da", @@ -91,26 +75,25 @@ describe("ExpertSkillsService", () => { }), ); expect(result[0].color).toMatch(/^#[0-9A-F]{6}$/i); - expect(result[0].icon).toMatch(/Icon$/); 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 () => { - mockStandardizedSkillsClient.fetchCategories.mockResolvedValue([]); + standardizedSkills.fetchCategories.mockResolvedValue([]); await expect(service.getCategories()).resolves.toEqual([]); - expect(mockDbService.query).not.toHaveBeenCalled(); + expect(db.query).not.toHaveBeenCalled(); }); it("returns top members for a catalog category name", async () => { - mockStandardizedSkillsClient.fetchCategories.mockResolvedValue([ + standardizedSkills.fetchCategories.mockResolvedValue([ { id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", name: "Programming and Development", }, ]); - mockDbService.query.mockResolvedValueOnce([ + db.query.mockResolvedValueOnce([ { handle: "billzedison", firstName: "Honghan", @@ -125,12 +108,11 @@ describe("ExpertSkillsService", () => { const result = await service.getCategoryMembers( "Programming & Development", ); - const membersSql = mockDbService.query.mock.calls[0][0] as string; - const membersParams = mockDbService.query.mock.calls[0][1] as unknown[]; - expect(membersSql).toContain("ORDER BY cw.wins DESC, m.handle ASC"); - expect(membersSql).toContain("LIMIT $2"); - expect(membersParams).toEqual([ + expect(sql.load).toHaveBeenCalledWith( + "reports/statistics/expert-skills/category-members.sql", + ); + expect(db.query).toHaveBeenCalledWith("SELECT expert skills", [ "481b5ebc-2fe6-45ed-a90c-736936d458d7", 100, ]); @@ -148,7 +130,7 @@ describe("ExpertSkillsService", () => { }); it("throws when the selected category is not in the catalog", async () => { - mockStandardizedSkillsClient.fetchCategories.mockResolvedValue([ + standardizedSkills.fetchCategories.mockResolvedValue([ { id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", name: "Programming and Development", @@ -158,6 +140,6 @@ describe("ExpertSkillsService", () => { await expect( service.getCategoryMembers("Test Cat QA 1"), ).rejects.toBeInstanceOf(NotFoundException); - expect(mockDbService.query).not.toHaveBeenCalled(); + expect(db.query).not.toHaveBeenCalled(); }); }); diff --git a/src/statistics/expert-skills-statistics.service.ts b/src/statistics/expert-skills-statistics.service.ts new file mode 100644 index 0000000..7e776dc --- /dev/null +++ b/src/statistics/expert-skills-statistics.service.ts @@ -0,0 +1,175 @@ +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"; +import { + StandardizedSkillCategory, + StandardizedSkillsClient, +} from "./standardized-skills.client"; + +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; +}; + +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, + private readonly standardizedSkills: StandardizedSkillsClient, + ) {} + + async getCategories() { + const categories = await this.standardizedSkills.fetchCategories(); + 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.standardizedSkills.fetchCategories(); + 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 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/reports/expert-skills/standardized-skills.client.spec.ts b/src/statistics/standardized-skills.client.spec.ts similarity index 85% rename from src/reports/expert-skills/standardized-skills.client.spec.ts rename to src/statistics/standardized-skills.client.spec.ts index 7319df1..a164e82 100644 --- a/src/reports/expert-skills/standardized-skills.client.spec.ts +++ b/src/statistics/standardized-skills.client.spec.ts @@ -6,13 +6,18 @@ import { ConfigService } from "@nestjs/config"; import { Test, TestingModule } from "@nestjs/testing"; import { StandardizedSkillsClient } from "./standardized-skills.client"; -const API_BASE_URL = "https://api.example.test"; +const API_BASE_URL = "https://api.topcoder-dev.com"; const CATEGORIES_URL = `${API_BASE_URL}/v5/standardized-skills/categories?disablePagination=true&sortBy=name`; +const VALID_ISSUERS = JSON.stringify([ + "https://topcoder-dev.auth0.com/", + "https://api.topcoder.com", + "https://api.topcoder-dev.com", +]); describe("StandardizedSkillsClient", () => { let client: StandardizedSkillsClient; const configValues: Record = { - TOPCODER_API_URL_BASE: API_BASE_URL, + VALID_ISSUERS, }; const mockConfigService = { get: jest.fn((key: string, defaultValue?: string) => { @@ -28,7 +33,7 @@ describe("StandardizedSkillsClient", () => { Object.keys(configValues).forEach((key) => { delete configValues[key]; }); - configValues.TOPCODER_API_URL_BASE = API_BASE_URL; + configValues.VALID_ISSUERS = VALID_ISSUERS; global.fetch = fetchMock as unknown as typeof fetch; const moduleRef: TestingModule = await Test.createTestingModule({ @@ -45,7 +50,7 @@ describe("StandardizedSkillsClient", () => { global.fetch = originalFetch; }); - it("fetches categories from the standardized-skills catalog", async () => { + it("fetches categories using the API host from VALID_ISSUERS", async () => { fetchMock.mockResolvedValue({ ok: true, json: () => @@ -106,8 +111,10 @@ describe("StandardizedSkillsClient", () => { ); }); - it("throws when TOPCODER_API_URL_BASE is not configured", async () => { - delete configValues.TOPCODER_API_URL_BASE; + it("throws when VALID_ISSUERS does not include an API host", async () => { + configValues.VALID_ISSUERS = JSON.stringify([ + "https://topcoder-dev.auth0.com/", + ]); await expect(client.fetchCategories()).rejects.toBeInstanceOf( InternalServerErrorException, diff --git a/src/reports/expert-skills/standardized-skills.client.ts b/src/statistics/standardized-skills.client.ts similarity index 70% rename from src/reports/expert-skills/standardized-skills.client.ts rename to src/statistics/standardized-skills.client.ts index 7b24462..540802c 100644 --- a/src/reports/expert-skills/standardized-skills.client.ts +++ b/src/statistics/standardized-skills.client.ts @@ -19,6 +19,70 @@ type M2MClient = { const CATEGORIES_PATH = "/v5/standardized-skills/categories?disablePagination=true&sortBy=name"; +function parseValidIssuers(value?: string): string[] { + const raw = String(value || "").trim(); + if (!raw) { + return []; + } + + if (raw.startsWith("[")) { + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed + .map((issuer) => + String(issuer || "") + .trim() + .replace(/\/$/, ""), + ) + .filter(Boolean); + } + } catch { + return []; + } + } + + return raw + .split(",") + .map((issuer) => issuer.trim().replace(/\/$/, "")) + .filter(Boolean); +} + +function issuerHostname(issuer: string): string | undefined { + try { + return new URL(issuer).hostname.toLowerCase(); + } catch { + return undefined; + } +} + +function resolveApiBaseUrl( + issuers: string[], + deployEnv?: string, +): string | undefined { + const apiIssuers = issuers.filter((issuer) => + Boolean(issuerHostname(issuer)?.startsWith("api.")), + ); + if (!apiIssuers.length) { + return undefined; + } + + const environment = String(deployEnv || "").toLowerCase(); + const preferProd = environment === "prod" || environment === "production"; + const prodUrl = apiIssuers.find( + (issuer) => !issuerHostname(issuer)?.includes("-dev"), + ); + const devUrl = apiIssuers.find((issuer) => + Boolean(issuerHostname(issuer)?.includes("-dev")), + ); + + if (preferProd) { + return prodUrl || devUrl || apiIssuers[0]; + } + + return devUrl || prodUrl || apiIssuers[0]; +} + @Injectable() export class StandardizedSkillsClient { private readonly logger = new Logger(StandardizedSkillsClient.name); @@ -74,16 +138,17 @@ export class StandardizedSkillsClient { } private buildCategoriesUrl(): string { - const baseUrl = String( - this.config.get("TOPCODER_API_URL_BASE") || "", - ) - .trim() - .replace(/\/$/, ""); + const issuers = parseValidIssuers(this.config.get("VALID_ISSUERS")); + const deployEnv = + this.config.get("DEPLOY_ENV") || + this.config.get("LOGICAL_ENV") || + this.config.get("NODE_ENV"); + const baseUrl = resolveApiBaseUrl(issuers, deployEnv); if (!baseUrl) { - this.logger.error("TOPCODER_API_URL_BASE is not configured."); + this.logger.error("VALID_ISSUERS does not include a Topcoder API host."); throw new InternalServerErrorException( - "TOPCODER_API_URL_BASE is not configured.", + "VALID_ISSUERS does not include a Topcoder API host.", ); } diff --git a/src/statistics/statistics-expert-skills.controller.ts b/src/statistics/statistics-expert-skills.controller.ts new file mode 100644 index 0000000..bc1f020 --- /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 the standardized-skills catalog 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..47b0e3f --- /dev/null +++ b/src/statistics/statistics-expert-skills.sql.spec.ts @@ -0,0 +1,32 @@ +import { SqlLoaderService } from "../common/sql-loader.service"; + +describe("Expert skills statistics SQL", () => { + const sqlLoader = new SqlLoaderService(); + + 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..0fda7ed 100644 --- a/src/statistics/statistics.module.ts +++ b/src/statistics/statistics.module.ts @@ -10,6 +10,9 @@ 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 { StandardizedSkillsClient } from "./standardized-skills.client"; import { SqlLoaderService } from "../common/sql-loader.service"; @Module({ @@ -19,6 +22,7 @@ import { SqlLoaderService } from "../common/sql-loader.service"; StatisticsDesignController, StatisticsDevelopmentController, StatisticsQaController, + StatisticsExpertSkillsController, ], providers: [ SrmDataService, @@ -27,6 +31,8 @@ import { SqlLoaderService } from "../common/sql-loader.service"; DesignStatisticsService, DevelopmentStatisticsService, QaStatisticsService, + ExpertSkillsStatisticsService, + StandardizedSkillsClient, SqlLoaderService, ], }) From f30e8fb945fc2228afe5b72aa4dc548b60e7c2eb Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 24 Aug 2026 09:09:08 +0530 Subject: [PATCH 8/9] read skill category with sql --- .../statistics/expert-skills/categories.sql | 5 + src/reports/report-directory.data.ts | 2 +- .../expert-skills-statistics.service.spec.ts | 117 ++++---- .../expert-skills-statistics.service.ts | 28 +- .../standardized-skills.client.spec.ts | 124 --------- src/statistics/standardized-skills.client.ts | 253 ------------------ .../statistics-expert-skills.controller.ts | 2 +- .../statistics-expert-skills.sql.spec.ts | 10 + src/statistics/statistics.module.ts | 2 - 9 files changed, 100 insertions(+), 443 deletions(-) create mode 100644 sql/reports/statistics/expert-skills/categories.sql delete mode 100644 src/statistics/standardized-skills.client.spec.ts delete mode 100644 src/statistics/standardized-skills.client.ts 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/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index ea08e8c..1fc78c1 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -687,7 +687,7 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { publicReport( "Expert Skill Categories", "/statistics/expert-skills/categories", - "Skill categories from the standardized-skills catalog with win-normalized bubble sizes", + "Skill categories from skills.skill_category with win-normalized bubble sizes", ), publicReport( "Expert Skill Category Members", diff --git a/src/statistics/expert-skills-statistics.service.spec.ts b/src/statistics/expert-skills-statistics.service.spec.ts index 6772c07..a508e8d 100644 --- a/src/statistics/expert-skills-statistics.service.spec.ts +++ b/src/statistics/expert-skills-statistics.service.spec.ts @@ -2,7 +2,6 @@ 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"; -import { StandardizedSkillsClient } from "./standardized-skills.client"; describe("ExpertSkillsStatisticsService", () => { const db = { @@ -11,13 +10,9 @@ describe("ExpertSkillsStatisticsService", () => { const sql = { load: jest.fn().mockReturnValue("SELECT expert skills"), }; - const standardizedSkills = { - fetchCategories: jest.fn(), - }; const service = new ExpertSkillsStatisticsService( db as unknown as DbService, sql as unknown as SqlLoaderService, - standardizedSkills as unknown as StandardizedSkillsClient, ); beforeEach(() => { @@ -26,39 +21,44 @@ describe("ExpertSkillsStatisticsService", () => { }); it("maps catalog categories to stats and normalized sizes", async () => { - standardizedSkills.fetchCategories.mockResolvedValue([ - { - id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", - name: "Programming and Development", - }, - { - id: "1f5ed3e8-8d22-44ea-b75d-ea85147a04da", - name: "Scripting and Automation", - }, - ]); - db.query.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: [], - }, - ]); + 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).toHaveBeenCalledWith("SELECT expert skills", [ + 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", @@ -80,39 +80,48 @@ describe("ExpertSkillsStatisticsService", () => { }); it("returns an empty list when the catalog has no categories", async () => { - standardizedSkills.fetchCategories.mockResolvedValue([]); + db.query.mockResolvedValueOnce([]); await expect(service.getCategories()).resolves.toEqual([]); - expect(db.query).not.toHaveBeenCalled(); + 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 () => { - standardizedSkills.fetchCategories.mockResolvedValue([ - { - id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", - name: "Programming and Development", - }, - ]); - db.query.mockResolvedValueOnce([ - { - handle: "billzedison", - firstName: "Honghan", - lastName: "Wu", - photoUrl: null, - countryCode: "IND", - rating: 2000, - wins: 376, - }, - ]); + 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).toHaveBeenCalledWith("SELECT expert skills", [ + expect(db.query).toHaveBeenNthCalledWith(2, "SELECT expert skills", [ "481b5ebc-2fe6-45ed-a90c-736936d458d7", 100, ]); @@ -130,7 +139,7 @@ describe("ExpertSkillsStatisticsService", () => { }); it("throws when the selected category is not in the catalog", async () => { - standardizedSkills.fetchCategories.mockResolvedValue([ + db.query.mockResolvedValueOnce([ { id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", name: "Programming and Development", @@ -140,6 +149,6 @@ describe("ExpertSkillsStatisticsService", () => { await expect( service.getCategoryMembers("Test Cat QA 1"), ).rejects.toBeInstanceOf(NotFoundException); - expect(db.query).not.toHaveBeenCalled(); + expect(db.query).toHaveBeenCalledTimes(1); }); }); diff --git a/src/statistics/expert-skills-statistics.service.ts b/src/statistics/expert-skills-statistics.service.ts index 7e776dc..b99f508 100644 --- a/src/statistics/expert-skills-statistics.service.ts +++ b/src/statistics/expert-skills-statistics.service.ts @@ -10,10 +10,6 @@ import { normalizeCategoryName, normalizeCategorySizes, } from "./expert-skills-statistics.data"; -import { - StandardizedSkillCategory, - StandardizedSkillsClient, -} from "./standardized-skills.client"; const MEMBERS_LIMIT = 100; const COUNTRY_DISPLAY_NAMES: Record = { @@ -44,6 +40,11 @@ type MemberRow = { 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]; @@ -78,11 +79,10 @@ export class ExpertSkillsStatisticsService { constructor( private readonly db: DbService, private readonly sql: SqlLoaderService, - private readonly standardizedSkills: StandardizedSkillsClient, ) {} async getCategories() { - const categories = await this.standardizedSkills.fetchCategories(); + const categories = await this.loadCategories(); if (!categories.length) { return []; } @@ -141,13 +141,13 @@ export class ExpertSkillsStatisticsService { private async findCategory( selectedCategory: string, - ): Promise { + ): Promise { const requested = String(selectedCategory || "").trim(); if (!requested) { throw new NotFoundException("Skill category not found."); } - const categories = await this.standardizedSkills.fetchCategories(); + const categories = await this.loadCategories(); const normalizedRequested = normalizeCategoryName(requested); const match = categories.find( (category) => @@ -162,6 +162,18 @@ export class ExpertSkillsStatisticsService { 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> { diff --git a/src/statistics/standardized-skills.client.spec.ts b/src/statistics/standardized-skills.client.spec.ts deleted file mode 100644 index a164e82..0000000 --- a/src/statistics/standardized-skills.client.spec.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { - BadGatewayException, - InternalServerErrorException, -} from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { Test, TestingModule } from "@nestjs/testing"; -import { StandardizedSkillsClient } from "./standardized-skills.client"; - -const API_BASE_URL = "https://api.topcoder-dev.com"; -const CATEGORIES_URL = `${API_BASE_URL}/v5/standardized-skills/categories?disablePagination=true&sortBy=name`; -const VALID_ISSUERS = JSON.stringify([ - "https://topcoder-dev.auth0.com/", - "https://api.topcoder.com", - "https://api.topcoder-dev.com", -]); - -describe("StandardizedSkillsClient", () => { - let client: StandardizedSkillsClient; - const configValues: Record = { - VALID_ISSUERS, - }; - const mockConfigService = { - get: jest.fn((key: string, defaultValue?: string) => { - return configValues[key] ?? defaultValue; - }), - }; - const fetchMock = jest.fn(); - const originalFetch = global.fetch; - - beforeEach(async () => { - fetchMock.mockReset(); - mockConfigService.get.mockClear(); - Object.keys(configValues).forEach((key) => { - delete configValues[key]; - }); - configValues.VALID_ISSUERS = VALID_ISSUERS; - global.fetch = fetchMock as unknown as typeof fetch; - - const moduleRef: TestingModule = await Test.createTestingModule({ - providers: [ - StandardizedSkillsClient, - { provide: ConfigService, useValue: mockConfigService }, - ], - }).compile(); - - client = moduleRef.get(StandardizedSkillsClient); - }); - - afterEach(() => { - global.fetch = originalFetch; - }); - - it("fetches categories using the API host from VALID_ISSUERS", async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve([ - { - id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", - name: "Programming and Development", - }, - { id: " ", name: "Ignored" }, - { - id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", - name: "Duplicate", - }, - ]), - }); - - await expect(client.fetchCategories()).resolves.toEqual([ - { - id: "481b5ebc-2fe6-45ed-a90c-736936d458d7", - name: "Programming and Development", - }, - ]); - expect(fetchMock).toHaveBeenCalledWith(CATEGORIES_URL, { - headers: { Accept: "application/json" }, - }); - }); - - it("reads wrapped category payloads", async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - categories: [ - { - id: "1f5ed3e8-8d22-44ea-b75d-ea85147a04da", - name: "Scripting and Automation", - }, - ], - }), - }); - - await expect(client.fetchCategories()).resolves.toEqual([ - { - id: "1f5ed3e8-8d22-44ea-b75d-ea85147a04da", - name: "Scripting and Automation", - }, - ]); - }); - - it("throws when the catalog request fails", async () => { - fetchMock.mockResolvedValue({ - ok: false, - status: 401, - }); - - await expect(client.fetchCategories()).rejects.toBeInstanceOf( - BadGatewayException, - ); - }); - - it("throws when VALID_ISSUERS does not include an API host", async () => { - configValues.VALID_ISSUERS = JSON.stringify([ - "https://topcoder-dev.auth0.com/", - ]); - - await expect(client.fetchCategories()).rejects.toBeInstanceOf( - InternalServerErrorException, - ); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); diff --git a/src/statistics/standardized-skills.client.ts b/src/statistics/standardized-skills.client.ts deleted file mode 100644 index 540802c..0000000 --- a/src/statistics/standardized-skills.client.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { - BadGatewayException, - Injectable, - InternalServerErrorException, - Logger, -} from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import * as core from "tc-core-library-js"; - -export type StandardizedSkillCategory = { - id: string; - name: string; -}; - -type M2MClient = { - getMachineToken: (clientId: string, clientSecret: string) => Promise; -}; - -const CATEGORIES_PATH = - "/v5/standardized-skills/categories?disablePagination=true&sortBy=name"; - -function parseValidIssuers(value?: string): string[] { - const raw = String(value || "").trim(); - if (!raw) { - return []; - } - - if (raw.startsWith("[")) { - try { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - return parsed - .map((issuer) => - String(issuer || "") - .trim() - .replace(/\/$/, ""), - ) - .filter(Boolean); - } - } catch { - return []; - } - } - - return raw - .split(",") - .map((issuer) => issuer.trim().replace(/\/$/, "")) - .filter(Boolean); -} - -function issuerHostname(issuer: string): string | undefined { - try { - return new URL(issuer).hostname.toLowerCase(); - } catch { - return undefined; - } -} - -function resolveApiBaseUrl( - issuers: string[], - deployEnv?: string, -): string | undefined { - const apiIssuers = issuers.filter((issuer) => - Boolean(issuerHostname(issuer)?.startsWith("api.")), - ); - if (!apiIssuers.length) { - return undefined; - } - - const environment = String(deployEnv || "").toLowerCase(); - const preferProd = environment === "prod" || environment === "production"; - const prodUrl = apiIssuers.find( - (issuer) => !issuerHostname(issuer)?.includes("-dev"), - ); - const devUrl = apiIssuers.find((issuer) => - Boolean(issuerHostname(issuer)?.includes("-dev")), - ); - - if (preferProd) { - return prodUrl || devUrl || apiIssuers[0]; - } - - return devUrl || prodUrl || apiIssuers[0]; -} - -@Injectable() -export class StandardizedSkillsClient { - private readonly logger = new Logger(StandardizedSkillsClient.name); - private m2m?: M2MClient; - - constructor(private readonly config: ConfigService) {} - - async fetchCategories(): Promise { - const url = this.buildCategoriesUrl(); - const headers: Record = { Accept: "application/json" }; - const token = await this.getOptionalM2MToken(); - - if (token) { - headers.Authorization = `Bearer ${token}`; - } - - let response: Response; - try { - response = await fetch(url, { headers }); - } catch (error) { - this.logger.error( - "Standardized-skills categories request failed.", - error instanceof Error ? error.message : error, - ); - throw new BadGatewayException( - "Failed to fetch skill categories from standardized-skills.", - ); - } - - if (!response.ok) { - this.logger.error( - `Standardized-skills categories request failed with status ${response.status}.`, - ); - throw new BadGatewayException( - "Failed to fetch skill categories from standardized-skills.", - ); - } - - let payload: unknown; - try { - payload = await response.json(); - } catch (error) { - this.logger.error( - "Standardized-skills categories response was not valid JSON.", - error instanceof Error ? error.message : error, - ); - throw new BadGatewayException( - "Failed to fetch skill categories from standardized-skills.", - ); - } - - return this.parseCategories(payload); - } - - private buildCategoriesUrl(): string { - const issuers = parseValidIssuers(this.config.get("VALID_ISSUERS")); - const deployEnv = - this.config.get("DEPLOY_ENV") || - this.config.get("LOGICAL_ENV") || - this.config.get("NODE_ENV"); - const baseUrl = resolveApiBaseUrl(issuers, deployEnv); - - if (!baseUrl) { - this.logger.error("VALID_ISSUERS does not include a Topcoder API host."); - throw new InternalServerErrorException( - "VALID_ISSUERS does not include a Topcoder API host.", - ); - } - - return `${baseUrl}${CATEGORIES_PATH}`; - } - - private async getOptionalM2MToken(): Promise { - const clientId = - this.config.get("M2M_CLIENT_ID") || - this.config.get("AUTH0_CLIENT_ID"); - const clientSecret = - this.config.get("M2M_CLIENT_SECRET") || - this.config.get("AUTH0_CLIENT_SECRET"); - - if (!clientId || !clientSecret) { - return undefined; - } - - try { - return await this.getM2MClient().getMachineToken(clientId, clientSecret); - } catch (error) { - this.logger.error( - "Failed to obtain an M2M token for standardized-skills.", - error instanceof Error ? error.message : error, - ); - throw new BadGatewayException( - "Failed to authenticate with standardized-skills.", - ); - } - } - - private getM2MClient(): M2MClient { - if (this.m2m) { - return this.m2m; - } - - this.m2m = core.auth.m2m({ - AUTH0_URL: this.config.get("AUTH0_URL"), - AUTH0_AUDIENCE: this.config.get("AUTH0_AUDIENCE"), - AUTH0_PROXY_SERVER_URL: this.config.get("AUTH0_PROXY_SERVER_URL"), - }) as M2MClient; - - return this.m2m; - } - - private parseCategories(payload: unknown): StandardizedSkillCategory[] { - const rows = this.extractCategoryList(payload); - const seen = new Set(); - const categories: StandardizedSkillCategory[] = []; - - rows.forEach((row) => { - const id = this.normalizeText(row.id); - const name = this.normalizeText(row.name); - - if (!id || !name || seen.has(id)) { - return; - } - - seen.add(id); - categories.push({ id, name }); - }); - - return categories; - } - - private extractCategoryList(payload: unknown): Array<{ - id?: unknown; - name?: unknown; - }> { - if (Array.isArray(payload)) { - return payload; - } - - if (!payload || typeof payload !== "object") { - return []; - } - - const record = payload as { - categories?: unknown; - data?: unknown; - }; - - if (Array.isArray(record.categories)) { - return record.categories; - } - - if (Array.isArray(record.data)) { - return record.data; - } - - return []; - } - - private normalizeText(value: unknown): string | undefined { - if (typeof value !== "string" && typeof value !== "number") { - return undefined; - } - - const normalized = String(value).trim(); - return normalized || undefined; - } -} diff --git a/src/statistics/statistics-expert-skills.controller.ts b/src/statistics/statistics-expert-skills.controller.ts index bc1f020..2dfc21f 100644 --- a/src/statistics/statistics-expert-skills.controller.ts +++ b/src/statistics/statistics-expert-skills.controller.ts @@ -10,7 +10,7 @@ export class StatisticsExpertSkillsController { @Get("/categories") @ApiOperation({ summary: - "Skill categories from the standardized-skills catalog with win-normalized bubble sizes", + "Skill categories from skills.skill_category with win-normalized bubble sizes", }) getCategories() { return this.expertSkills.getCategories(); diff --git a/src/statistics/statistics-expert-skills.sql.spec.ts b/src/statistics/statistics-expert-skills.sql.spec.ts index 47b0e3f..0e9a0ee 100644 --- a/src/statistics/statistics-expert-skills.sql.spec.ts +++ b/src/statistics/statistics-expert-skills.sql.spec.ts @@ -3,6 +3,16 @@ 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", diff --git a/src/statistics/statistics.module.ts b/src/statistics/statistics.module.ts index 0fda7ed..1eb7c17 100644 --- a/src/statistics/statistics.module.ts +++ b/src/statistics/statistics.module.ts @@ -12,7 +12,6 @@ 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 { StandardizedSkillsClient } from "./standardized-skills.client"; import { SqlLoaderService } from "../common/sql-loader.service"; @Module({ @@ -32,7 +31,6 @@ import { SqlLoaderService } from "../common/sql-loader.service"; DevelopmentStatisticsService, QaStatisticsService, ExpertSkillsStatisticsService, - StandardizedSkillsClient, SqlLoaderService, ], }) From 6e9d6a4623c0b8c1f845ca8a58fab3a48ad3448e Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 24 Aug 2026 12:37:06 +0300 Subject: [PATCH 9/9] Exclude F2f and tasks from campus leaderboard --- sql/reports/topcoder/campus-leaderboard.sql | 10 ++++++++-- src/reports/topcoder/topcoder-reports.service.ts | 3 ++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/sql/reports/topcoder/campus-leaderboard.sql b/sql/reports/topcoder/campus-leaderboard.sql index fd9f622..0c6b30d 100644 --- a/sql/reports/topcoder/campus-leaderboard.sql +++ b/sql/reports/topcoder/campus-leaderboard.sql @@ -1,7 +1,8 @@ -- 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. Members without activity come back as a --- single row with a NULL "challengeId". +-- 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 @@ -198,6 +199,11 @@ member_participation AS ( 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") diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index c97f913..6866a86 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -1258,7 +1258,8 @@ 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. + * 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.