From 90376df075af63be02bff88695f932b7133dab5f Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Wed, 21 Jan 2026 12:20:09 +0200 Subject: [PATCH 1/4] MM & passed review --- .../scripts/recalculateChallengeWinners.js | 197 ++++++++++++------ .../migration.sql | 2 + prisma/schema.prisma | 1 + winners1.csv | 5 + 4 files changed, 144 insertions(+), 61 deletions(-) create mode 100644 prisma/migrations/20260121120000_add_passed_review_prize_type/migration.sql create mode 100644 winners1.csv diff --git a/data-migration/src/scripts/recalculateChallengeWinners.js b/data-migration/src/scripts/recalculateChallengeWinners.js index cd2793f..bccfbd5 100644 --- a/data-migration/src/scripts/recalculateChallengeWinners.js +++ b/data-migration/src/scripts/recalculateChallengeWinners.js @@ -48,6 +48,13 @@ const DEFAULT_ACTOR = process.env.UPDATED_BY || process.env.CREATED_BY || "winne const CREATED_BY = process.env.CREATED_BY || DEFAULT_ACTOR; const UPDATED_BY = process.env.UPDATED_BY || DEFAULT_ACTOR; const CONTEST_SUBMISSION_TYPE = "CONTEST_SUBMISSION"; +const CHALLENGE_TYPE_BY_ID = new Map([ + ["ecd58c69-238f-43a4-a4bb-d172719b9f31", "Task"], + ["929bc408-9cf2-4b3e-ba71-adfbf693046c", "Marathon Match"], + ["927abff4-7af9-4145-8ba1-577c16e64e2e", "Challenge"], + ["dc876fa4-ef2d-4eee-b701-b555fcc6544c", "First2Finish"], + ["e76afccb-b6c6-488d-950e-76bddfea5df9", "Topgear Task"], +]); const roundScore = (value) => Math.round(value * 100) / 100; @@ -211,7 +218,7 @@ const getChallengeIds = async (prisma, reviewClient, submissionTable, options) = SELECT DISTINCT "challengeId" AS id FROM ${submissionTable} WHERE "challengeId" IS NOT NULL - AND "type" = ${CONTEST_SUBMISSION_TYPE} + AND "type" = ${CONTEST_SUBMISSION_TYPE}::reviews."SubmissionType" ORDER BY "challengeId" DESC `; challengeIds = rows.map((row) => row.id).filter(Boolean); @@ -239,29 +246,48 @@ const getChallengeIds = async (prisma, reviewClient, submissionTable, options) = return filtered; }; -const loadSubmissionScores = async (reviewClient, tables, challengeId) => { - const rows = await reviewClient.$queryRaw` - SELECT - s.id AS "submissionId", - s."challengeId" AS "challengeId", - s."memberId" AS "memberId", - s."submittedDate" AS "submittedDate", - s."createdAt" AS "createdAt", - MIN(r."scorecardId") AS "scorecardId", - AVG( - CASE - WHEN r."finalScore" IS NOT NULL THEN r."finalScore" - WHEN r."initialScore" IS NOT NULL THEN r."initialScore" - ELSE NULL - END - ) AS "reviewScore" - FROM ${tables.submission} s - INNER JOIN ${tables.review} r ON r."submissionId" = s.id - WHERE s."challengeId" = ${challengeId} - AND s."type" = ${CONTEST_SUBMISSION_TYPE} - AND r."committed" = true - GROUP BY s.id, s."challengeId", s."memberId", s."submittedDate", s."createdAt" - `; +const loadSubmissionScores = async (reviewClient, tables, challenge) => { + const challengeType = CHALLENGE_TYPE_BY_ID.get(challenge.typeId); + const isMarathonMatch = challengeType === "Marathon Match"; + + const rows = isMarathonMatch + ? await reviewClient.$queryRaw` + SELECT + s.id AS "submissionId", + s."challengeId" AS "challengeId", + s."memberId" AS "memberId", + s."submittedDate" AS "submittedDate", + s."createdAt" AS "createdAt", + MIN(rs."scorecardId") AS "scorecardId", + AVG(rs."aggregateScore") AS "reviewScore" + FROM ${tables.submission} s + INNER JOIN ${tables.reviewSummation} rs ON rs."submissionId" = s.id + WHERE s."challengeId" = ${challenge.id} + AND s."type" = ${CONTEST_SUBMISSION_TYPE}::reviews."SubmissionType" + GROUP BY s.id, s."challengeId", s."memberId", s."submittedDate", s."createdAt" + ` + : await reviewClient.$queryRaw` + SELECT + s.id AS "submissionId", + s."challengeId" AS "challengeId", + s."memberId" AS "memberId", + s."submittedDate" AS "submittedDate", + s."createdAt" AS "createdAt", + MIN(r."scorecardId") AS "scorecardId", + AVG( + CASE + WHEN r."finalScore" IS NOT NULL THEN r."finalScore" + WHEN r."initialScore" IS NOT NULL THEN r."initialScore" + ELSE NULL + END + ) AS "reviewScore" + FROM ${tables.submission} s + INNER JOIN ${tables.review} r ON r."submissionId" = s.id + WHERE s."challengeId" = ${challenge.id} + AND s."type" = ${CONTEST_SUBMISSION_TYPE}::reviews."SubmissionType" + AND r."committed" = true + GROUP BY s.id, s."challengeId", s."memberId", s."submittedDate", s."createdAt" + `; return rows || []; }; @@ -318,7 +344,7 @@ const buildCsvWriter = (csvPath) => { if (!csvPath) { return { writeLine: (line) => process.stdout.write(`${line}\n`), - end: () => {}, + end: () => { }, }; } @@ -350,6 +376,7 @@ async function main() { submission: buildSchemaTable(reviewSchema, "submission"), review: buildSchemaTable(reviewSchema, "review"), scorecard: buildSchemaTable(reviewSchema, "scorecard"), + reviewSummation: buildSchemaTable(reviewSchema, "reviewSummation"), }; const prisma = new PrismaClient(); @@ -378,6 +405,7 @@ async function main() { "Review score", "Scorecard score", "Placement", + "Prize type", ] .map(toCsvValue) .join(",") @@ -390,7 +418,17 @@ async function main() { for (const challengeId of challengeIds) { processed += 1; - const submissions = await loadSubmissionScores(reviewClient, tables, challengeId); + + const challenge = await prisma.challenge.findUnique({ + where: { id: challengeId }, + }); + if (!challenge) { + console.warn(`Challenge ${challengeId} not found, skipping.`); + skipped += 1; + continue; + } + + const submissions = await loadSubmissionScores(reviewClient, tables, challenge); if (!submissions.length) { skipped += 1; @@ -410,11 +448,16 @@ async function main() { scorecardIds ); + const challengeType = CHALLENGE_TYPE_BY_ID.get(challenge.typeId); + const isMarathonMatch = challengeType === "Marathon Match"; + const normalized = submissions.map((row) => { const reviewScoreValue = toNumber(row.reviewScore); const reviewScore = reviewScoreValue === null ? null : roundScore(reviewScoreValue); - const scorecardScore = toNumber(minScoreByScorecard.get(row.scorecardId)) ?? 0; - const isPassing = reviewScore !== null && reviewScore >= scorecardScore; + const scorecardScore = isMarathonMatch + ? 0 + : toNumber(minScoreByScorecard.get(row.scorecardId)) ?? 0; + const isPassing = reviewScore !== null && (isMarathonMatch ? reviewScore > 0 : reviewScore >= scorecardScore); return { submissionId: row.submissionId, challengeId: row.challengeId, @@ -449,16 +492,31 @@ async function main() { }); const placementBySubmission = new Map(); - passingSorted.forEach((row, index) => { - placementBySubmission.set(row.submissionId, index + 1); - }); const placementPrizeCount = await getPlacementPrizeCount(prisma, challengeId); const winnerLimit = placementPrizeCount > 0 ? placementPrizeCount : passingSorted.length; const winners = passingSorted.slice(0, winnerLimit); + const passedReview = passingSorted.slice(winnerLimit); + + winners.forEach((row, index) => { + placementBySubmission.set(row.submissionId, { + placement: index + 1, + type: PrizeSetTypeEnum.PLACEMENT, + }); + }); + passedReview.forEach((row, index) => { + placementBySubmission.set(row.submissionId, { + placement: index + 1, + type: PrizeSetTypeEnum.PASSED_REVIEW, + }); + }); + + console.info( + `Challenge ${challengeId}: ${winners.length} winner(s), ${passedReview.length} passed review` + ); let handleMap = null; - if (csvWriter || (!options.csvOnly && winners.length > 0)) { + if (csvWriter || (!options.csvOnly && (winners.length > 0 || passedReview.length > 0))) { handleMap = await loadSubmitterHandles(challengeId); } @@ -471,7 +529,9 @@ async function main() { return String(a.submissionId).localeCompare(String(b.submissionId)); }); rowsForCsv.forEach((row) => { - const placement = placementBySubmission.get(row.submissionId) || ""; + const placementInfo = placementBySubmission.get(row.submissionId); + const placement = placementInfo ? placementInfo.placement : ""; + const prizeType = placementInfo ? placementInfo.type : ""; const submitterHandle = handleMap && row.memberId ? handleMap.get(String(row.memberId)) || "" : ""; csvWriter.writeLine( @@ -479,10 +539,11 @@ async function main() { row.submissionId, row.challengeId, submitterHandle, - toIsoString(row.submittedAt), + toIsoString(row.submittedAt || row.createdAt), row.reviewScore === null ? "" : row.reviewScore, row.scorecardScore, placement, + prizeType, ] .map(toCsvValue) .join(",") @@ -491,37 +552,51 @@ async function main() { } if (!options.csvOnly) { - const winnerRecords = winners - .map((row, index) => { - const parsedUserId = Number.parseInt(row.memberId, 10); - if (!Number.isFinite(parsedUserId)) { - console.warn( - `Skipping winner for submission ${row.submissionId} (challenge ${challengeId}) due to invalid memberId ${row.memberId}` - ); - return null; - } - const handle = - (handleMap && handleMap.get(String(parsedUserId))) || - (handleMap && handleMap.get(String(row.memberId))) || - String(parsedUserId); - return { - challengeId, - userId: parsedUserId, - handle, - placement: index + 1, - type: PrizeSetTypeEnum.PLACEMENT, - createdBy: CREATED_BY, - updatedBy: UPDATED_BY, - }; - }) - .filter(Boolean); + const buildRecord = (row) => { + const placementInfo = placementBySubmission.get(row.submissionId); + if (!placementInfo) { + console.warn( + `Skipping entry for submission ${row.submissionId} (challenge ${challengeId}) due to missing placement info` + ); + return null; + } + const { placement, type } = placementInfo; + const parsedUserId = Number.parseInt(row.memberId, 10); + if (!Number.isFinite(parsedUserId)) { + console.warn( + `Skipping ${type} entry for submission ${row.submissionId} (challenge ${challengeId}) due to invalid memberId ${row.memberId}` + ); + return null; + } + const handle = + (handleMap && handleMap.get(String(parsedUserId))) || + (handleMap && handleMap.get(String(row.memberId))) || + String(parsedUserId); + return { + challengeId, + userId: parsedUserId, + handle, + placement, + type, + createdBy: CREATED_BY, + updatedBy: UPDATED_BY, + }; + }; + + const winnerRecords = winners.map(buildRecord).filter(Boolean); + + const passedReviewRecords = passedReview.map(buildRecord).filter(Boolean); await prisma.$transaction(async (tx) => { await tx.challengeWinner.deleteMany({ - where: { challengeId, type: PrizeSetTypeEnum.PLACEMENT }, + where: { + challengeId, + type: { in: [PrizeSetTypeEnum.PLACEMENT, PrizeSetTypeEnum.PASSED_REVIEW] }, + }, }); - if (winnerRecords.length > 0) { - await tx.challengeWinner.createMany({ data: winnerRecords }); + const toCreate = [...winnerRecords, ...passedReviewRecords]; + if (toCreate.length > 0) { + await tx.challengeWinner.createMany({ data: toCreate }); } }); diff --git a/prisma/migrations/20260121120000_add_passed_review_prize_type/migration.sql b/prisma/migrations/20260121120000_add_passed_review_prize_type/migration.sql new file mode 100644 index 0000000..76b3ef6 --- /dev/null +++ b/prisma/migrations/20260121120000_add_passed_review_prize_type/migration.sql @@ -0,0 +1,2 @@ +-- Add new prize set type for passed review rewards +ALTER TYPE "challenges"."PrizeSetTypeEnum" ADD VALUE IF NOT EXISTS 'PASSED_REVIEW'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8ce74c9..22dc4dd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -50,6 +50,7 @@ enum PrizeSetTypeEnum { PLACEMENT COPILOT REVIEWER + PASSED_REVIEW CHECKPOINT } diff --git a/winners1.csv b/winners1.csv new file mode 100644 index 0000000..690f639 --- /dev/null +++ b/winners1.csv @@ -0,0 +1,5 @@ +Submission ID,Challenge ID,Submitter handle,Submission date,Review score,Scorecard score,Placement,Prize type +LnxpmyLx2f0eBc,ffc62a9b-94ca-454b-a532-708b33db04ac,rdzianach,2013-09-28T04:26:41.983Z,90,20,1,PLACEMENT +pIzn45Te-rM06c,ffc62a9b-94ca-454b-a532-708b33db04ac,sdgun,2013-09-29T04:31:00.546Z,60,20,2,PLACEMENT +tD9We164PgpRnI,ffc62a9b-94ca-454b-a532-708b33db04ac,Kaumad,2013-09-28T18:02:55.554Z,20,20,1,PASSED_REVIEW +PrH9NO0kTiBtUM,ffc62a9b-94ca-454b-a532-708b33db04ac,Valarmathi1,2013-09-28T03:17:27.736Z,10,20,, From 927de6714d8fbccb7a4dbda16fa8bf25eb3398e1 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Wed, 21 Jan 2026 12:29:00 +0200 Subject: [PATCH 2/4] drop winners --- winners1.csv | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 winners1.csv diff --git a/winners1.csv b/winners1.csv deleted file mode 100644 index 690f639..0000000 --- a/winners1.csv +++ /dev/null @@ -1,5 +0,0 @@ -Submission ID,Challenge ID,Submitter handle,Submission date,Review score,Scorecard score,Placement,Prize type -LnxpmyLx2f0eBc,ffc62a9b-94ca-454b-a532-708b33db04ac,rdzianach,2013-09-28T04:26:41.983Z,90,20,1,PLACEMENT -pIzn45Te-rM06c,ffc62a9b-94ca-454b-a532-708b33db04ac,sdgun,2013-09-29T04:31:00.546Z,60,20,2,PLACEMENT -tD9We164PgpRnI,ffc62a9b-94ca-454b-a532-708b33db04ac,Kaumad,2013-09-28T18:02:55.554Z,20,20,1,PASSED_REVIEW -PrH9NO0kTiBtUM,ffc62a9b-94ca-454b-a532-708b33db04ac,Valarmathi1,2013-09-28T03:17:27.736Z,10,20,, From 929071a851dd98a743a2c1f1a16316f02ca405c2 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 23 Jan 2026 15:13:39 +1100 Subject: [PATCH 3/4] Use scorecard min passing score --- .../scripts/recalculateChallengeWinners.js | 79 +++++++++++++++---- 1 file changed, 65 insertions(+), 14 deletions(-) diff --git a/data-migration/src/scripts/recalculateChallengeWinners.js b/data-migration/src/scripts/recalculateChallengeWinners.js index bccfbd5..b707d7d 100644 --- a/data-migration/src/scripts/recalculateChallengeWinners.js +++ b/data-migration/src/scripts/recalculateChallengeWinners.js @@ -15,7 +15,7 @@ * - node data-migration/src/scripts/recalculateChallengeWinners.js --csv-only --csv-path /tmp/winners.csv * 3) Validate CSV output (challenge ordering is DESC by ID): * - Submission ID, Challenge ID, Submitter handle, Submission date, Review score (avg), - * Scorecard score (min), Placement + * Scorecard min passing score, Placement * 4) Run write mode to apply winners: * - node data-migration/src/scripts/recalculateChallengeWinners.js * 5) Optional filters: @@ -26,7 +26,8 @@ * * Notes: * - Review score is the average of committed final scores (falls back to initial score). - * - Passing requires review score >= scorecard minScore. + * - Passing requires review score >= scorecard minimumPassingScore (falls back to minScore). + * - Review scores only consider committed reviews from the Review phase. * - Winners are capped by placement prize count when placement prizes exist. * - Tie breaker for equal scores: earlier submission date wins the higher placement. */ @@ -246,9 +247,13 @@ const getChallengeIds = async (prisma, reviewClient, submissionTable, options) = return filtered; }; -const loadSubmissionScores = async (reviewClient, tables, challenge) => { +const loadSubmissionScores = async (reviewClient, tables, challenge, reviewPhaseIds) => { const challengeType = CHALLENGE_TYPE_BY_ID.get(challenge.typeId); const isMarathonMatch = challengeType === "Marathon Match"; + const phaseFilterClause = + reviewPhaseIds && reviewPhaseIds.length > 0 + ? Prisma.sql`r."phaseId" IN (${Prisma.join(reviewPhaseIds)})` + : Prisma.sql`FALSE`; const rows = isMarathonMatch ? await reviewClient.$queryRaw` @@ -286,28 +291,52 @@ const loadSubmissionScores = async (reviewClient, tables, challenge) => { WHERE s."challengeId" = ${challenge.id} AND s."type" = ${CONTEST_SUBMISSION_TYPE}::reviews."SubmissionType" AND r."committed" = true + AND ${phaseFilterClause} GROUP BY s.id, s."challengeId", s."memberId", s."submittedDate", s."createdAt" `; return rows || []; }; -const loadScorecardMinScores = async (reviewClient, scorecardTable, scorecardIds) => { +const loadScorecardPassingScores = async (reviewClient, scorecardTable, scorecardIds) => { if (!scorecardIds.length) { return new Map(); } const rows = await reviewClient.$queryRaw` - SELECT id, "minScore" + SELECT id, "minScore", "minimumPassingScore" FROM ${scorecardTable} WHERE id IN (${Prisma.join(scorecardIds)}) `; return new Map( - (rows || []).map((row) => [row.id, toNumber(row.minScore) ?? 0]) + (rows || []).map((row) => { + const minimumPassingScore = toNumber(row.minimumPassingScore); + const minScore = toNumber(row.minScore); + const passingScore = minimumPassingScore !== null ? minimumPassingScore : (minScore ?? 0); + return [row.id, passingScore]; + }) ); }; +const getReviewPhaseIds = (challenge) => { + if (!challenge || !Array.isArray(challenge.phases)) { + return []; + } + const phaseIds = new Set(); + challenge.phases.forEach((phase) => { + const name = String(phase.name || "").trim().toLowerCase(); + if (name !== "review") { + return; + } + [phase.id, phase.phaseId] + .map((value) => String(value || "").trim()) + .filter(Boolean) + .forEach((value) => phaseIds.add(value)); + }); + return Array.from(phaseIds); +}; + const loadSubmitterHandles = async (challengeId) => { try { const resources = await helper.getChallengeResources( @@ -403,7 +432,7 @@ async function main() { "Submitter handle", "Submission date", "Review score", - "Scorecard score", + "Scorecard min passing score", "Placement", "Prize type", ] @@ -421,6 +450,15 @@ async function main() { const challenge = await prisma.challenge.findUnique({ where: { id: challengeId }, + include: { + phases: { + select: { + id: true, + name: true, + phaseId: true, + }, + }, + }, }); if (!challenge) { console.warn(`Challenge ${challengeId} not found, skipping.`); @@ -428,7 +466,21 @@ async function main() { continue; } - const submissions = await loadSubmissionScores(reviewClient, tables, challenge); + const challengeType = CHALLENGE_TYPE_BY_ID.get(challenge.typeId); + const isMarathonMatch = challengeType === "Marathon Match"; + const reviewPhaseIds = getReviewPhaseIds(challenge); + if (!isMarathonMatch && reviewPhaseIds.length === 0) { + console.warn( + `Challenge ${challengeId} has no Review phase; skipping review-score processing.` + ); + } + + const submissions = await loadSubmissionScores( + reviewClient, + tables, + challenge, + reviewPhaseIds + ); if (!submissions.length) { skipped += 1; @@ -442,22 +494,21 @@ async function main() { .filter((id) => typeof id === "string" && id.length > 0) ) ); - const minScoreByScorecard = await loadScorecardMinScores( + const passingScoreByScorecard = await loadScorecardPassingScores( reviewClient, tables.scorecard, scorecardIds ); - const challengeType = CHALLENGE_TYPE_BY_ID.get(challenge.typeId); - const isMarathonMatch = challengeType === "Marathon Match"; - const normalized = submissions.map((row) => { const reviewScoreValue = toNumber(row.reviewScore); const reviewScore = reviewScoreValue === null ? null : roundScore(reviewScoreValue); const scorecardScore = isMarathonMatch ? 0 - : toNumber(minScoreByScorecard.get(row.scorecardId)) ?? 0; - const isPassing = reviewScore !== null && (isMarathonMatch ? reviewScore > 0 : reviewScore >= scorecardScore); + : toNumber(passingScoreByScorecard.get(row.scorecardId)) ?? 0; + const isPassing = + reviewScore !== null && + (isMarathonMatch ? reviewScore > 0 : reviewScore >= scorecardScore); return { submissionId: row.submissionId, challengeId: row.challengeId, From 44cd58d45ec4b482beb3114738394a73d559cf54 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 23 Jan 2026 15:32:57 +1100 Subject: [PATCH 4/4] Tweak eligible phases to include Iterative Review --- data-migration/src/scripts/recalculateChallengeWinners.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/data-migration/src/scripts/recalculateChallengeWinners.js b/data-migration/src/scripts/recalculateChallengeWinners.js index b707d7d..ccaffb8 100644 --- a/data-migration/src/scripts/recalculateChallengeWinners.js +++ b/data-migration/src/scripts/recalculateChallengeWinners.js @@ -27,7 +27,7 @@ * Notes: * - Review score is the average of committed final scores (falls back to initial score). * - Passing requires review score >= scorecard minimumPassingScore (falls back to minScore). - * - Review scores only consider committed reviews from the Review phase. + * - Review scores only consider committed reviews from the Review/Iterative Review phases. * - Winners are capped by placement prize count when placement prizes exist. * - Tie breaker for equal scores: earlier submission date wins the higher placement. */ @@ -323,10 +323,11 @@ const getReviewPhaseIds = (challenge) => { if (!challenge || !Array.isArray(challenge.phases)) { return []; } + const eligiblePhases = new Set(["review", "iterative review"]); const phaseIds = new Set(); challenge.phases.forEach((phase) => { const name = String(phase.name || "").trim().toLowerCase(); - if (name !== "review") { + if (!eligiblePhases.has(name)) { return; } [phase.id, phase.phaseId] @@ -471,7 +472,7 @@ async function main() { const reviewPhaseIds = getReviewPhaseIds(challenge); if (!isMarathonMatch && reviewPhaseIds.length === 0) { console.warn( - `Challenge ${challengeId} has no Review phase; skipping review-score processing.` + `Challenge ${challengeId} has no Review/Iterative Review phase; skipping review-score processing.` ); }