From d54a1185405600dcdbebb8c2c610f08355c21484 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 3 Aug 2026 09:36:16 +0300 Subject: [PATCH 1/2] PM-5653 - support leaderboard calculations --- sql/reports/topcoder/leaderboard-generic.sql | 218 +++++++++++++ sql/reports/topcoder/leaderboard-mm.sql | 97 ++++++ src/app-constants.ts | 1 + .../topcoder/dto/leaderboard-generic.dto.ts | 42 +++ .../topcoder/dto/leaderboard-mm.dto.ts | 51 +++ .../topcoder/topcoder-reports.controller.ts | 24 ++ .../topcoder/topcoder-reports.service.ts | 307 ++++++++++++++++++ 7 files changed, 740 insertions(+) create mode 100644 sql/reports/topcoder/leaderboard-generic.sql create mode 100644 sql/reports/topcoder/leaderboard-mm.sql create mode 100644 src/reports/topcoder/dto/leaderboard-generic.dto.ts create mode 100644 src/reports/topcoder/dto/leaderboard-mm.dto.ts diff --git a/sql/reports/topcoder/leaderboard-generic.sql b/sql/reports/topcoder/leaderboard-generic.sql new file mode 100644 index 0000000..b56a626 --- /dev/null +++ b/sql/reports/topcoder/leaderboard-generic.sql @@ -0,0 +1,218 @@ +WITH challenge_context AS ( + SELECT + c.id AS challenge_id, + c.name AS challenge_name, + COALESCE( + cp."actualStartDate", + cp."scheduledStartDate" + ) AS start_date, + COALESCE( + cp."actualEndDate", + cp."scheduledEndDate" + ) AS end_date, + GREATEST( + 1, + CEIL( + EXTRACT(EPOCH FROM COALESCE(cp."actualEndDate", cp."scheduledEndDate") - COALESCE(cp."actualStartDate", cp."scheduledStartDate")) + / 86400.0 + ) + ) AS duration_days + FROM challenges."Challenge" AS c + JOIN challenges."ChallengePhase" AS cp + ON cp."challengeId" = c.id + AND cp.name = 'Submission' + WHERE c.id = ANY($1::text[]) +), +member_submissions AS ( + SELECT + cc.challenge_id, + s.id AS submission_id, + s."memberId" AS user_id, + COALESCE( + NULLIF(TRIM(u.handle), ''), + NULLIF(TRIM(mem.handle), ''), + fallback.member_handle + ) AS handle, + COALESCE(NULLIF(TRIM(mem."firstName"), ''), NULLIF(TRIM(u.handle), ''), NULLIF(TRIM(mem.handle), '')) AS name, + COALESCE( + home_code.name, + home_id.name, + comp_code.name, + comp_id.name, + NULLIF(TRIM(mem."competitionCountryCode"), ''), + NULLIF(TRIM(mem."homeCountryCode"), '') + ) AS country, + COALESCE( + NULLIF(TRIM(mem."competitionCountryCode"), ''), + NULLIF(TRIM(mem."homeCountryCode"), '') + ) AS country_code, + mem."photoURL" AS photoURL, + mmr.rating AS rating, + mmr."ratingColor" AS ratingColor, + COALESCE( + CASE + WHEN challenge_reviewers.is_ai_only_challenge + THEN ai_decision."totalScore" + ELSE final_review."aggregateScore" + END, + s."finalScore"::double precision, + s."initialScore"::double precision + ) AS score, + COALESCE(s."submittedDate", s."createdAt") AS submitted_date, + cc.duration_days + FROM challenge_context AS cc + JOIN reviews."submission" AS s + ON s."challengeId" = cc.challenge_id + AND s."memberId" IS NOT NULL + LEFT JOIN LATERAL ( + SELECT rs."aggregateScore", rs."scorecardId" + FROM reviews."reviewSummation" AS rs + WHERE rs."submissionId" = s.id + AND COALESCE(rs."isFinal", TRUE) = TRUE + AND rs."isProvisional" IS DISTINCT FROM TRUE + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC + LIMIT 1 + ) AS final_review ON TRUE + LEFT JOIN LATERAL ( + SELECT + COUNT(*) > 0 AS has_reviewers, + BOOL_AND(cr."aiWorkflowId" IS NOT NULL AND cr."isMemberReview" = FALSE) AND COUNT(*) > 0 AS is_ai_only_challenge + FROM challenges."ChallengeReviewer" AS cr + WHERE cr."challengeId" = cc.challenge_id + ) AS challenge_reviewers ON TRUE + LEFT JOIN LATERAL ( + SELECT d."totalScore", d.status + FROM reviews."aiReviewDecision" AS d + WHERE d."submissionId" = s.id + AND UPPER(d.status::text) != 'PENDING' + ORDER BY d."updatedAt" DESC NULLS LAST + LIMIT 1 + ) AS ai_decision ON TRUE + LEFT JOIN reviews.scorecard AS sc + ON sc.id = final_review."scorecardId" + LEFT JOIN members."member" AS mem + ON mem."userId" = s."memberId"::bigint + LEFT JOIN LATERAL ( + SELECT DISTINCT ON (mmr."userId") + mmr.rating, + mmr."ratingColor" + FROM members."memberMaxRating" AS mmr + WHERE mmr."userId" = s."memberId"::bigint + ORDER BY mmr."userId", mmr.rating DESC + ) AS mmr ON TRUE + LEFT JOIN identity."user" AS u + ON s."memberId" ~ '^[0-9]+$' + AND u.user_id = s."memberId"::numeric + LEFT JOIN LATERAL ( + SELECT MAX(r."memberHandle") AS member_handle + FROM resources."Resource" AS r + WHERE r."challengeId" = cc.challenge_id + AND r."memberId" = s."memberId" + ) AS fallback ON TRUE + LEFT JOIN lookups."Country" AS home_code + ON UPPER(home_code."countryCode") = UPPER(mem."homeCountryCode") + LEFT JOIN lookups."Country" AS home_id + ON UPPER(home_id.id) = UPPER(mem."homeCountryCode") + LEFT JOIN lookups."Country" AS comp_code + ON UPPER(comp_code."countryCode") = UPPER(mem."competitionCountryCode") + LEFT JOIN lookups."Country" AS comp_id + ON UPPER(comp_id.id) = UPPER(mem."competitionCountryCode") + WHERE COALESCE( + CASE + WHEN challenge_reviewers.is_ai_only_challenge + THEN ai_decision."totalScore" + ELSE final_review."aggregateScore" + END, + s."finalScore"::double precision, + s."initialScore"::double precision + ) IS NOT NULL + AND ( + (challenge_reviewers.is_ai_only_challenge + AND UPPER(ai_decision.status::text) = 'PASSED') + OR ( + NOT challenge_reviewers.is_ai_only_challenge + AND COALESCE(final_review."aggregateScore", s."finalScore"::double precision, s."initialScore"::double precision) + >= COALESCE(sc."minimumPassingScore", sc."minScore", 0) + ) + ) +), +unique_member_submissions AS ( + SELECT DISTINCT ON (challenge_id, user_id) + ms.* + FROM member_submissions AS ms + ORDER BY + ms.challenge_id, + ms.user_id, + ms.score DESC NULLS LAST, + ms.submitted_date DESC NULLS LAST, + ms.submission_id DESC +), +challenge_prizes AS ( + SELECT + cps."challengeId" AS challenge_id, + ROW_NUMBER() OVER (PARTITION BY cps."challengeId" ORDER BY p.id) AS placement, + p.value + FROM challenges."ChallengePrizeSet" AS cps + JOIN challenges."Prize" AS p + ON p."prizeSetId" = cps.id + WHERE cps.type = 'PLACEMENT' +), +challenge_summary AS ( + SELECT + cc.challenge_id, + cc.challenge_name, + cc.duration_days, + COALESCE(SUM(pr.value), 0) AS prize_pool, + COUNT(DISTINCT ums.user_id) AS submissions_count, + JSONB_AGG( + JSONB_BUILD_OBJECT('placement', pr.placement, 'value', pr.value) ORDER BY pr.placement + ) FILTER (WHERE pr.value IS NOT NULL) AS placement_prizes + FROM challenge_context AS cc + LEFT JOIN unique_member_submissions AS ums + ON ums.challenge_id = cc.challenge_id + LEFT JOIN challenge_prizes AS pr + ON pr.challenge_id = cc.challenge_id + GROUP BY cc.challenge_id, cc.challenge_name, cc.duration_days +) +SELECT + ums.challenge_id AS "challengeId", + cs.challenge_name AS "challengeName", + cs.duration_days AS "durationDays", + cs.prize_pool AS "prizePool", + cs.submissions_count AS "submissionsCount", + cs.placement_prizes AS "placementPrizes", + ums.user_id AS "userId", + ums.handle AS handle, + ums.name AS name, + COALESCE( + home_code.name, + home_id.name, + comp_code.name, + comp_id.name, + NULLIF(TRIM(ums.country), '') + ) AS country, + COALESCE( + NULLIF(TRIM(ums.country_code), ''), + NULLIF(TRIM(ums.country_code), '') + ) AS "countryCode", + ums.photoURL AS "photoURL", + ums.rating AS rating, + ums.ratingColor AS "ratingColor", + ums.score AS score, + ums.submitted_date AS submitted_date, + ROW_NUMBER() OVER ( + PARTITION BY ums.challenge_id + ORDER BY ums.score DESC NULLS LAST, ums.submitted_date ASC NULLS LAST, ums.user_id ASC + ) AS placement +FROM unique_member_submissions AS ums +LEFT JOIN lookups."Country" AS home_code + ON UPPER(home_code."countryCode") = UPPER(ums.country_code) +LEFT JOIN lookups."Country" AS home_id + ON UPPER(home_id.id) = UPPER(ums.country_code) +LEFT JOIN lookups."Country" AS comp_code + ON UPPER(comp_code."countryCode") = UPPER(ums.country_code) +LEFT JOIN lookups."Country" AS comp_id + ON UPPER(comp_id.id) = UPPER(ums.country_code) +JOIN challenge_summary AS cs + ON cs.challenge_id = ums.challenge_id +ORDER BY ums.challenge_id, ums.score DESC NULLS LAST, ums.submitted_date ASC NULLS LAST, ums.user_id ASC; diff --git a/sql/reports/topcoder/leaderboard-mm.sql b/sql/reports/topcoder/leaderboard-mm.sql new file mode 100644 index 0000000..6f477af --- /dev/null +++ b/sql/reports/topcoder/leaderboard-mm.sql @@ -0,0 +1,97 @@ +WITH challenge_context AS ( + SELECT + c.id AS challenge_id, + c.name AS challenge_name, + LOWER(ct.name) = 'marathon match' AS is_marathon_match + FROM challenges."Challenge" AS c + JOIN challenges."ChallengeType" AS ct + ON ct.id = c."typeId" + WHERE c.id = ANY($1::text[]) +), +submission_metrics AS ( + SELECT + cc.challenge_id, + s.id AS submission_id, + s."memberId" AS user_id, + COALESCE( + NULLIF(TRIM(u.handle), ''), + NULLIF(TRIM(mem.handle), ''), + fallback.member_handle + ) AS handle, + COALESCE(final_review."aggregateScore", s."finalScore"::double precision) AS standard_score, + provisional_review.provisional_score, + COALESCE(final_review."aggregateScore", s."finalScore"::double precision) AS final_score_raw, + COALESCE(s."submittedDate", s."createdAt") AS submitted_date + FROM challenge_context AS cc + JOIN reviews."submission" AS s + ON s."challengeId" = cc.challenge_id + AND s."memberId" IS NOT NULL + LEFT JOIN LATERAL ( + SELECT rs."aggregateScore" + FROM reviews."reviewSummation" AS rs + WHERE rs."submissionId" = s.id + AND COALESCE(rs."isFinal", TRUE) = TRUE + AND rs."isProvisional" IS DISTINCT FROM TRUE + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC + LIMIT 1 + ) AS final_review ON TRUE + LEFT JOIN LATERAL ( + SELECT rs."aggregateScore" AS provisional_score + FROM reviews."reviewSummation" AS rs + WHERE rs."submissionId" = s.id + AND rs."isProvisional" IS TRUE + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC + LIMIT 1 + ) AS provisional_review ON TRUE + LEFT JOIN members."member" AS mem + ON mem."userId" = s."memberId"::bigint + LEFT JOIN identity."user" AS u + ON s."memberId" ~ '^[0-9]+$' + AND u.user_id = s."memberId"::numeric + LEFT JOIN LATERAL ( + SELECT MAX(r."memberHandle") AS member_handle + FROM resources."Resource" AS r + WHERE r."challengeId" = cc.challenge_id + AND r."memberId" = s."memberId" + ) AS fallback ON TRUE + WHERE COALESCE(final_review."aggregateScore", s."finalScore"::double precision, s."initialScore"::double precision) IS NOT NULL +), +unique_member_submissions AS ( + SELECT DISTINCT ON (challenge_id, user_id) + sm.* + FROM submission_metrics AS sm + ORDER BY + sm.challenge_id, + sm.user_id, + sm.submitted_date DESC NULLS LAST, + sm.submission_id DESC +), +ranked_submissions AS ( + SELECT + ums.challenge_id, + ums.user_id AS "userId", + ums.submission_id AS "submissionId", + ums.handle AS handle, + ROW_NUMBER() OVER ( + PARTITION BY ums.challenge_id + ORDER BY ums.final_score_raw DESC NULLS LAST, ums.submitted_date ASC NULLS LAST, ums.user_id ASC + ) AS placement, + ums.provisional_score AS "provisionalScore", + ums.final_score_raw AS "finalScore", + ums.standard_score AS score + FROM unique_member_submissions AS ums +) +SELECT + cc.challenge_id AS "challengeId", + cc.challenge_name AS "challengeName", + rs."userId", + rs."submissionId", + rs.handle, + rs.placement, + rs."provisionalScore", + rs."finalScore", + rs.score +FROM ranked_submissions AS rs +JOIN challenge_context AS cc + ON cc.challenge_id = rs.challenge_id +ORDER BY rs.challenge_id, rs.placement; diff --git a/src/app-constants.ts b/src/app-constants.ts index c1bab63..72ca01d 100644 --- a/src/app-constants.ts +++ b/src/app-constants.ts @@ -6,6 +6,7 @@ export const Scopes = { TopgearCancelledChallenge: "reports:topgear-cancelled-challenge", AllReports: "reports:all", TopcoderReports: "reports:topcoder", + TopcoderLeaderboardReports: "reports:topcoder-leaderboard", Member: { EngagementData: "reports:member-engagement-data", RecentMemberData: "reports:member-recent-member-data", diff --git a/src/reports/topcoder/dto/leaderboard-generic.dto.ts b/src/reports/topcoder/dto/leaderboard-generic.dto.ts new file mode 100644 index 0000000..90f5320 --- /dev/null +++ b/src/reports/topcoder/dto/leaderboard-generic.dto.ts @@ -0,0 +1,42 @@ +import { Transform } from "class-transformer"; +import { IsArray, IsBoolean, IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator"; + +export class LeaderboardGenericQueryDto { + @Transform(({ value }) => { + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } + return value; + }) + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + challengeIds!: string[]; + + @Transform(({ value }) => (typeof value === "string" ? Number(value) : value)) + @IsOptional() + @IsNumber() + pointsPerDay?: number; + + @Transform(({ value }) => { + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } + return value; + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + placementPrizeAmounts?: string[]; + + @Transform(({ value }) => value === "true" || value === true) + @IsOptional() + @IsBoolean() + showHeadingAndSubtitle?: boolean; +} diff --git a/src/reports/topcoder/dto/leaderboard-mm.dto.ts b/src/reports/topcoder/dto/leaderboard-mm.dto.ts new file mode 100644 index 0000000..5543bbc --- /dev/null +++ b/src/reports/topcoder/dto/leaderboard-mm.dto.ts @@ -0,0 +1,51 @@ +import { Transform } from "class-transformer"; +import { IsArray, IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator"; + +export class LeaderboardMmQueryDto { + @Transform(({ value }) => { + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } + return value; + }) + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + challengeIds!: string[]; + + @Transform(({ value }) => { + if (typeof value === "string") { + return value + .split(",") + .map((item) => Number(item.trim())) + .filter((item) => Number.isFinite(item)); + } + return value; + }) + @IsOptional() + @IsArray() + @IsNumber({}, { each: true }) + placementPoints?: number[]; + + @Transform(({ value }) => (typeof value === "string" ? Number(value) : value)) + @IsOptional() + @IsNumber() + defaultPlacementPoints?: number; + + @Transform(({ value }) => { + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } + return value; + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + writersAndTesters?: string[]; +} diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index 9c476d4..20ebfad 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -9,6 +9,8 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { TopcoderReportsService } from "./topcoder-reports.service"; import { ChallengeSubmitterDataQueryDto } from "./dto/challenge-submitter-data.dto"; +import { LeaderboardGenericQueryDto } from "./dto/leaderboard-generic.dto"; +import { LeaderboardMmQueryDto } from "./dto/leaderboard-mm.dto"; import { RegistrantCountriesQueryDto } from "./dto/registrant-countries.dto"; import { RecentMemberDataQueryDto } from "./dto/recent-member-data.dto"; import { WeeklyMemberParticipationQueryDto } from "./dto/weekly-member-participation.dto"; @@ -52,6 +54,28 @@ export class TopcoderReportsController { return this.reports.getChallengeSubmitterData(challengeId); } + @Get("/topcoder/leaderboard/generic") + @RequiredScopes( + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.TopcoderLeaderboardReports, + ) + @ApiOperation({ summary: "Generic leaderboard report data" }) + getLeaderboardGeneric(@Query() query: LeaderboardGenericQueryDto) { + return this.reports.getLeaderboardGeneric(query); + } + + @Get("/topcoder/leaderboard/mm") + @RequiredScopes( + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.TopcoderLeaderboardReports, + ) + @ApiOperation({ summary: "Marathon Match leaderboard report data" }) + getLeaderboardMm(@Query() query: LeaderboardMmQueryDto) { + return this.reports.getLeaderboardMm(query); + } + @Get("/topcoder/mm-stats/:handle") @ApiOperation({ summary: "Marathon match performance snapshot for a specific handle", diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 8fc095a..5e485e7 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -100,6 +100,37 @@ type ChallengeSubmitterDataRow = { finalScore: string | number | null; }; +type LeaderboardGenericRow = { + challengeId: string; + challengeName: string; + durationDays: number; + prizePool: number; + submissionsCount: number; + placementPrizes: { placement: number; value: number }[] | null; + userId: string; + handle: string; + name: string | null; + country: string | null; + countryCode: string | null; + photoURL: string | null; + rating: string | number | null; + ratingColor: string | null; + score: number; + submittedDate: string; + placement: number; +}; + +type LeaderboardMmRow = { + challengeId: string; + challengeName: string; + userId: string; + handle: string; + placement: number; + provisionalScore: number | null; + finalScore: number | null; + score: number | null; +}; + type EngagementDataBaseRow = { member_id: string | null; fallback_handle: string | null; @@ -971,6 +1002,282 @@ export class TopcoderReportsService implements OnModuleDestroy { return projectNamesById; } + async getLeaderboardGeneric(filters: { + challengeIds: string[]; + pointsPerDay?: number; + placementPrizeAmounts?: string[]; + showHeadingAndSubtitle?: boolean; + }) { + const query = this.sql.load("reports/topcoder/leaderboard-generic.sql"); + const rows = await this.db.query(query, [ + filters.challengeIds, + ]); + + const useCmsPlacementPrizes = + Array.isArray(filters.placementPrizeAmounts) && + filters.placementPrizeAmounts.length > 0; + const cmsPlacementPrizeAmounts = (filters.placementPrizeAmounts ?? []) + .map((amount) => Number(amount)) + .filter((amount) => Number.isFinite(amount)); + + const challenges: Record< + string, + { id: string; name: string; submissions: { [memberId: string]: number } } + > = {}; + const entriesByUser: Record< + string, + { + userId: string; + handle: string; + name: string | null; + country: string | null; + countryCode: string | null; + wins: Record; + points: number; + prizes: number; + photoURL: string | null; + } + > = {}; + + rows.forEach((row) => { + challenges[row.challengeId] = challenges[row.challengeId] ?? { + id: row.challengeId, + name: row.challengeName, + submissions: {}, + }; + challenges[row.challengeId].submissions[row.userId] = row.placement; + + if (!entriesByUser[row.userId]) { + entriesByUser[row.userId] = { + userId: row.userId, + handle: row.handle, + name: row.name, + country: row.country, + countryCode: row.countryCode, + photoURL: row.photoURL, + wins: {}, + points: 0, + prizes: 0, + }; + } + + const log = + Math.log10(Math.max(1, row.prizePool)) + + row.durationDays * (filters.pointsPerDay ?? 0); + const relativeRank = row.placement / Math.max(1, row.submissionsCount); + const sqrt = Math.sqrt(relativeRank); + const pointsByWin = log / sqrt; + + entriesByUser[row.userId].points += pointsByWin; + entriesByUser[row.userId].wins[row.challengeId] = row.placement; + if (!useCmsPlacementPrizes) { + const prizeValue = + (row.placementPrizes ?? []).find((p) => p.placement === row.placement) + ?.value ?? 0; + entriesByUser[row.userId].prizes += prizeValue; + } + }); + + const placementData = Object.values(entriesByUser) + .filter((entry) => entry.points > 0) + .sort((a, b) => b.points - a.points) + .map((entry, index) => ({ + userId: entry.userId, + handle: entry.handle, + name: entry.name ?? "", + country: entry.country ?? "", + countryCode: entry.countryCode ?? "", + photoURL: entry.photoURL, + placement: index, + points: entry.points, + wins: entry.wins, + prizes: useCmsPlacementPrizes + ? (cmsPlacementPrizeAmounts[index] ?? 0) + : entry.prizes, + })); + + return { + placementData: filters.showHeadingAndSubtitle + ? placementData.slice(0, 10) + : placementData, + challenges, + }; + } + + private calculateWriterTesterBonuses( + placementData: Record, + writersAndTesters: string[] | undefined, + pointsMap: number[] = [], + baseScoringPoints = 1, + ) { + const contributions = (writersAndTesters ?? []) + .map((entry) => { + const [handle = "", challengeId = "", role = ""] = entry.split(":"); + if (!handle || !challengeId || !["writer", "tester"].includes(role)) { + return null; + } + return { handle, challengeId, role: role as "writer" | "tester" }; + }) + .filter(Boolean) as { + handle: string; + challengeId: string; + role: "writer" | "tester"; + }[]; + + const bonusPoints: Record = {}; + const eligibility: Record = {}; + const writerTesterRoles: Record< + string, + Record + > = {}; + + if (!contributions.length) { + return { bonusPoints, eligibility, writerTesterRoles }; + } + + const pointsFn = (winner: any) => + winner.score && winner.score <= 0 + ? 0 + : Number(pointsMap[winner.placement - 1] ?? baseScoringPoints); + + const challengeIds = Object.keys(placementData); + const competitionMatchesByHandle: Record> = {}; + const competitionPointsByHandle: Record = {}; + + challengeIds.forEach((challengeId) => { + placementData[challengeId].forEach((entry) => { + competitionMatchesByHandle[entry.handle] = + competitionMatchesByHandle[entry.handle] ?? new Set(); + competitionMatchesByHandle[entry.handle].add(challengeId); + competitionPointsByHandle[entry.handle] = + (competitionPointsByHandle[entry.handle] ?? 0) + pointsFn(entry); + }); + }); + + const matchCount = challengeIds.length; + const minCompetitions = Math.ceil(matchCount / 2); + const contributionByHandle: Record> = {}; + + contributions.forEach(({ handle, challengeId, role }) => { + if (!challengeIds.includes(challengeId)) { + return; + } + if (competitionMatchesByHandle[handle]?.has(challengeId)) { + return; + } + contributionByHandle[handle] = contributionByHandle[handle] ?? new Set(); + contributionByHandle[handle].add(challengeId); + writerTesterRoles[handle] = writerTesterRoles[handle] ?? {}; + writerTesterRoles[handle][challengeId] = role; + }); + + Object.entries(contributionByHandle).forEach( + ([handle, contributionsSet]) => { + const contributionMatches = contributionsSet.size; + const competitionMatches = + competitionMatchesByHandle[handle]?.size ?? 0; + const competitionPoints = competitionPointsByHandle[handle] ?? 0; + const averageCompetitionPoints = competitionMatches + ? competitionPoints / competitionMatches + : 0; + const eligibleForChampionship = + competitionMatches >= minCompetitions && + contributionMatches <= matchCount / 2; + const bonus = eligibleForChampionship + ? averageCompetitionPoints * contributionMatches + : 0; + + bonusPoints[handle] = Number(bonus.toFixed(2)); + eligibility[handle] = { + competitionMatches, + contributionMatches, + averageCompetitionPoints: Number(averageCompetitionPoints.toFixed(2)), + bonusPoints: bonusPoints[handle], + eligibleForChampionship, + }; + }, + ); + + return { bonusPoints, eligibility, writerTesterRoles }; + } + + async getLeaderboardMm(filters: { + challengeIds: string[]; + placementPoints?: number[]; + defaultPlacementPoints?: number; + writersAndTesters?: string[]; + }) { + const query = this.sql.load("reports/topcoder/leaderboard-mm.sql"); + const rows = await this.db.query(query, [ + filters.challengeIds, + ]); +console.log('here', rows); + + const placementData = rows.reduce( + (acc: Record, row) => { + acc[row.challengeId] = acc[row.challengeId] ?? []; + acc[row.challengeId].push({ + challengeId: row.challengeId, + challengeName: row.challengeName, + userId: row.userId, + handle: row.handle, + placement: row.placement, + provisionalScore: row.provisionalScore, + finalScore: row.finalScore, + score: row.score, + }); + return acc; + }, + {} as Record, + ); + + const memberHandles = new Set(); + Object.values(placementData).forEach((entries) => { + entries.forEach((entry) => memberHandles.add(entry.handle)); + }); + + const membersDetails: Record< + string, + { + userId: string; + handle: string; + name: string; + country: string; + countryCode: string; + } + > = {}; + memberHandles.forEach((handle) => { + membersDetails[handle] = { + userId: handle, + handle, + name: handle, + country: "", + countryCode: "", + }; + }); + + const writerTesterData = this.calculateWriterTesterBonuses( + placementData, + filters.writersAndTesters, + filters.placementPoints ?? [], + filters.defaultPlacementPoints ?? 1, + ); + + return { + membersDetails, + challengesDetails: Object.fromEntries( + Object.entries(placementData).map(([challengeId, entries]) => [ + challengeId, + { name: entries[0]?.challengeName ?? "" }, + ]), + ), + placementData, + writerTesterBonusPoints: writerTesterData.bonusPoints, + writerTesterRoles: writerTesterData.writerTesterRoles, + writerTesterEligibility: writerTesterData.eligibility, + }; + } + /** * Formats a preferred address row into the report output string. * From 5cab872a5ac4373b865fe7bf4635a5f1fff2a721 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 3 Aug 2026 09:37:17 +0300 Subject: [PATCH 2/2] deploy dev --- .circleci/config.yml | 2 ++ package.json | 1 + 2 files changed, 3 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index c79c575..f909fc6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -66,6 +66,8 @@ workflows: - develop - PM-4931 - improve-member-search-2 + tags: + only: /^dev-.*/ # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/package.json b/package.json index c27abc0..02ed013 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "packageManager": "pnpm@11.15.0", "scripts": { "build": "nest build", + "deploy:dev": "BRANCH=$(git rev-parse --abbrev-ref HEAD) && TAG=\"dev-${BRANCH}\" && git tag -d \"$TAG\" 2>/dev/null; git push origin \":refs/tags/$TAG\" 2>/dev/null; git tag \"$TAG\" && git push origin \"$TAG\"", "start": "node dist/main.js", "start:dev": "nest start --watch", "start:prod": "node dist/main.js",