Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 195 additions & 68 deletions data-migration/src/scripts/recalculateChallengeWinners.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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/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.
*/
Expand All @@ -48,6 +49,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;

Expand Down Expand Up @@ -211,7 +219,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"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ performance]
The cast to reviews."SubmissionType" might not be necessary if the column type already matches CONTEST_SUBMISSION_TYPE. Ensure this cast is required to avoid potential performance overhead.

ORDER BY "challengeId" DESC
`;
challengeIds = rows.map((row) => row.id).filter(Boolean);
Expand Down Expand Up @@ -239,49 +247,97 @@ 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, 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)})`

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ security]
The use of Prisma.sql with Prisma.join for dynamic SQL generation can be risky if reviewPhaseIds contains untrusted input. Ensure that reviewPhaseIds is properly validated and sanitized before being used here to prevent SQL injection vulnerabilities.

: Prisma.sql`FALSE`;

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"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ performance]
The cast to reviews."SubmissionType" might not be necessary if the column type already matches CONTEST_SUBMISSION_TYPE. Ensure this cast is required to avoid potential performance overhead.

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"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ performance]
The cast to reviews."SubmissionType" might not be necessary if the column type already matches CONTEST_SUBMISSION_TYPE. Ensure this cast is required to avoid potential performance overhead.

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);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
The logic for determining passingScore could be simplified by using a default value for minimumPassingScore directly in the SQL query, reducing the need for post-processing logic.

return [row.id, passingScore];
})
);
};

const getReviewPhaseIds = (challenge) => {
if (!challenge || !Array.isArray(challenge.phases)) {
return [];
}
const eligiblePhases = new Set(["review", "iterative review"]);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
Consider using a constant or configuration for the phase names to avoid hardcoding them in multiple places. This will improve maintainability and reduce the risk of typos or inconsistencies.

const phaseIds = new Set();
challenge.phases.forEach((phase) => {
const name = String(phase.name || "").trim().toLowerCase();
if (!eligiblePhases.has(name)) {
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(
Expand Down Expand Up @@ -318,7 +374,7 @@ const buildCsvWriter = (csvPath) => {
if (!csvPath) {
return {
writeLine: (line) => process.stdout.write(`${line}\n`),
end: () => {},
end: () => { },
};
}

Expand Down Expand Up @@ -350,6 +406,7 @@ async function main() {
submission: buildSchemaTable(reviewSchema, "submission"),
review: buildSchemaTable(reviewSchema, "review"),
scorecard: buildSchemaTable(reviewSchema, "scorecard"),
reviewSummation: buildSchemaTable(reviewSchema, "reviewSummation"),
};

const prisma = new PrismaClient();
Expand All @@ -376,8 +433,9 @@ async function main() {
"Submitter handle",
"Submission date",
"Review score",
"Scorecard score",
"Scorecard min passing score",
"Placement",
"Prize type",
]
.map(toCsvValue)
.join(",")
Expand All @@ -390,7 +448,40 @@ 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 },
include: {
phases: {
select: {
id: true,
name: true,
phaseId: true,
},
},
},
});
if (!challenge) {
console.warn(`Challenge ${challengeId} not found, skipping.`);
skipped += 1;
continue;
}

const challengeType = CHALLENGE_TYPE_BY_ID.get(challenge.typeId);
const isMarathonMatch = challengeType === "Marathon Match";
const reviewPhaseIds = getReviewPhaseIds(challenge);
if (!isMarathonMatch && reviewPhaseIds.length === 0) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 design]
The check for reviewPhaseIds.length === 0 and subsequent warning could be moved into getReviewPhaseIds to encapsulate the logic and reduce repetition.

console.warn(
`Challenge ${challengeId} has no Review/Iterative Review phase; skipping review-score processing.`
);
}

const submissions = await loadSubmissionScores(
reviewClient,
tables,
challenge,
reviewPhaseIds
);

if (!submissions.length) {
skipped += 1;
Expand All @@ -404,7 +495,7 @@ async function main() {
.filter((id) => typeof id === "string" && id.length > 0)
)
);
const minScoreByScorecard = await loadScorecardMinScores(
const passingScoreByScorecard = await loadScorecardPassingScores(
reviewClient,
tables.scorecard,
scorecardIds
Expand All @@ -413,8 +504,12 @@ async function main() {
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(passingScoreByScorecard.get(row.scorecardId)) ?? 0;
const isPassing =
reviewScore !== null &&
(isMarathonMatch ? reviewScore > 0 : reviewScore >= scorecardScore);
return {
submissionId: row.submissionId,
challengeId: row.challengeId,
Expand Down Expand Up @@ -449,16 +544,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);
}

Expand All @@ -471,18 +581,21 @@ 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(
[
row.submissionId,
row.challengeId,
submitterHandle,
toIsoString(row.submittedAt),
toIsoString(row.submittedAt || row.createdAt),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 readability]
Consider using row.submittedDate directly instead of row.submittedAt || row.createdAt to avoid potential confusion between submission and creation dates.

row.reviewScore === null ? "" : row.reviewScore,
row.scorecardScore,
placement,
prizeType,
]
.map(toCsvValue)
.join(",")
Expand All @@ -491,37 +604,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 });
}
});

Expand Down
Loading
Loading