From dbde82044f39dd696cc5db641471d71ebde13df0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 30 Mar 2026 22:15:00 +1100 Subject: [PATCH 01/27] PM-4545: treat new WM tasks as task challenges for payments What was broken Payments were not generated when tasks created from the new work manager were completed because the task completion path did not recognize them as task challenges. Root cause The completion and payment logic only checked legacy.pureV5Task, while new work manager tasks are stored through canonical task metadata (task.isTask/taskIsTask) without that legacy flag. What was changed Updated ChallengeService task handling to treat either canonical task metadata or legacy.pureV5Task as a task challenge during validation, completion preparation, winner normalization, and task.memberId synchronization. Any added/updated tests Added a ChallengeService regression test covering task completion/payment generation when legacy.pureV5Task is absent. --- src/services/ChallengeService.js | 15 ++++++--- test/unit/ChallengeService.test.js | 51 ++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index f2a88d6..30c62f7 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -2367,7 +2367,9 @@ async function validateWinners(winners, challengeResources) { * @param {Array} challengeResources the challenge resources */ function validateTask(currentUser, challenge, data, challengeResources) { - if (!_.get(challenge, "legacy.pureV5Task")) { + const isTask = helper.getTaskInfo(challenge).isTask || _.get(challenge, "legacy.pureV5Task"); + + if (!isTask) { // Not a Task return; } @@ -2407,7 +2409,7 @@ function validateTask(currentUser, challenge, data, challengeResources) { } function prepareTaskCompletionData(challenge, challengeResources, data) { - const isTask = _.get(challenge, "legacy.pureV5Task"); + const isTask = helper.getTaskInfo(challenge).isTask || _.get(challenge, "legacy.pureV5Task"); const isCompleteTask = data.status === ChallengeStatusEnum.COMPLETED && challenge.status !== ChallengeStatusEnum.COMPLETED; @@ -2943,7 +2945,10 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { await validateWinners(combinedWinnerPayload, challengeResources); } - if (_.get(challenge, "legacy.pureV5Task", false) && !_.isUndefined(data.winners)) { + const isTaskChallenge = + helper.getTaskInfo(challenge).isTask || _.get(challenge, "legacy.pureV5Task"); + + if (isTaskChallenge && !_.isUndefined(data.winners)) { _.each(data.winners, (w) => { w.type = PrizeSetTypeEnum.PLACEMENT; }); @@ -2971,7 +2976,7 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { // task.memberId goes out of sync due to another processor setting "task.memberId" but subsequent immediate update to the task // will not have the memberId set. So we need to set it using winners to ensure it is always in sync. The proper fix is to correct // the sync issue in the processor. However this is quick fix that works since winner.userId is task.memberId. - if (_.get(challenge, "legacy.pureV5Task") && !_.isUndefined(data.winners)) { + if (isTaskChallenge && !_.isUndefined(data.winners)) { const winnerMemberId = _.get(data.winners, "[0].userId"); logger.info( `Setting task.memberId to ${winnerMemberId} for challenge ${challengeId}. Task ${_.get( @@ -2994,7 +2999,7 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { logger.info(`task ${challengeId} has no winner set yet.`); } } else { - logger.info(`${challengeId} is not a pureV5 challenge or has no winners set yet.`); + logger.info(`${challengeId} is not a task challenge or has no winners set yet.`); } const finalTypeId = data.typeId || challenge.typeId; diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index d070454..a4bb060 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -1224,6 +1224,57 @@ describe('challenge service unit tests', () => { should.exist(result.updated) }) + it('update challenge - triggers payments for task challenges stored without legacy.pureV5Task', async () => { + const originalGetChallengeResources = helper.getChallengeResources + const originalGenerateChallengePayments = helper.generateChallengePayments + let generatedPaymentsChallengeId + + helper.getChallengeResources = async (challengeId) => { + if (challengeId === data.taskChallenge.id) { + return [{ + roleId: config.SUBMITTER_ROLE_ID, + memberId: 12345678, + memberHandle: 'thomaskranitsas' + }] + } + + return originalGetChallengeResources(challengeId) + } + helper.generateChallengePayments = async (challengeId) => { + generatedPaymentsChallengeId = challengeId + return true + } + + try { + await prisma.challenge.update({ + where: { id: data.taskChallenge.id }, + data: { + status: ChallengeStatusEnum.ACTIVE, + updatedBy: 'admin' + } + }) + + const result = await service.updateChallenge( + { isMachine: true, sub: 'sub-task', userId: 22838965 }, + data.taskChallenge.id, + { + status: ChallengeStatusEnum.COMPLETED, + winners: [{ + userId: 12345678, + handle: 'thomaskranitsas', + placement: 1 + }] + } + ) + + should.equal(result.status, ChallengeStatusEnum.COMPLETED) + should.equal(generatedPaymentsChallengeId, data.taskChallenge.id) + } finally { + helper.getChallengeResources = originalGetChallengeResources + helper.generateChallengePayments = originalGenerateChallengePayments + } + }) + describe('reviewer scorecard changes', () => { const originalScorecardId = 'sc-original' const newScorecardId = 'sc-updated' From 7c339ec065218ed1f802f0407118a8fca06b4d93 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 31 Mar 2026 13:41:17 +1100 Subject: [PATCH 02/27] PM-4528: expose draft billing for project write users What was broken:\nChallenge editor GET responses stripped billing for all interactive users, so the draft page challenge fee fell back to $0.\n\nRoot cause:\nChallengeService.getChallenge unconditionally removed billing for non-machine, non-admin callers even when they already had project write access.\n\nWhat was changed:\nPreserved challenge billing details in getChallenge for project write users while continuing to hide billing for users without that access.\nAdded regression coverage for both the allowed and denied billing-response paths.\n\nTests:\nAdded getChallenge billing visibility tests in test/unit/ChallengeService.test.js.\nRan npm run lint.\nRan npm test, which is blocked in this workspace because Prisma has no DATABASE_URL configured.\nRan npm run build, which fails because challenge-api-v6 does not define a build script. --- src/services/ChallengeService.js | 17 +++++++++----- test/unit/ChallengeService.test.js | 37 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 30c62f7..70a12b9 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -2160,7 +2160,8 @@ createChallenge.schema = { * @param {Object} currentUser the user who perform operation * @param {String} id the challenge id * @param {Boolean} checkIfExists flag to check if challenge exists - * @returns {Object} the challenge with given id + * @returns {Object} the challenge with given id. Interactive callers keep + * billing details only when they already have project write access. */ async function getChallenge(currentUser, id, checkIfExists) { // Log the ID of the challenge being requested @@ -2181,14 +2182,18 @@ async function getChallenge(currentUser, id, checkIfExists) { // Remove privateDescription for unregistered users if (currentUser) { if (!currentUser.isMachine && !hasAdminRole(currentUser)) { - _.unset(challenge, "billing"); + const hasProjectWriteAccess = await helper.userHasProjectWriteAccess( + challenge.projectId, + currentUser, + ); + + if (!hasProjectWriteAccess) { + _.unset(challenge, "billing"); + } + if (_.isEmpty(challenge.privateDescription)) { _.unset(challenge, "privateDescription"); } else if (!taskInfo.isTask || !taskInfo.isAssigned) { - const hasProjectWriteAccess = await helper.userHasProjectWriteAccess( - challenge.projectId, - currentUser, - ); if (!hasProjectWriteAccess) { const memberResources = await helper.listResourcesByMemberAndChallenge( currentUser.userId, diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index a4bb060..2b1c2ce 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -460,6 +460,43 @@ describe('challenge service unit tests', () => { should.equal(result.numOfRegistrants, 0) }) + it('get challenge preserves billing for project write users', async () => { + const originalUserHasProjectWriteAccess = helper.userHasProjectWriteAccess + + helper.userHasProjectWriteAccess = async () => true + + try { + const result = await service.getChallenge( + { handle: 'writer', userId: 'testuser' }, + createdChallengeData.id + ) + + should.deepEqual(result.billing, createdChallengeData.billing) + } finally { + helper.userHasProjectWriteAccess = originalUserHasProjectWriteAccess + } + }) + + it('get challenge hides billing for users without project write access', async () => { + const originalUserHasProjectWriteAccess = helper.userHasProjectWriteAccess + const originalListResourcesByMemberAndChallenge = helper.listResourcesByMemberAndChallenge + + helper.userHasProjectWriteAccess = async () => false + helper.listResourcesByMemberAndChallenge = async () => [] + + try { + const result = await service.getChallenge( + { handle: 'viewer', userId: 'testuser' }, + createdChallengeData.id + ) + + should.equal(_.isUndefined(result.billing), true) + } finally { + helper.userHasProjectWriteAccess = originalUserHasProjectWriteAccess + helper.listResourcesByMemberAndChallenge = originalListResourcesByMemberAndChallenge + } + }) + it('get challenge - not found', async () => { try { await service.getChallenge({ isMachine: true }, notFoundId) From 31f54ab1e9255890fe27e7f5ee3ee5db5fa9f28a Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 31 Mar 2026 13:50:49 +1100 Subject: [PATCH 03/27] PM-4618: preserve challenge attachments on draft save What was broken Updating a challenge deleted existing attachments, so saving a draft challenge cleared uploaded files. Root cause The challenge update transaction deleted attachments whenever the patch payload omitted attachment data because it checked updateData.attachment, which is not populated in the normal save flow. What was changed Removed the attachment deletion from the general challenge update path so attachments are only managed by the dedicated attachment endpoints. Added a unit test that updates a challenge without an attachments field and verifies the existing attachment remains in both the response and the database. Any added/updated tests Added a unit test in test/unit/ChallengeService.test.js covering attachment persistence when the update payload omits attachments. --- src/services/ChallengeService.js | 9 +- test/unit/ChallengeService.test.js | 3095 +++++++++++++++------------- 2 files changed, 1670 insertions(+), 1434 deletions(-) diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 70a12b9..da32a13 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -2893,9 +2893,9 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { logger.debug(`updateChallenge(AI screening): ${message} (challengeId=${challengeId})`); await challengeHelper.addAIScreeningPhaseForChallenge(tempChallenge, prisma, debugLogForAI); logger.info( - `updateChallenge: AI screening phase ensured (challengeId=${challengeId}) resultingPhases=${( - tempChallenge.phases || [] - ).length}`, + `updateChallenge: AI screening phase ensured (challengeId=${challengeId}) resultingPhases=${ + (tempChallenge.phases || []).length + }`, ); // Update phasesForUpdate with the updated phases after AI screening addition phasesForUpdate = tempChallenge.phases; @@ -3237,9 +3237,6 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { if (!_.isNil(updateData.winners)) { await tx.challengeWinner.deleteMany({ where: { challengeId } }); } - if (_.isNil(updateData.attachment)) { - await tx.attachment.deleteMany({ where: { challengeId } }); - } if (shouldReplaceTerms) { await tx.challengeTerm.deleteMany({ where: { challengeId } }); } diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index 2b1c2ce..88d624b 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -3,61 +3,61 @@ */ if (!process.env.REVIEW_DB_URL && process.env.DATABASE_URL) { - process.env.REVIEW_DB_URL = process.env.DATABASE_URL + process.env.REVIEW_DB_URL = process.env.DATABASE_URL; } -require('../../app-bootstrap') -const _ = require('lodash') -const config = require('config') -const { v4: uuid } = require('uuid'); -const chai = require('chai') -const constants = require('../../app-constants') -const service = require('../../src/services/ChallengeService') -const helper = require('../../src/common/helper') -const challengeHelper = require('../../src/common/challenge-helper') -const projectHelper = require('../../src/common/project-helper') -const testHelper = require('../testHelper') -const { getClient, ChallengeStatusEnum, PrizeSetTypeEnum } = require('../../src/common/prisma') -const { getReviewClient } = require('../../src/common/review-prisma') -const prisma = getClient() -const reviewSchema = config.get('REVIEW_DB_SCHEMA') -const reviewTableName = `"${reviewSchema}"."review"` -const should = chai.should() -let reviewClient - -describe('challenge service unit tests', () => { +require("../../app-bootstrap"); +const _ = require("lodash"); +const config = require("config"); +const { v4: uuid } = require("uuid"); +const chai = require("chai"); +const constants = require("../../app-constants"); +const service = require("../../src/services/ChallengeService"); +const helper = require("../../src/common/helper"); +const challengeHelper = require("../../src/common/challenge-helper"); +const projectHelper = require("../../src/common/project-helper"); +const testHelper = require("../testHelper"); +const { getClient, ChallengeStatusEnum, PrizeSetTypeEnum } = require("../../src/common/prisma"); +const { getReviewClient } = require("../../src/common/review-prisma"); +const prisma = getClient(); +const reviewSchema = config.get("REVIEW_DB_SCHEMA"); +const reviewTableName = `"${reviewSchema}"."review"`; +const should = chai.should(); +let reviewClient; + +describe("challenge service unit tests", () => { // created entity id - let id - let id2 - let attachment + let id; + let id2; + let attachment; const winners = [ { userId: 12345678, - handle: 'thomaskranitsas', - placement: 1 + handle: "thomaskranitsas", + placement: 1, }, { userId: 3456789, - handle: 'tonyj', - placement: 2 - } - ] + handle: "tonyj", + placement: 2, + }, + ]; // generated data - let data - let testChallengeData - let createdChallengeData - const notFoundId = uuid() + let data; + let testChallengeData; + let createdChallengeData; + const notFoundId = uuid(); const authUser = { - userId: 'testuser' - } + userId: "testuser", + }; before(async () => { - await testHelper.clearData() - await testHelper.createData() - data = testHelper.getData() + await testHelper.clearData(); + await testHelper.createData(); + data = testHelper.getData(); - reviewClient = getReviewClient() - await reviewClient.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${reviewSchema}"`) + reviewClient = getReviewClient(); + await reviewClient.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${reviewSchema}"`); await reviewClient.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS ${reviewTableName} ( "id" varchar(36) PRIMARY KEY, @@ -67,931 +67,1047 @@ describe('challenge service unit tests', () => { "createdAt" timestamp DEFAULT now(), "updatedAt" timestamp DEFAULT now() ) - `) + `); await reviewClient.$executeRawUnsafe(` ALTER TABLE ${reviewTableName} ADD COLUMN IF NOT EXISTS "scorecardId" varchar(255) - `) - await reviewClient.$executeRawUnsafe(`DELETE FROM ${reviewTableName}`) + `); + await reviewClient.$executeRawUnsafe(`DELETE FROM ${reviewTableName}`); testChallengeData = { typeId: data.challenge.typeId, trackId: data.challenge.trackId, legacy: { - reviewType: 'COMMUNITY', - confidentialityType: 'public', + reviewType: "COMMUNITY", + confidentialityType: "public", useSchedulingAPI: true, pureV5Task: false, selfService: false, - selfServiceCopilot: 'aaa' + selfServiceCopilot: "aaa", }, billing: { - billingAccountId: 'billing-account', - markup: 100 + billingAccountId: "billing-account", + markup: 100, }, task: { isTask: false, isAssigned: false, - memberId: null + memberId: null, }, - name: 'Prisma Test Challenge', - description: 'Prisma Test Challenge', - privateDescription: 'Prisma Test Challenge', - descriptionFormat: 'html', + name: "Prisma Test Challenge", + description: "Prisma Test Challenge", + privateDescription: "Prisma Test Challenge", + descriptionFormat: "html", funChallenge: true, metadata: [ { - name: 'meta-name', - value: 'meta-value' - } + name: "meta-name", + value: "meta-value", + }, ], timelineTemplateId: data.timelineTemplate.id, events: [ { id: 1, - name: 'event-name', - key: 'event-key' - } + name: "event-name", + key: "event-key", + }, + ], + phases: [ + { + phaseId: data.phase.id, + duration: 120, + }, + { + phaseId: data.phase2.id, + duration: 200, + }, + ], + discussions: [ + { + id: "ad985cff-ad3e-44de-b54e-3992505ba0ae", + name: "discussion name", + type: "challenge", + provider: "vanilla", + options: [{ "discussion-opt": "discussion-value" }], + }, ], - phases: [{ - phaseId: data.phase.id, - duration: 120 - }, { - phaseId: data.phase2.id, - duration: 200 - }], - discussions: [{ - id: 'ad985cff-ad3e-44de-b54e-3992505ba0ae', - name: 'discussion name', - type: 'challenge', - provider: 'vanilla', - options: [ - { 'discussion-opt': 'discussion-value' } - ] - }], prizeSets: [ { - type: 'placement', - description: 'placement prizes', + type: "placement", + description: "placement prizes", prizes: [ { - description: 'placement 1', - type: 'USD', - value: 1000 - } - ] - } - ], - tags: [ - 'tag-1', 'tag-2' + description: "placement 1", + type: "USD", + value: 1000, + }, + ], + }, ], + tags: ["tag-1", "tag-2"], legacyId: 1, projectId: 123, - startDate: '2025-03-13T06:56:50.701Z', - status: 'New', + startDate: "2025-03-13T06:56:50.701Z", + status: "New", groups: [], terms: [], - skills: [] - } - }) + skills: [], + }; + }); after(async () => { - const idsToDelete = _.compact([id, id2]) + const idsToDelete = _.compact([id, id2]); if (idsToDelete.length > 0) { await prisma.challenge.deleteMany({ where: { id: { - in: idsToDelete - } - } - }) + in: idsToDelete, + }, + }, + }); } - await testHelper.clearData() - }) - - describe('create challenge tests', () => { - it('create challenge successfully', async () => { - const challengeData = _.cloneDeep(testChallengeData) - const result = await service.createChallenge({ isMachine: true, sub: 'sub', userId: 'testuser' }, challengeData, config.M2M_FULL_ACCESS_TOKEN) - createdChallengeData = result - should.exist(result.id) - id = result.id - should.equal(result.typeId, data.challenge.typeId) - should.equal(result.trackId, data.challenge.trackId) - should.equal(result.name, testChallengeData.name) - should.equal(result.description, testChallengeData.description) - should.equal(result.timelineTemplateId, testChallengeData.timelineTemplateId) - should.equal(result.phases.length, 2) - should.exist(result.phases[0].id) - should.equal(result.phases[0].phaseId, data.phase.id) - should.equal(result.phases[0].duration, challengeData.phases[0].duration) - should.equal(testHelper.getDatesDiff(result.phases[0].scheduledStartDate, challengeData.startDate), 0) - should.equal(testHelper.getDatesDiff(result.phases[0].scheduledEndDate, challengeData.startDate), - challengeData.phases[0].duration * 1000) - should.exist(result.phases[1].id) - should.equal(result.phases[1].phaseId, data.phase2.id) - should.equal(result.phases[1].predecessor, result.phases[0].phaseId) - should.equal(result.phases[1].duration, challengeData.phases[1].duration) - should.equal(testHelper.getDatesDiff(result.phases[1].scheduledStartDate, challengeData.startDate), - challengeData.phases[0].duration * 1000) - should.equal(testHelper.getDatesDiff(result.phases[1].scheduledEndDate, challengeData.startDate), - challengeData.phases[0].duration * 1000 + challengeData.phases[1].duration * 1000) - should.equal(result.prizeSets.length, 1) - should.equal(result.prizeSets[0].type, testChallengeData.prizeSets[0].type) - should.equal(result.prizeSets[0].description, testChallengeData.prizeSets[0].description) - should.equal(result.prizeSets[0].prizes.length, 1) - should.equal(result.prizeSets[0].prizes[0].description, testChallengeData.prizeSets[0].prizes[0].description) - should.equal(result.prizeSets[0].prizes[0].type, testChallengeData.prizeSets[0].prizes[0].type) - should.equal(result.prizeSets[0].prizes[0].value, testChallengeData.prizeSets[0].prizes[0].value) - should.equal(result.reviewType, testChallengeData.reviewType) - should.equal(result.tags.length, 2) - should.equal(result.tags[0], testChallengeData.tags[0]) - should.equal(_.isNil(result.projectId), _.isNil(testChallengeData.projectId)) - should.equal(result.legacyId, testChallengeData.legacyId) - should.equal(result.forumId, testChallengeData.forumId) - should.equal(result.status, testChallengeData.status) - should.equal(result.funChallenge, testChallengeData.funChallenge) - should.equal(result.createdBy, 'testuser') - should.exist(result.startDate) - should.exist(result.created) - should.equal(result.numOfSubmissions, 0) - should.equal(result.numOfRegistrants, 0) - }) - - it('create challenge successfully when project directProjectId is a numeric string', async () => { - const challengeData = _.cloneDeep(testChallengeData) - const originalGetProject = projectHelper.getProject - projectHelper.getProject = async () => ({ directProjectId: '33541' }) + await testHelper.clearData(); + }); + + describe("create challenge tests", () => { + it("create challenge successfully", async () => { + const challengeData = _.cloneDeep(testChallengeData); + const result = await service.createChallenge( + { isMachine: true, sub: "sub", userId: "testuser" }, + challengeData, + config.M2M_FULL_ACCESS_TOKEN, + ); + createdChallengeData = result; + should.exist(result.id); + id = result.id; + should.equal(result.typeId, data.challenge.typeId); + should.equal(result.trackId, data.challenge.trackId); + should.equal(result.name, testChallengeData.name); + should.equal(result.description, testChallengeData.description); + should.equal(result.timelineTemplateId, testChallengeData.timelineTemplateId); + should.equal(result.phases.length, 2); + should.exist(result.phases[0].id); + should.equal(result.phases[0].phaseId, data.phase.id); + should.equal(result.phases[0].duration, challengeData.phases[0].duration); + should.equal( + testHelper.getDatesDiff(result.phases[0].scheduledStartDate, challengeData.startDate), + 0, + ); + should.equal( + testHelper.getDatesDiff(result.phases[0].scheduledEndDate, challengeData.startDate), + challengeData.phases[0].duration * 1000, + ); + should.exist(result.phases[1].id); + should.equal(result.phases[1].phaseId, data.phase2.id); + should.equal(result.phases[1].predecessor, result.phases[0].phaseId); + should.equal(result.phases[1].duration, challengeData.phases[1].duration); + should.equal( + testHelper.getDatesDiff(result.phases[1].scheduledStartDate, challengeData.startDate), + challengeData.phases[0].duration * 1000, + ); + should.equal( + testHelper.getDatesDiff(result.phases[1].scheduledEndDate, challengeData.startDate), + challengeData.phases[0].duration * 1000 + challengeData.phases[1].duration * 1000, + ); + should.equal(result.prizeSets.length, 1); + should.equal(result.prizeSets[0].type, testChallengeData.prizeSets[0].type); + should.equal(result.prizeSets[0].description, testChallengeData.prizeSets[0].description); + should.equal(result.prizeSets[0].prizes.length, 1); + should.equal( + result.prizeSets[0].prizes[0].description, + testChallengeData.prizeSets[0].prizes[0].description, + ); + should.equal( + result.prizeSets[0].prizes[0].type, + testChallengeData.prizeSets[0].prizes[0].type, + ); + should.equal( + result.prizeSets[0].prizes[0].value, + testChallengeData.prizeSets[0].prizes[0].value, + ); + should.equal(result.reviewType, testChallengeData.reviewType); + should.equal(result.tags.length, 2); + should.equal(result.tags[0], testChallengeData.tags[0]); + should.equal(_.isNil(result.projectId), _.isNil(testChallengeData.projectId)); + should.equal(result.legacyId, testChallengeData.legacyId); + should.equal(result.forumId, testChallengeData.forumId); + should.equal(result.status, testChallengeData.status); + should.equal(result.funChallenge, testChallengeData.funChallenge); + should.equal(result.createdBy, "testuser"); + should.exist(result.startDate); + should.exist(result.created); + should.equal(result.numOfSubmissions, 0); + should.equal(result.numOfRegistrants, 0); + }); + + it("create challenge successfully when project directProjectId is a numeric string", async () => { + const challengeData = _.cloneDeep(testChallengeData); + const originalGetProject = projectHelper.getProject; + projectHelper.getProject = async () => ({ directProjectId: "33541" }); try { const result = await service.createChallenge( - { isMachine: true, sub: 'sub', userId: 'testuser' }, + { isMachine: true, sub: "sub", userId: "testuser" }, challengeData, - config.M2M_FULL_ACCESS_TOKEN || 'test-token' - ) - id2 = result.id - should.equal(_.get(result, 'legacy.directProjectId'), 33541) + config.M2M_FULL_ACCESS_TOKEN || "test-token", + ); + id2 = result.id; + should.equal(_.get(result, "legacy.directProjectId"), 33541); } finally { - projectHelper.getProject = originalGetProject - } - }) - - it('create challenge applies default ai configs when reviewers are not provided', async () => { - const challengeData = _.cloneDeep(testChallengeData) - challengeData.discussions[0].type = 'CHALLENGE' - challengeData.prizeSets[0].type = 'PLACEMENT' - challengeData.status = 'NEW' - const originalGetProject = projectHelper.getProject - const originalApplyDefaultMemberReviewers = challengeHelper.applyDefaultMemberReviewersForChallengeCreation - const originalApplyDefaultAIConfig = challengeHelper.applyDefaultAIConfigForChallengeCreation - const originalCreateAIReviewConfigs = challengeHelper.createAIReviewConfigsForChallengeCreation - const aiReviewConfigs = [{ - templateId: 'template-1', - minPassingThreshold: 80, - mode: 'aggregated', - autoFinalize: false, - formula: {}, - workflows: [{ workflowId: 'wf-1', weightPercent: 100, isGating: true }] - }] - - let applyDefaultAICallCount = 0 - let createAIConfigCallCount = 0 - let createdChallengeId - let createdConfigs - let tempChallengeId - - projectHelper.getProject = async () => ({ directProjectId: '33541' }) - challengeHelper.applyDefaultMemberReviewersForChallengeCreation = async () => {} + projectHelper.getProject = originalGetProject; + } + }); + + it("create challenge applies default ai configs when reviewers are not provided", async () => { + const challengeData = _.cloneDeep(testChallengeData); + challengeData.discussions[0].type = "CHALLENGE"; + challengeData.prizeSets[0].type = "PLACEMENT"; + challengeData.status = "NEW"; + const originalGetProject = projectHelper.getProject; + const originalApplyDefaultMemberReviewers = + challengeHelper.applyDefaultMemberReviewersForChallengeCreation; + const originalApplyDefaultAIConfig = challengeHelper.applyDefaultAIConfigForChallengeCreation; + const originalCreateAIReviewConfigs = + challengeHelper.createAIReviewConfigsForChallengeCreation; + const aiReviewConfigs = [ + { + templateId: "template-1", + minPassingThreshold: 80, + mode: "aggregated", + autoFinalize: false, + formula: {}, + workflows: [{ workflowId: "wf-1", weightPercent: 100, isGating: true }], + }, + ]; + + let applyDefaultAICallCount = 0; + let createAIConfigCallCount = 0; + let createdChallengeId; + let createdConfigs; + let tempChallengeId; + + projectHelper.getProject = async () => ({ directProjectId: "33541" }); + challengeHelper.applyDefaultMemberReviewersForChallengeCreation = async () => {}; challengeHelper.applyDefaultAIConfigForChallengeCreation = async () => { - applyDefaultAICallCount += 1 - return aiReviewConfigs - } - challengeHelper.createAIReviewConfigsForChallengeCreation = async (challengeIdArg, aiConfigsArg) => { - createAIConfigCallCount += 1 - createdChallengeId = challengeIdArg - createdConfigs = aiConfigsArg - } + applyDefaultAICallCount += 1; + return aiReviewConfigs; + }; + challengeHelper.createAIReviewConfigsForChallengeCreation = async ( + challengeIdArg, + aiConfigsArg, + ) => { + createAIConfigCallCount += 1; + createdChallengeId = challengeIdArg; + createdConfigs = aiConfigsArg; + }; try { const result = await service.createChallenge( - { isMachine: true, sub: 'sub', userId: 'testuser' }, + { isMachine: true, sub: "sub", userId: "testuser" }, challengeData, - config.M2M_FULL_ACCESS_TOKEN || 'test-token' - ) - tempChallengeId = result.id - - should.equal(applyDefaultAICallCount, 1) - should.equal(createAIConfigCallCount, 1) - should.equal(createdChallengeId, result.id) - createdConfigs.should.deep.equal(aiReviewConfigs) + config.M2M_FULL_ACCESS_TOKEN || "test-token", + ); + tempChallengeId = result.id; + + should.equal(applyDefaultAICallCount, 1); + should.equal(createAIConfigCallCount, 1); + should.equal(createdChallengeId, result.id); + createdConfigs.should.deep.equal(aiReviewConfigs); } finally { - projectHelper.getProject = originalGetProject - challengeHelper.applyDefaultMemberReviewersForChallengeCreation = originalApplyDefaultMemberReviewers - challengeHelper.applyDefaultAIConfigForChallengeCreation = originalApplyDefaultAIConfig - challengeHelper.createAIReviewConfigsForChallengeCreation = originalCreateAIReviewConfigs + projectHelper.getProject = originalGetProject; + challengeHelper.applyDefaultMemberReviewersForChallengeCreation = + originalApplyDefaultMemberReviewers; + challengeHelper.applyDefaultAIConfigForChallengeCreation = originalApplyDefaultAIConfig; + challengeHelper.createAIReviewConfigsForChallengeCreation = originalCreateAIReviewConfigs; if (tempChallengeId) { - await prisma.challenge.delete({ where: { id: tempChallengeId } }) + await prisma.challenge.delete({ where: { id: tempChallengeId } }); } } - }).timeout(10000) - - it('create challenge skips default ai configs when reviewers are provided', async () => { - const challengeData = _.cloneDeep(testChallengeData) - challengeData.discussions[0].type = 'CHALLENGE' - challengeData.prizeSets[0].type = 'PLACEMENT' - challengeData.status = 'NEW' - const originalGetProject = projectHelper.getProject - challengeData.reviewers = [{ - scorecardId: 'provided-scorecard', - isMemberReview: false, - phaseId: data.phase.id, - aiWorkflowId: 'wf-provided' - }] - - const originalApplyDefaultAIConfig = challengeHelper.applyDefaultAIConfigForChallengeCreation - const originalCreateAIReviewConfigs = challengeHelper.createAIReviewConfigsForChallengeCreation - let applyDefaultAICalled = false - let createAIConfigCalled = false - let tempChallengeId - - projectHelper.getProject = async () => ({ directProjectId: '33541' }) + }).timeout(10000); + + it("create challenge skips default ai configs when reviewers are provided", async () => { + const challengeData = _.cloneDeep(testChallengeData); + challengeData.discussions[0].type = "CHALLENGE"; + challengeData.prizeSets[0].type = "PLACEMENT"; + challengeData.status = "NEW"; + const originalGetProject = projectHelper.getProject; + challengeData.reviewers = [ + { + scorecardId: "provided-scorecard", + isMemberReview: false, + phaseId: data.phase.id, + aiWorkflowId: "wf-provided", + }, + ]; + + const originalApplyDefaultAIConfig = challengeHelper.applyDefaultAIConfigForChallengeCreation; + const originalCreateAIReviewConfigs = + challengeHelper.createAIReviewConfigsForChallengeCreation; + let applyDefaultAICalled = false; + let createAIConfigCalled = false; + let tempChallengeId; + + projectHelper.getProject = async () => ({ directProjectId: "33541" }); challengeHelper.applyDefaultAIConfigForChallengeCreation = async () => { - applyDefaultAICalled = true - return [] - } + applyDefaultAICalled = true; + return []; + }; challengeHelper.createAIReviewConfigsForChallengeCreation = async () => { - createAIConfigCalled = true - } + createAIConfigCalled = true; + }; try { const result = await service.createChallenge( - { isMachine: true, sub: 'sub', userId: 'testuser' }, + { isMachine: true, sub: "sub", userId: "testuser" }, challengeData, - config.M2M_FULL_ACCESS_TOKEN || 'test-token' - ) - tempChallengeId = result.id + config.M2M_FULL_ACCESS_TOKEN || "test-token", + ); + tempChallengeId = result.id; - should.equal(applyDefaultAICalled, false) - should.equal(createAIConfigCalled, false) + should.equal(applyDefaultAICalled, false); + should.equal(createAIConfigCalled, false); } finally { - projectHelper.getProject = originalGetProject - challengeHelper.applyDefaultAIConfigForChallengeCreation = originalApplyDefaultAIConfig - challengeHelper.createAIReviewConfigsForChallengeCreation = originalCreateAIReviewConfigs + projectHelper.getProject = originalGetProject; + challengeHelper.applyDefaultAIConfigForChallengeCreation = originalApplyDefaultAIConfig; + challengeHelper.createAIReviewConfigsForChallengeCreation = originalCreateAIReviewConfigs; if (tempChallengeId) { - await prisma.challenge.delete({ where: { id: tempChallengeId } }) + await prisma.challenge.delete({ where: { id: tempChallengeId } }); } } - }).timeout(10000) + }).timeout(10000); - it('create challenge - type not found', async () => { - const challengeData = _.clone(testChallengeData) - challengeData.typeId = notFoundId + it("create challenge - type not found", async () => { + const challengeData = _.clone(testChallengeData); + challengeData.typeId = notFoundId; try { - await service.createChallenge({ isMachine: true, sub: 'sub', userId: 'testuser' }, challengeData, config.M2M_FULL_ACCESS_TOKEN) + await service.createChallenge( + { isMachine: true, sub: "sub", userId: "testuser" }, + challengeData, + config.M2M_FULL_ACCESS_TOKEN, + ); } catch (e) { - should.equal(e.message, `ChallengeType with id: ${notFoundId} doesn't exist`) - return + should.equal(e.message, `ChallengeType with id: ${notFoundId} doesn't exist`); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('create challenge - invalid projectId', async () => { - const challengeData = _.clone(testChallengeData) - challengeData.projectId = -1 + it("create challenge - invalid projectId", async () => { + const challengeData = _.clone(testChallengeData); + challengeData.projectId = -1; try { - await service.createChallenge({ isMachine: true, sub: 'sub', userId: 'testuser' }, challengeData, config.M2M_FULL_ACCESS_TOKEN) + await service.createChallenge( + { isMachine: true, sub: "sub", userId: "testuser" }, + challengeData, + config.M2M_FULL_ACCESS_TOKEN, + ); } catch (e) { - should.equal(e.message.indexOf('"projectId" must be a positive number') >= 0, true) - return + should.equal(e.message.indexOf('"projectId" must be a positive number') >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('create challenge - missing name', async () => { - const challengeData = _.clone(testChallengeData) - delete challengeData.name + it("create challenge - missing name", async () => { + const challengeData = _.clone(testChallengeData); + delete challengeData.name; try { - await service.createChallenge({ isMachine: true, sub: 'sub', userId: 'testuser' }, challengeData, config.M2M_FULL_ACCESS_TOKEN) + await service.createChallenge( + { isMachine: true, sub: "sub", userId: "testuser" }, + challengeData, + config.M2M_FULL_ACCESS_TOKEN, + ); } catch (e) { - should.equal(e.message.indexOf('"name" is required') >= 0, true) - return + should.equal(e.message.indexOf('"name" is required') >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('create challenge - invalid date', async () => { - const challengeData = _.clone(testChallengeData) - challengeData.startDate = 'abc' + it("create challenge - invalid date", async () => { + const challengeData = _.clone(testChallengeData); + challengeData.startDate = "abc"; try { - await service.createChallenge({ isMachine: true, sub: 'sub', userId: 'testuser' }, challengeData, config.M2M_FULL_ACCESS_TOKEN) + await service.createChallenge( + { isMachine: true, sub: "sub", userId: "testuser" }, + challengeData, + config.M2M_FULL_ACCESS_TOKEN, + ); } catch (e) { - should.equal(e.message.indexOf('"startDate" must be a valid ISO 8601 date') >= 0, true) - return + should.equal(e.message.indexOf('"startDate" must be a valid ISO 8601 date') >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('create challenge - invalid status', async () => { - const challengeData = _.clone(testChallengeData) - challengeData.status = ['ACTIVE'] + it("create challenge - invalid status", async () => { + const challengeData = _.clone(testChallengeData); + challengeData.status = ["ACTIVE"]; try { - await service.createChallenge({ isMachine: true, sub: 'sub', userId: 'testuser' }, challengeData, config.M2M_FULL_ACCESS_TOKEN) + await service.createChallenge( + { isMachine: true, sub: "sub", userId: "testuser" }, + challengeData, + config.M2M_FULL_ACCESS_TOKEN, + ); } catch (e) { - should.equal(e.message.indexOf('"status" must be a string') >= 0, true) - return + should.equal(e.message.indexOf('"status" must be a string') >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('create challenge - unexpected field', async () => { - const challengeData = _.clone(testChallengeData) - challengeData.other = 123 + it("create challenge - unexpected field", async () => { + const challengeData = _.clone(testChallengeData); + challengeData.other = 123; try { - await service.createChallenge({ isMachine: true, sub: 'sub', userId: 'testuser' }, challengeData, config.M2M_FULL_ACCESS_TOKEN) + await service.createChallenge( + { isMachine: true, sub: "sub", userId: "testuser" }, + challengeData, + config.M2M_FULL_ACCESS_TOKEN, + ); } catch (e) { - should.equal(e.message.indexOf('"other" is not allowed') >= 0, true) - return - } - throw new Error('should not reach here') - }) - }) - - describe('get challenge tests', () => { - it('get challenge successfully', async () => { - const result = await service.getChallenge({ isMachine: true }, createdChallengeData.id) - should.equal(result.id, createdChallengeData.id) - should.equal(result.typeId, testChallengeData.typeId) - should.equal(result.trackId, testChallengeData.trackId) - should.equal(result.name, testChallengeData.name) - should.equal(result.description, testChallengeData.description) - should.equal(result.timelineTemplateId, testChallengeData.timelineTemplateId) - should.equal(result.phases.length, 2) - should.equal(result.phases[0].phaseId, data.phase.id) - should.equal(result.phases[0].name, data.phase.name) - should.equal(result.phases[0].description, data.phase.description) - should.equal(result.phases[0].isOpen, false) - should.equal(result.phases[0].duration, testChallengeData.phases[0].duration) - should.equal(result.phases[1].phaseId, data.phase2.id) - should.equal(result.phases[1].name, data.phase2.name) - should.equal(result.phases[1].predecessor, data.phase.id) - should.equal(result.phases[1].description, data.phase2.description) - should.equal(result.phases[1].isOpen, false) - should.equal(result.phases[1].duration, testChallengeData.phases[1].duration) - should.equal(result.prizeSets.length, 1) - should.equal(result.prizeSets[0].type, testChallengeData.prizeSets[0].type) - should.equal(result.prizeSets[0].description, testChallengeData.prizeSets[0].description) - should.equal(result.prizeSets[0].prizes.length, 1) - should.equal(result.prizeSets[0].prizes[0].description, testChallengeData.prizeSets[0].prizes[0].description) - should.equal(result.prizeSets[0].prizes[0].type, testChallengeData.prizeSets[0].prizes[0].type) - should.equal(result.prizeSets[0].prizes[0].value, testChallengeData.prizeSets[0].prizes[0].value) - should.equal(result.reviewType, testChallengeData.reviewType) - should.equal(result.tags.length, 2) - should.equal(result.tags[0], testChallengeData.tags[0]) - should.equal(result.tags[1], testChallengeData.tags[1]) - should.equal(result.projectId, testChallengeData.projectId) - should.equal(result.legacyId, testChallengeData.legacyId) - should.equal(result.forumId, testChallengeData.forumId) - should.equal(result.status, testChallengeData.status) - should.equal(result.createdBy, 'testuser') - should.exist(result.startDate) - should.exist(result.created) - should.equal(result.numOfSubmissions, 0) - should.equal(result.numOfRegistrants, 0) - }) - - it('get challenge preserves billing for project write users', async () => { - const originalUserHasProjectWriteAccess = helper.userHasProjectWriteAccess - - helper.userHasProjectWriteAccess = async () => true + should.equal(e.message.indexOf('"other" is not allowed') >= 0, true); + return; + } + throw new Error("should not reach here"); + }); + }); + + describe("get challenge tests", () => { + it("get challenge successfully", async () => { + const result = await service.getChallenge({ isMachine: true }, createdChallengeData.id); + should.equal(result.id, createdChallengeData.id); + should.equal(result.typeId, testChallengeData.typeId); + should.equal(result.trackId, testChallengeData.trackId); + should.equal(result.name, testChallengeData.name); + should.equal(result.description, testChallengeData.description); + should.equal(result.timelineTemplateId, testChallengeData.timelineTemplateId); + should.equal(result.phases.length, 2); + should.equal(result.phases[0].phaseId, data.phase.id); + should.equal(result.phases[0].name, data.phase.name); + should.equal(result.phases[0].description, data.phase.description); + should.equal(result.phases[0].isOpen, false); + should.equal(result.phases[0].duration, testChallengeData.phases[0].duration); + should.equal(result.phases[1].phaseId, data.phase2.id); + should.equal(result.phases[1].name, data.phase2.name); + should.equal(result.phases[1].predecessor, data.phase.id); + should.equal(result.phases[1].description, data.phase2.description); + should.equal(result.phases[1].isOpen, false); + should.equal(result.phases[1].duration, testChallengeData.phases[1].duration); + should.equal(result.prizeSets.length, 1); + should.equal(result.prizeSets[0].type, testChallengeData.prizeSets[0].type); + should.equal(result.prizeSets[0].description, testChallengeData.prizeSets[0].description); + should.equal(result.prizeSets[0].prizes.length, 1); + should.equal( + result.prizeSets[0].prizes[0].description, + testChallengeData.prizeSets[0].prizes[0].description, + ); + should.equal( + result.prizeSets[0].prizes[0].type, + testChallengeData.prizeSets[0].prizes[0].type, + ); + should.equal( + result.prizeSets[0].prizes[0].value, + testChallengeData.prizeSets[0].prizes[0].value, + ); + should.equal(result.reviewType, testChallengeData.reviewType); + should.equal(result.tags.length, 2); + should.equal(result.tags[0], testChallengeData.tags[0]); + should.equal(result.tags[1], testChallengeData.tags[1]); + should.equal(result.projectId, testChallengeData.projectId); + should.equal(result.legacyId, testChallengeData.legacyId); + should.equal(result.forumId, testChallengeData.forumId); + should.equal(result.status, testChallengeData.status); + should.equal(result.createdBy, "testuser"); + should.exist(result.startDate); + should.exist(result.created); + should.equal(result.numOfSubmissions, 0); + should.equal(result.numOfRegistrants, 0); + }); + + it("get challenge preserves billing for project write users", async () => { + const originalUserHasProjectWriteAccess = helper.userHasProjectWriteAccess; + + helper.userHasProjectWriteAccess = async () => true; try { const result = await service.getChallenge( - { handle: 'writer', userId: 'testuser' }, - createdChallengeData.id - ) + { handle: "writer", userId: "testuser" }, + createdChallengeData.id, + ); - should.deepEqual(result.billing, createdChallengeData.billing) + should.deepEqual(result.billing, createdChallengeData.billing); } finally { - helper.userHasProjectWriteAccess = originalUserHasProjectWriteAccess + helper.userHasProjectWriteAccess = originalUserHasProjectWriteAccess; } - }) + }); - it('get challenge hides billing for users without project write access', async () => { - const originalUserHasProjectWriteAccess = helper.userHasProjectWriteAccess - const originalListResourcesByMemberAndChallenge = helper.listResourcesByMemberAndChallenge + it("get challenge hides billing for users without project write access", async () => { + const originalUserHasProjectWriteAccess = helper.userHasProjectWriteAccess; + const originalListResourcesByMemberAndChallenge = helper.listResourcesByMemberAndChallenge; - helper.userHasProjectWriteAccess = async () => false - helper.listResourcesByMemberAndChallenge = async () => [] + helper.userHasProjectWriteAccess = async () => false; + helper.listResourcesByMemberAndChallenge = async () => []; try { const result = await service.getChallenge( - { handle: 'viewer', userId: 'testuser' }, - createdChallengeData.id - ) + { handle: "viewer", userId: "testuser" }, + createdChallengeData.id, + ); - should.equal(_.isUndefined(result.billing), true) + should.equal(_.isUndefined(result.billing), true); } finally { - helper.userHasProjectWriteAccess = originalUserHasProjectWriteAccess - helper.listResourcesByMemberAndChallenge = originalListResourcesByMemberAndChallenge + helper.userHasProjectWriteAccess = originalUserHasProjectWriteAccess; + helper.listResourcesByMemberAndChallenge = originalListResourcesByMemberAndChallenge; } - }) + }); - it('get challenge - not found', async () => { + it("get challenge - not found", async () => { try { - await service.getChallenge({ isMachine: true }, notFoundId) + await service.getChallenge({ isMachine: true }, notFoundId); } catch (e) { - should.equal(e.message, `Challenge of id ${notFoundId} is not found.`) - return + should.equal(e.message, `Challenge of id ${notFoundId} is not found.`); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('get challenge - invalid id', async () => { + it("get challenge - invalid id", async () => { try { - await service.getChallenge({ isMachine: true }, 'invalid') + await service.getChallenge({ isMachine: true }, "invalid"); } catch (e) { - should.equal(e.message.indexOf('"id" must be a valid GUID') >= 0, true) - return + should.equal(e.message.indexOf('"id" must be a valid GUID') >= 0, true); + return; } - throw new Error('should not reach here') - }) - }) - - describe('search challenges tests', () => { - it('search challenges successfully by legacyId', async() => { - const res = await service.searchChallenges({ isMachine: true }, { - page: 1, - perPage: 10, - legacyId: testChallengeData.legacyId - }) - should.equal(res.total, 1) - should.equal(res.page, 1) - should.equal(res.perPage, 10) - should.equal(res.result.length, 1) - const result = res.result[0] - should.equal(result.id, id) - should.equal(result.type, data.challengeType.name) - should.equal(result.track, data.challengeTrack.name) - should.equal(result.name, testChallengeData.name) - should.equal(result.description, testChallengeData.description) - should.equal(result.timelineTemplateId, testChallengeData.timelineTemplateId) - should.equal(result.phases.length, 2) - should.equal(result.phases[0].phaseId, data.phase.id) - should.equal(result.phases[0].name, data.phase.name) - should.equal(result.phases[0].description, data.phase.description) - should.equal(result.phases[0].isOpen, false) - should.equal(result.phases[0].duration, testChallengeData.phases[0].duration) - should.equal(result.phases[1].phaseId, data.phase2.id) - should.equal(result.phases[1].name, data.phase2.name) - should.equal(result.phases[1].predecessor, data.phase.id) - should.equal(result.phases[1].description, data.phase2.description) - should.equal(result.phases[1].isOpen, false) - should.equal(result.phases[1].duration, testChallengeData.phases[1].duration) - should.equal(result.prizeSets.length, 1) - should.equal(result.prizeSets[0].type, testChallengeData.prizeSets[0].type) - should.equal(result.prizeSets[0].description, testChallengeData.prizeSets[0].description) - should.equal(result.prizeSets[0].prizes.length, 1) - should.equal(result.prizeSets[0].prizes[0].description, testChallengeData.prizeSets[0].prizes[0].description) - should.equal(result.prizeSets[0].prizes[0].type, testChallengeData.prizeSets[0].prizes[0].type) - should.equal(result.prizeSets[0].prizes[0].value, testChallengeData.prizeSets[0].prizes[0].value) - should.equal(result.reviewType, testChallengeData.reviewType) - should.equal(result.tags.length, 2) - should.equal(result.tags[0], testChallengeData.tags[0]) - should.equal(result.tags[1], testChallengeData.tags[1]) - should.equal(result.projectId, testChallengeData.projectId) - should.equal(result.legacyId, testChallengeData.legacyId) - should.equal(result.forumId, testChallengeData.forumId) - should.equal(result.status, testChallengeData.status) - should.equal(result.createdBy, 'testuser') - should.exist(result.startDate) - should.exist(result.created) - should.equal(result.numOfSubmissions, 0) - should.equal(result.numOfRegistrants, 0) - }) - - it('search challenges sorts status alphabetically for member and non-member searches', async () => { + throw new Error("should not reach here"); + }); + }); + + describe("search challenges tests", () => { + it("search challenges successfully by legacyId", async () => { + const res = await service.searchChallenges( + { isMachine: true }, + { + page: 1, + perPage: 10, + legacyId: testChallengeData.legacyId, + }, + ); + should.equal(res.total, 1); + should.equal(res.page, 1); + should.equal(res.perPage, 10); + should.equal(res.result.length, 1); + const result = res.result[0]; + should.equal(result.id, id); + should.equal(result.type, data.challengeType.name); + should.equal(result.track, data.challengeTrack.name); + should.equal(result.name, testChallengeData.name); + should.equal(result.description, testChallengeData.description); + should.equal(result.timelineTemplateId, testChallengeData.timelineTemplateId); + should.equal(result.phases.length, 2); + should.equal(result.phases[0].phaseId, data.phase.id); + should.equal(result.phases[0].name, data.phase.name); + should.equal(result.phases[0].description, data.phase.description); + should.equal(result.phases[0].isOpen, false); + should.equal(result.phases[0].duration, testChallengeData.phases[0].duration); + should.equal(result.phases[1].phaseId, data.phase2.id); + should.equal(result.phases[1].name, data.phase2.name); + should.equal(result.phases[1].predecessor, data.phase.id); + should.equal(result.phases[1].description, data.phase2.description); + should.equal(result.phases[1].isOpen, false); + should.equal(result.phases[1].duration, testChallengeData.phases[1].duration); + should.equal(result.prizeSets.length, 1); + should.equal(result.prizeSets[0].type, testChallengeData.prizeSets[0].type); + should.equal(result.prizeSets[0].description, testChallengeData.prizeSets[0].description); + should.equal(result.prizeSets[0].prizes.length, 1); + should.equal( + result.prizeSets[0].prizes[0].description, + testChallengeData.prizeSets[0].prizes[0].description, + ); + should.equal( + result.prizeSets[0].prizes[0].type, + testChallengeData.prizeSets[0].prizes[0].type, + ); + should.equal( + result.prizeSets[0].prizes[0].value, + testChallengeData.prizeSets[0].prizes[0].value, + ); + should.equal(result.reviewType, testChallengeData.reviewType); + should.equal(result.tags.length, 2); + should.equal(result.tags[0], testChallengeData.tags[0]); + should.equal(result.tags[1], testChallengeData.tags[1]); + should.equal(result.projectId, testChallengeData.projectId); + should.equal(result.legacyId, testChallengeData.legacyId); + should.equal(result.forumId, testChallengeData.forumId); + should.equal(result.status, testChallengeData.status); + should.equal(result.createdBy, "testuser"); + should.exist(result.startDate); + should.exist(result.created); + should.equal(result.numOfSubmissions, 0); + should.equal(result.numOfRegistrants, 0); + }); + + it("search challenges sorts status alphabetically for member and non-member searches", async () => { const statusChallenges = [ { id: uuid(), name: `Status Sort Cancelled ${Date.now()}`, - status: ChallengeStatusEnum.CANCELLED_CLIENT_REQUEST + status: ChallengeStatusEnum.CANCELLED_CLIENT_REQUEST, }, { id: uuid(), name: `Status Sort New ${Date.now()}`, - status: ChallengeStatusEnum.NEW + status: ChallengeStatusEnum.NEW, }, { id: uuid(), name: `Status Sort Active ${Date.now()}`, - status: ChallengeStatusEnum.ACTIVE + status: ChallengeStatusEnum.ACTIVE, }, { id: uuid(), name: `Status Sort Completed ${Date.now()}`, - status: ChallengeStatusEnum.COMPLETED - } - ] - const statusChallengeIds = statusChallenges.map(challengeRow => challengeRow.id) - const originalMemberChallengeAccessFindMany = prisma.memberChallengeAccess.findMany + status: ChallengeStatusEnum.COMPLETED, + }, + ]; + const statusChallengeIds = statusChallenges.map((challengeRow) => challengeRow.id); + const originalMemberChallengeAccessFindMany = prisma.memberChallengeAccess.findMany; try { - await Promise.all(statusChallenges.map(challengeRow => prisma.challenge.create({ - data: { - id: challengeRow.id, - name: challengeRow.name, - description: 'status-sort', - privateDescription: 'status-sort', - challengeSource: 'Topcoder', - descriptionFormat: 'html', - timelineTemplate: { connect: { id: data.timelineTemplate.id } }, - type: { connect: { id: data.challenge.typeId } }, - track: { connect: { id: data.challenge.trackId } }, - tags: [], - groups: [], - status: challengeRow.status, - createdBy: 'testuser', - updatedBy: 'testuser' - } - }))) + await Promise.all( + statusChallenges.map((challengeRow) => + prisma.challenge.create({ + data: { + id: challengeRow.id, + name: challengeRow.name, + description: "status-sort", + privateDescription: "status-sort", + challengeSource: "Topcoder", + descriptionFormat: "html", + timelineTemplate: { connect: { id: data.timelineTemplate.id } }, + type: { connect: { id: data.challenge.typeId } }, + track: { connect: { id: data.challenge.trackId } }, + tags: [], + groups: [], + status: challengeRow.status, + createdBy: "testuser", + updatedBy: "testuser", + }, + }), + ), + ); prisma.memberChallengeAccess.findMany = async () => - statusChallenges.map(challengeRow => ({ challengeId: challengeRow.id })) + statusChallenges.map((challengeRow) => ({ challengeId: challengeRow.id })); - const ascRes = await service.searchChallenges({ isMachine: true }, { - memberId: 'status-sort-member', - sortBy: 'status', - sortOrder: 'asc', - page: 1, - perPage: 10 - }) - should.deepEqual(_.map(ascRes.result, 'status'), [ + const ascRes = await service.searchChallenges( + { isMachine: true }, + { + memberId: "status-sort-member", + sortBy: "status", + sortOrder: "asc", + page: 1, + perPage: 10, + }, + ); + should.deepEqual(_.map(ascRes.result, "status"), [ ChallengeStatusEnum.ACTIVE, ChallengeStatusEnum.CANCELLED_CLIENT_REQUEST, ChallengeStatusEnum.COMPLETED, - ChallengeStatusEnum.NEW - ]) + ChallengeStatusEnum.NEW, + ]); - const ascResNoMember = await service.searchChallenges({ isMachine: true }, { - ids: statusChallengeIds, - sortBy: 'status', - sortOrder: 'asc', - page: 1, - perPage: 10 - }) - should.deepEqual(_.map(ascResNoMember.result, 'status'), [ + const ascResNoMember = await service.searchChallenges( + { isMachine: true }, + { + ids: statusChallengeIds, + sortBy: "status", + sortOrder: "asc", + page: 1, + perPage: 10, + }, + ); + should.deepEqual(_.map(ascResNoMember.result, "status"), [ ChallengeStatusEnum.ACTIVE, ChallengeStatusEnum.CANCELLED_CLIENT_REQUEST, ChallengeStatusEnum.COMPLETED, - ChallengeStatusEnum.NEW - ]) + ChallengeStatusEnum.NEW, + ]); - const descRes = await service.searchChallenges({ isMachine: true }, { - memberId: 'status-sort-member', - sortBy: 'status', - sortOrder: 'desc', - page: 1, - perPage: 10 - }) - should.deepEqual(_.map(descRes.result, 'status'), [ + const descRes = await service.searchChallenges( + { isMachine: true }, + { + memberId: "status-sort-member", + sortBy: "status", + sortOrder: "desc", + page: 1, + perPage: 10, + }, + ); + should.deepEqual(_.map(descRes.result, "status"), [ ChallengeStatusEnum.NEW, ChallengeStatusEnum.COMPLETED, ChallengeStatusEnum.CANCELLED_CLIENT_REQUEST, ChallengeStatusEnum.ACTIVE, - ]) + ]); - const descResNoMember = await service.searchChallenges({ isMachine: true }, { - ids: statusChallengeIds, - sortBy: 'status', - sortOrder: 'desc', - page: 1, - perPage: 10 - }) - should.deepEqual(_.map(descResNoMember.result, 'status'), [ + const descResNoMember = await service.searchChallenges( + { isMachine: true }, + { + ids: statusChallengeIds, + sortBy: "status", + sortOrder: "desc", + page: 1, + perPage: 10, + }, + ); + should.deepEqual(_.map(descResNoMember.result, "status"), [ ChallengeStatusEnum.NEW, ChallengeStatusEnum.COMPLETED, ChallengeStatusEnum.CANCELLED_CLIENT_REQUEST, - ChallengeStatusEnum.ACTIVE - ]) + ChallengeStatusEnum.ACTIVE, + ]); } finally { - prisma.memberChallengeAccess.findMany = originalMemberChallengeAccessFindMany + prisma.memberChallengeAccess.findMany = originalMemberChallengeAccessFindMany; await prisma.challenge.deleteMany({ where: { id: { - in: statusChallengeIds - } - } - }) + in: statusChallengeIds, + }, + }, + }); } - }) - - it('search challenges successfully 1', async () => { - const res = await service.searchChallenges({ isMachine: true }, { - page: 1, - perPage: 10, - id: id, - - typeId: testChallengeData.typeId, - name: testChallengeData.name.substring(2).trim(), - description: testChallengeData.description, - timelineTemplateId: testChallengeData.timelineTemplateId, - tag: testChallengeData.tags[0], - projectId: testChallengeData.projectId, - status: testChallengeData.status, - createdDateStart: '1992-01-02', - createdDateEnd: '2032-01-02', - createdBy: testChallengeData.createdBy - }) - should.equal(res.total, 1) - should.equal(res.page, 1) - should.equal(res.perPage, 10) - should.equal(res.result.length, 1) - const result = res.result[0] - should.equal(result.id, id) - should.equal(result.type, data.challengeType.name) - should.equal(result.track, data.challengeTrack.name) - should.equal(result.name, testChallengeData.name) - should.equal(result.description, testChallengeData.description) - should.equal(result.timelineTemplateId, testChallengeData.timelineTemplateId) - should.equal(result.phases.length, 2) - should.equal(result.phases[0].phaseId, data.phase.id) - should.equal(result.phases[0].name, data.phase.name) - should.equal(result.phases[0].description, data.phase.description) - should.equal(result.phases[0].isOpen, false) - should.equal(result.phases[0].duration, testChallengeData.phases[0].duration) - should.equal(result.phases[1].phaseId, data.phase2.id) - should.equal(result.phases[1].name, data.phase2.name) - should.equal(result.phases[1].predecessor, data.phase.id) - should.equal(result.phases[1].description, data.phase2.description) - should.equal(result.phases[1].isOpen, false) - should.equal(result.phases[1].duration, testChallengeData.phases[1].duration) - should.equal(result.prizeSets.length, 1) - should.equal(result.prizeSets[0].type, testChallengeData.prizeSets[0].type) - should.equal(result.prizeSets[0].description, testChallengeData.prizeSets[0].description) - should.equal(result.prizeSets[0].prizes.length, 1) - should.equal(result.prizeSets[0].prizes[0].description, testChallengeData.prizeSets[0].prizes[0].description) - should.equal(result.prizeSets[0].prizes[0].type, testChallengeData.prizeSets[0].prizes[0].type) - should.equal(result.prizeSets[0].prizes[0].value, testChallengeData.prizeSets[0].prizes[0].value) - should.equal(result.reviewType, testChallengeData.reviewType) - should.equal(result.tags.length, 2) - should.equal(result.tags[0], testChallengeData.tags[0]) - should.equal(result.tags[1], testChallengeData.tags[1]) - should.equal(result.projectId, testChallengeData.projectId) - should.equal(result.legacyId, testChallengeData.legacyId) - should.equal(result.forumId, testChallengeData.forumId) - should.equal(result.status, testChallengeData.status) - should.equal(result.createdBy, 'testuser') - should.exist(result.startDate) - should.exist(result.created) - should.equal(result.numOfSubmissions, 0) - should.equal(result.numOfRegistrants, 0) - }) - - it('search challenges successfully 2', async () => { - const result = await service.searchChallenges({ isMachine: true }, { name: 'aaa bbb ccc' }) - should.equal(result.total, 0) - should.equal(result.page, 1) - should.equal(result.perPage, 20) - should.equal(result.result.length, 0) - }) - - it('search challenges successfully 3', async () => { - const res = await service.searchChallenges({ isMachine: true }, { - page: 1, - perPage: 10, - id: data.challenge.id, - typeId: data.challenge.typeId, - track: data.challenge.track, - name: data.challenge.name.substring(2).trim().toUpperCase(), - description: data.challenge.description, - timelineTemplateId: data.challenge.timelineTemplateId, - reviewType: data.challenge.reviewType, - tag: data.challenge.tags[0], - projectId: data.challenge.projectId, - forumId: data.challenge.forumId, - status: _.capitalize(data.challenge.status.toLowerCase()), - createdDateStart: '1992-01-02', - createdDateEnd: '2022-01-02', - createdBy: data.challenge.createdBy, - memberId: '23124329' - }) - should.equal(res.total, 0) - should.equal(res.page, 1) - should.equal(res.perPage, 10) - should.equal(res.result.length, 0) - }) - - it('search challenges successfully 4 - with terms', async () => { - const res = await service.searchChallenges({ isMachine: true }, { - page: 1, - perPage: 10, - id - }) - const challengeData = _.cloneDeep(testChallengeData) - should.equal(res.total, 1) - should.equal(res.page, 1) - should.equal(res.perPage, 10) - should.equal(res.result.length, 1) - const result = res.result[0] - - should.equal(result.type, data.challengeType.name) - should.equal(result.track, data.challengeTrack.name) - should.equal(result.name, challengeData.name) - should.equal(result.description, challengeData.description) - should.equal(result.timelineTemplateId, challengeData.timelineTemplateId) - should.equal(result.phases.length, 2) - should.equal(result.phases[0].phaseId, data.phase.id) - should.equal(result.phases[0].name, data.phase.name) - should.equal(result.phases[0].description, data.phase.description) - should.equal(result.phases[0].isOpen, false) - should.equal(result.phases[0].duration, challengeData.phases[0].duration) - should.equal(result.phases[1].phaseId, data.phase2.id) - should.equal(result.phases[1].name, data.phase2.name) - should.equal(result.phases[1].predecessor, data.phase.id) - should.equal(result.phases[1].description, data.phase2.description) - should.equal(result.phases[1].isOpen, false) - should.equal(result.phases[1].duration, challengeData.phases[1].duration) - should.equal(result.prizeSets.length, 1) - should.equal(result.prizeSets[0].type, challengeData.prizeSets[0].type) - should.equal(result.prizeSets[0].description, challengeData.prizeSets[0].description) - should.equal(result.prizeSets[0].prizes.length, 1) - should.equal(result.prizeSets[0].prizes[0].description, challengeData.prizeSets[0].prizes[0].description) - should.equal(result.prizeSets[0].prizes[0].type, challengeData.prizeSets[0].prizes[0].type) - should.equal(result.prizeSets[0].prizes[0].value, challengeData.prizeSets[0].prizes[0].value) - should.equal(result.reviewType, challengeData.reviewType) - should.equal(result.tags.length, 2) - should.equal(result.tags[0], challengeData.tags[0]) - should.equal(result.projectId, challengeData.projectId) - should.equal(result.legacyId, challengeData.legacyId) - should.equal(result.forumId, challengeData.forumId) - should.equal(result.status, challengeData.status) - should.equal(result.createdBy, 'testuser') - should.exist(result.startDate) - should.exist(result.created) - should.equal(result.numOfSubmissions, 0) - should.equal(result.numOfRegistrants, 0) - }) - - it('search challenges successfully 5 - with tco eligible events', async () => { - const result = await service.searchChallenges({ isMachine: true }, { tco: true }) - should.equal(result.total, 0) - should.equal(result.page, 1) - should.equal(result.perPage, 20) - should.equal(result.result.length, 0) - }) - - it('search challenges - invalid name', async () => { + }); + + it("search challenges successfully 1", async () => { + const res = await service.searchChallenges( + { isMachine: true }, + { + page: 1, + perPage: 10, + id: id, + + typeId: testChallengeData.typeId, + name: testChallengeData.name.substring(2).trim(), + description: testChallengeData.description, + timelineTemplateId: testChallengeData.timelineTemplateId, + tag: testChallengeData.tags[0], + projectId: testChallengeData.projectId, + status: testChallengeData.status, + createdDateStart: "1992-01-02", + createdDateEnd: "2032-01-02", + createdBy: testChallengeData.createdBy, + }, + ); + should.equal(res.total, 1); + should.equal(res.page, 1); + should.equal(res.perPage, 10); + should.equal(res.result.length, 1); + const result = res.result[0]; + should.equal(result.id, id); + should.equal(result.type, data.challengeType.name); + should.equal(result.track, data.challengeTrack.name); + should.equal(result.name, testChallengeData.name); + should.equal(result.description, testChallengeData.description); + should.equal(result.timelineTemplateId, testChallengeData.timelineTemplateId); + should.equal(result.phases.length, 2); + should.equal(result.phases[0].phaseId, data.phase.id); + should.equal(result.phases[0].name, data.phase.name); + should.equal(result.phases[0].description, data.phase.description); + should.equal(result.phases[0].isOpen, false); + should.equal(result.phases[0].duration, testChallengeData.phases[0].duration); + should.equal(result.phases[1].phaseId, data.phase2.id); + should.equal(result.phases[1].name, data.phase2.name); + should.equal(result.phases[1].predecessor, data.phase.id); + should.equal(result.phases[1].description, data.phase2.description); + should.equal(result.phases[1].isOpen, false); + should.equal(result.phases[1].duration, testChallengeData.phases[1].duration); + should.equal(result.prizeSets.length, 1); + should.equal(result.prizeSets[0].type, testChallengeData.prizeSets[0].type); + should.equal(result.prizeSets[0].description, testChallengeData.prizeSets[0].description); + should.equal(result.prizeSets[0].prizes.length, 1); + should.equal( + result.prizeSets[0].prizes[0].description, + testChallengeData.prizeSets[0].prizes[0].description, + ); + should.equal( + result.prizeSets[0].prizes[0].type, + testChallengeData.prizeSets[0].prizes[0].type, + ); + should.equal( + result.prizeSets[0].prizes[0].value, + testChallengeData.prizeSets[0].prizes[0].value, + ); + should.equal(result.reviewType, testChallengeData.reviewType); + should.equal(result.tags.length, 2); + should.equal(result.tags[0], testChallengeData.tags[0]); + should.equal(result.tags[1], testChallengeData.tags[1]); + should.equal(result.projectId, testChallengeData.projectId); + should.equal(result.legacyId, testChallengeData.legacyId); + should.equal(result.forumId, testChallengeData.forumId); + should.equal(result.status, testChallengeData.status); + should.equal(result.createdBy, "testuser"); + should.exist(result.startDate); + should.exist(result.created); + should.equal(result.numOfSubmissions, 0); + should.equal(result.numOfRegistrants, 0); + }); + + it("search challenges successfully 2", async () => { + const result = await service.searchChallenges({ isMachine: true }, { name: "aaa bbb ccc" }); + should.equal(result.total, 0); + should.equal(result.page, 1); + should.equal(result.perPage, 20); + should.equal(result.result.length, 0); + }); + + it("search challenges successfully 3", async () => { + const res = await service.searchChallenges( + { isMachine: true }, + { + page: 1, + perPage: 10, + id: data.challenge.id, + typeId: data.challenge.typeId, + track: data.challenge.track, + name: data.challenge.name.substring(2).trim().toUpperCase(), + description: data.challenge.description, + timelineTemplateId: data.challenge.timelineTemplateId, + reviewType: data.challenge.reviewType, + tag: data.challenge.tags[0], + projectId: data.challenge.projectId, + forumId: data.challenge.forumId, + status: _.capitalize(data.challenge.status.toLowerCase()), + createdDateStart: "1992-01-02", + createdDateEnd: "2022-01-02", + createdBy: data.challenge.createdBy, + memberId: "23124329", + }, + ); + should.equal(res.total, 0); + should.equal(res.page, 1); + should.equal(res.perPage, 10); + should.equal(res.result.length, 0); + }); + + it("search challenges successfully 4 - with terms", async () => { + const res = await service.searchChallenges( + { isMachine: true }, + { + page: 1, + perPage: 10, + id, + }, + ); + const challengeData = _.cloneDeep(testChallengeData); + should.equal(res.total, 1); + should.equal(res.page, 1); + should.equal(res.perPage, 10); + should.equal(res.result.length, 1); + const result = res.result[0]; + + should.equal(result.type, data.challengeType.name); + should.equal(result.track, data.challengeTrack.name); + should.equal(result.name, challengeData.name); + should.equal(result.description, challengeData.description); + should.equal(result.timelineTemplateId, challengeData.timelineTemplateId); + should.equal(result.phases.length, 2); + should.equal(result.phases[0].phaseId, data.phase.id); + should.equal(result.phases[0].name, data.phase.name); + should.equal(result.phases[0].description, data.phase.description); + should.equal(result.phases[0].isOpen, false); + should.equal(result.phases[0].duration, challengeData.phases[0].duration); + should.equal(result.phases[1].phaseId, data.phase2.id); + should.equal(result.phases[1].name, data.phase2.name); + should.equal(result.phases[1].predecessor, data.phase.id); + should.equal(result.phases[1].description, data.phase2.description); + should.equal(result.phases[1].isOpen, false); + should.equal(result.phases[1].duration, challengeData.phases[1].duration); + should.equal(result.prizeSets.length, 1); + should.equal(result.prizeSets[0].type, challengeData.prizeSets[0].type); + should.equal(result.prizeSets[0].description, challengeData.prizeSets[0].description); + should.equal(result.prizeSets[0].prizes.length, 1); + should.equal( + result.prizeSets[0].prizes[0].description, + challengeData.prizeSets[0].prizes[0].description, + ); + should.equal(result.prizeSets[0].prizes[0].type, challengeData.prizeSets[0].prizes[0].type); + should.equal(result.prizeSets[0].prizes[0].value, challengeData.prizeSets[0].prizes[0].value); + should.equal(result.reviewType, challengeData.reviewType); + should.equal(result.tags.length, 2); + should.equal(result.tags[0], challengeData.tags[0]); + should.equal(result.projectId, challengeData.projectId); + should.equal(result.legacyId, challengeData.legacyId); + should.equal(result.forumId, challengeData.forumId); + should.equal(result.status, challengeData.status); + should.equal(result.createdBy, "testuser"); + should.exist(result.startDate); + should.exist(result.created); + should.equal(result.numOfSubmissions, 0); + should.equal(result.numOfRegistrants, 0); + }); + + it("search challenges successfully 5 - with tco eligible events", async () => { + const result = await service.searchChallenges({ isMachine: true }, { tco: true }); + should.equal(result.total, 0); + should.equal(result.page, 1); + should.equal(result.perPage, 20); + should.equal(result.result.length, 0); + }); + + it("search challenges - invalid name", async () => { try { - await service.searchChallenges({ isMachine: true }, { name: ['invalid'] }) + await service.searchChallenges({ isMachine: true }, { name: ["invalid"] }); } catch (e) { - should.equal(e.message.indexOf('"name" must be a string') >= 0, true) - return + should.equal(e.message.indexOf('"name" must be a string') >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('search challenges - invalid forumId', async () => { + it("search challenges - invalid forumId", async () => { try { - await service.searchChallenges({ isMachine: true }, { forumId: 'invalid' }) + await service.searchChallenges({ isMachine: true }, { forumId: "invalid" }); } catch (e) { - should.equal(e.message.indexOf('"forumId" must be a number') >= 0, true) - return + should.equal(e.message.indexOf('"forumId" must be a number') >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('search challenges - invalid page', async () => { + it("search challenges - invalid page", async () => { try { - await service.searchChallenges({ isMachine: true }, { page: -1 }) + await service.searchChallenges({ isMachine: true }, { page: -1 }); } catch (e) { - should.equal(e.message.indexOf('"page" must be larger than or equal to 1') >= 0, true) - return + should.equal(e.message.indexOf('"page" must be larger than or equal to 1') >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('search challenges - invalid perPage', async () => { + it("search challenges - invalid perPage", async () => { try { - await service.searchChallenges({ isMachine: true }, { perPage: -1 }) + await service.searchChallenges({ isMachine: true }, { perPage: -1 }); } catch (e) { - should.equal(e.message.indexOf('"perPage" must be larger than or equal to 1') >= 0, true) - return + should.equal(e.message.indexOf('"perPage" must be larger than or equal to 1') >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('search challenges - invalid name', async () => { + it("search challenges - invalid name", async () => { try { - await service.searchChallenges({ isMachine: true }, { name: ['abc'] }) + await service.searchChallenges({ isMachine: true }, { name: ["abc"] }); } catch (e) { - should.equal(e.message.indexOf('"name" must be a string') >= 0, true) - return + should.equal(e.message.indexOf('"name" must be a string') >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('search challenges - invalid track', async () => { + it("search challenges - invalid track", async () => { try { - await service.searchChallenges({ isMachine: true }, { track: ['abc'] }) + await service.searchChallenges({ isMachine: true }, { track: ["abc"] }); } catch (e) { - should.equal(e.message.indexOf('"track" must be a string') >= 0, true) - return + should.equal(e.message.indexOf('"track" must be a string') >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('search challenges - invalid description', async () => { + it("search challenges - invalid description", async () => { try { - await service.searchChallenges({ isMachine: true }, { description: ['abc'] }) + await service.searchChallenges({ isMachine: true }, { description: ["abc"] }); } catch (e) { - should.equal(e.message.indexOf('"description" must be a string') >= 0, true) - return + should.equal(e.message.indexOf('"description" must be a string') >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('search challenges - invalid reviewType', async () => { + it("search challenges - invalid reviewType", async () => { try { - await service.searchChallenges({ isMachine: true }, { reviewType: ['abc'] }) + await service.searchChallenges({ isMachine: true }, { reviewType: ["abc"] }); } catch (e) { - should.equal(e.message.indexOf('"reviewType" must be a string') >= 0, true) - return + should.equal(e.message.indexOf('"reviewType" must be a string') >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('search challenges - invalid tag', async () => { + it("search challenges - invalid tag", async () => { try { - await service.searchChallenges({ isMachine: true }, { tag: ['abc'] }) + await service.searchChallenges({ isMachine: true }, { tag: ["abc"] }); } catch (e) { - should.equal(e.message.indexOf('"tag" must be a string') >= 0, true) - return + should.equal(e.message.indexOf('"tag" must be a string') >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('search challenges - invalid group', async () => { + it("search challenges - invalid group", async () => { try { - await service.searchChallenges({ isMachine: true }, { group: ['abc'] }) + await service.searchChallenges({ isMachine: true }, { group: ["abc"] }); } catch (e) { - should.equal(e.message.indexOf('"group" must be a string') >= 0, true) - return + should.equal(e.message.indexOf('"group" must be a string') >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('search challenges - invalid createdBy', async () => { + it("search challenges - invalid createdBy", async () => { try { - await service.searchChallenges({ isMachine: true }, { createdBy: ['abc'] }) + await service.searchChallenges({ isMachine: true }, { createdBy: ["abc"] }); } catch (e) { - should.equal(e.message.indexOf('"createdBy" must be a string') >= 0, true) - return + should.equal(e.message.indexOf('"createdBy" must be a string') >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('search challenges - invalid updatedBy', async () => { + it("search challenges - invalid updatedBy", async () => { try { - await service.searchChallenges({ isMachine: true }, { updatedBy: ['abc'] }) + await service.searchChallenges({ isMachine: true }, { updatedBy: ["abc"] }); } catch (e) { - should.equal(e.message.indexOf('"updatedBy" must be a string') >= 0, true) - return + should.equal(e.message.indexOf('"updatedBy" must be a string') >= 0, true); + return; } - throw new Error('should not reach here') - }) - }) + throw new Error("should not reach here"); + }); + }); - describe('update challenge tests', () => { + describe("update challenge tests", () => { const ensureSkipTimelineTemplate = async () => { - const templateId = config.SKIP_PROJECT_ID_BY_TIMLINE_TEMPLATE_ID - let template = await prisma.timelineTemplate.findUnique({ where: { id: templateId } }) + const templateId = config.SKIP_PROJECT_ID_BY_TIMLINE_TEMPLATE_ID; + let template = await prisma.timelineTemplate.findUnique({ where: { id: templateId } }); if (!template) { template = await prisma.timelineTemplate.create({ data: { id: templateId, name: `skip-template-${templateId}`, - description: 'Template used to bypass project requirements in activation tests', + description: "Template used to bypass project requirements in activation tests", isActive: true, - createdBy: 'activation-test', - updatedBy: 'activation-test' - } - }) + createdBy: "activation-test", + updatedBy: "activation-test", + }, + }); } const existingPhases = await prisma.timelineTemplatePhase.findMany({ - where: { timelineTemplateId: templateId } - }) - const existingPhaseIds = new Set(existingPhases.map(p => p.phaseId)) - const phaseRows = [] + where: { timelineTemplateId: templateId }, + }); + const existingPhaseIds = new Set(existingPhases.map((p) => p.phaseId)); + const phaseRows = []; if (!existingPhaseIds.has(data.phase.id)) { phaseRows.push({ timelineTemplateId: templateId, phaseId: data.phase.id, defaultDuration: 1000, - createdBy: 'activation-test', - updatedBy: 'activation-test' - }) + createdBy: "activation-test", + updatedBy: "activation-test", + }); } if (!existingPhaseIds.has(data.phase2.id)) { phaseRows.push({ @@ -999,25 +1115,25 @@ describe('challenge service unit tests', () => { phaseId: data.phase2.id, predecessor: data.phase.id, defaultDuration: 1000, - createdBy: 'activation-test', - updatedBy: 'activation-test' - }) + createdBy: "activation-test", + updatedBy: "activation-test", + }); } if (phaseRows.length > 0) { - await prisma.timelineTemplatePhase.createMany({ data: phaseRows }) + await prisma.timelineTemplatePhase.createMany({ data: phaseRows }); } - return templateId - } + return templateId; + }; const createChallengeWithRequiredReviewPhases = async () => { - await ensureSkipTimelineTemplate() + await ensureSkipTimelineTemplate(); - const phaseNames = ['Screening', 'Review'] - const createdPhaseIds = [] - const phaseRecords = [] + const phaseNames = ["Screening", "Review"]; + const createdPhaseIds = []; + const phaseRecords = []; for (const phaseName of phaseNames) { - let phaseRecord = await prisma.phase.findFirst({ where: { name: phaseName } }) + let phaseRecord = await prisma.phase.findFirst({ where: { name: phaseName } }); if (!phaseRecord) { phaseRecord = await prisma.phase.create({ data: { @@ -1026,20 +1142,20 @@ describe('challenge service unit tests', () => { description: `${phaseName} phase`, isOpen: false, duration: 86400, - createdBy: 'activation-test', - updatedBy: 'activation-test' - } - }) - createdPhaseIds.push(phaseRecord.id) + createdBy: "activation-test", + updatedBy: "activation-test", + }, + }); + createdPhaseIds.push(phaseRecord.id); } - phaseRecords.push(phaseRecord) + phaseRecords.push(phaseRecord); } const challenge = await prisma.challenge.create({ data: { id: uuid(), name: `Activation coverage ${Date.now()}`, - description: 'Activation coverage test', + description: "Activation coverage test", typeId: data.challenge.typeId, trackId: data.challenge.trackId, timelineTemplateId: config.SKIP_PROJECT_ID_BY_TIMLINE_TEMPLATE_ID, @@ -1047,16 +1163,16 @@ describe('challenge service unit tests', () => { status: ChallengeStatusEnum.DRAFT, tags: [], groups: [], - createdBy: 'activation-test', - updatedBy: 'activation-test' - } - }) + createdBy: "activation-test", + updatedBy: "activation-test", + }, + }); - const challengePhaseIds = [] + const challengePhaseIds = []; await prisma.challengePhase.createMany({ data: phaseRecords.map((phase) => { - const cpId = uuid() - challengePhaseIds.push(cpId) + const cpId = uuid(); + challengePhaseIds.push(cpId); return { id: cpId, challengeId: challenge.id, @@ -1064,38 +1180,38 @@ describe('challenge service unit tests', () => { name: phase.name, duration: phase.duration || 86400, isOpen: false, - createdBy: 'activation-test', - updatedBy: 'activation-test' - } - }) - }) + createdBy: "activation-test", + updatedBy: "activation-test", + }; + }), + }); - return { challenge, phaseRecords, createdPhaseIds, challengePhaseIds } - } + return { challenge, phaseRecords, createdPhaseIds, challengePhaseIds }; + }; const cleanupChallengeWithRequiredReviewPhases = async ({ challenge, createdPhaseIds = [], - challengePhaseIds = [] + challengePhaseIds = [], }) => { if (challengePhaseIds.length > 0) { - await prisma.challengePhase.deleteMany({ where: { id: { in: challengePhaseIds } } }) + await prisma.challengePhase.deleteMany({ where: { id: { in: challengePhaseIds } } }); } if (challenge) { - await prisma.challenge.delete({ where: { id: challenge.id } }) + await prisma.challenge.delete({ where: { id: challenge.id } }); } if (createdPhaseIds.length > 0) { - await prisma.phase.deleteMany({ where: { id: { in: createdPhaseIds } } }) + await prisma.phase.deleteMany({ where: { id: { in: createdPhaseIds } } }); } - } + }; const createActivationChallenge = async (status = ChallengeStatusEnum.NEW) => { - const timelineTemplateId = await ensureSkipTimelineTemplate() + const timelineTemplateId = await ensureSkipTimelineTemplate(); return prisma.challenge.create({ data: { id: uuid(), name: `Activation reviewer check ${Date.now()}`, - description: 'activation reviewer check', + description: "activation reviewer check", typeId: data.challenge.typeId, trackId: data.challenge.trackId, timelineTemplateId, @@ -1103,1008 +1219,1131 @@ describe('challenge service unit tests', () => { status, tags: [], groups: [], - createdBy: 'activation-test', - updatedBy: 'activation-test' - } - }) - } + createdBy: "activation-test", + updatedBy: "activation-test", + }, + }); + }; - it('update challenge successfully 1', async () => { - const challengeData = testChallengeData - const result = await service.updateChallenge({ isMachine: true, sub: 'sub3', userId: 22838965 }, id, { - privateDescription: 'track 333', - description: 'updated desc', - attachments: [] // this will delete attachments - }) - should.equal(result.id, id) - should.equal(result.typeId, data.challenge.typeId) - should.equal(result.privateDescription, 'track 333') - should.equal(result.name, challengeData.name) - should.equal(result.description, 'updated desc') - should.equal(result.timelineTemplateId, challengeData.timelineTemplateId) - should.equal(result.phases.length, 2) - should.exist(result.phases[0].id) - should.equal(result.phases[0].phaseId, data.phase.id) - should.equal(result.phases[0].duration, challengeData.phases[0].duration) - should.equal(testHelper.getDatesDiff(result.phases[0].scheduledStartDate, challengeData.startDate), 0) - should.equal(testHelper.getDatesDiff(result.phases[0].scheduledEndDate, challengeData.startDate), - challengeData.phases[0].duration * 1000) - should.exist(result.phases[1].id) - should.equal(result.phases[1].phaseId, data.phase2.id) - should.equal(result.phases[1].predecessor, result.phases[0].phaseId) - should.equal(result.phases[1].duration, challengeData.phases[1].duration) - should.equal(testHelper.getDatesDiff(result.phases[1].scheduledStartDate, challengeData.startDate), - challengeData.phases[0].duration * 1000) - should.equal(testHelper.getDatesDiff(result.phases[1].scheduledEndDate, challengeData.startDate), - challengeData.phases[0].duration * 1000 + challengeData.phases[1].duration * 1000) - should.equal(result.prizeSets.length, 1) - should.equal(result.prizeSets[0].type, challengeData.prizeSets[0].type) - should.equal(result.prizeSets[0].description, challengeData.prizeSets[0].description) - should.equal(result.prizeSets[0].prizes.length, 1) - should.equal(result.prizeSets[0].prizes[0].description, challengeData.prizeSets[0].prizes[0].description) - should.equal(result.prizeSets[0].prizes[0].type, challengeData.prizeSets[0].prizes[0].type) - should.equal(result.prizeSets[0].prizes[0].value, challengeData.prizeSets[0].prizes[0].value) - should.equal(result.reviewType, challengeData.reviewType) - should.equal(result.tags.length, 2) - should.equal(result.tags[0], challengeData.tags[0]) - should.equal(result.tags[1], challengeData.tags[1]) - should.equal(result.projectId, challengeData.projectId) - should.equal(result.legacyId, challengeData.legacyId) - should.equal(result.forumId, challengeData.forumId) - should.equal(result.status, challengeData.status) - should.equal(result.funChallenge, challengeData.funChallenge) - should.equal(!result.attachments || result.attachments.length === 0, true) - should.equal(result.createdBy, 'testuser') - should.equal(result.updatedBy, '22838965') - should.exist(result.startDate) - should.exist(result.created) - should.exist(result.updated) - }).timeout(3000) - - it('update challenge with startDate only keeps derived dates stable', async () => { + it("update challenge successfully 1", async () => { + const challengeData = testChallengeData; const result = await service.updateChallenge( - { isMachine: true, sub: 'sub3', userId: 22838965 }, + { isMachine: true, sub: "sub3", userId: 22838965 }, id, { - startDate: testChallengeData.startDate - } - ) + privateDescription: "track 333", + description: "updated desc", + attachments: [], // this will delete attachments + }, + ); + should.equal(result.id, id); + should.equal(result.typeId, data.challenge.typeId); + should.equal(result.privateDescription, "track 333"); + should.equal(result.name, challengeData.name); + should.equal(result.description, "updated desc"); + should.equal(result.timelineTemplateId, challengeData.timelineTemplateId); + should.equal(result.phases.length, 2); + should.exist(result.phases[0].id); + should.equal(result.phases[0].phaseId, data.phase.id); + should.equal(result.phases[0].duration, challengeData.phases[0].duration); + should.equal( + testHelper.getDatesDiff(result.phases[0].scheduledStartDate, challengeData.startDate), + 0, + ); + should.equal( + testHelper.getDatesDiff(result.phases[0].scheduledEndDate, challengeData.startDate), + challengeData.phases[0].duration * 1000, + ); + should.exist(result.phases[1].id); + should.equal(result.phases[1].phaseId, data.phase2.id); + should.equal(result.phases[1].predecessor, result.phases[0].phaseId); + should.equal(result.phases[1].duration, challengeData.phases[1].duration); + should.equal( + testHelper.getDatesDiff(result.phases[1].scheduledStartDate, challengeData.startDate), + challengeData.phases[0].duration * 1000, + ); + should.equal( + testHelper.getDatesDiff(result.phases[1].scheduledEndDate, challengeData.startDate), + challengeData.phases[0].duration * 1000 + challengeData.phases[1].duration * 1000, + ); + should.equal(result.prizeSets.length, 1); + should.equal(result.prizeSets[0].type, challengeData.prizeSets[0].type); + should.equal(result.prizeSets[0].description, challengeData.prizeSets[0].description); + should.equal(result.prizeSets[0].prizes.length, 1); + should.equal( + result.prizeSets[0].prizes[0].description, + challengeData.prizeSets[0].prizes[0].description, + ); + should.equal(result.prizeSets[0].prizes[0].type, challengeData.prizeSets[0].prizes[0].type); + should.equal(result.prizeSets[0].prizes[0].value, challengeData.prizeSets[0].prizes[0].value); + should.equal(result.reviewType, challengeData.reviewType); + should.equal(result.tags.length, 2); + should.equal(result.tags[0], challengeData.tags[0]); + should.equal(result.tags[1], challengeData.tags[1]); + should.equal(result.projectId, challengeData.projectId); + should.equal(result.legacyId, challengeData.legacyId); + should.equal(result.forumId, challengeData.forumId); + should.equal(result.status, challengeData.status); + should.equal(result.funChallenge, challengeData.funChallenge); + should.equal(!result.attachments || result.attachments.length === 0, true); + should.equal(result.createdBy, "testuser"); + should.equal(result.updatedBy, "22838965"); + should.exist(result.startDate); + should.exist(result.created); + should.exist(result.updated); + }).timeout(3000); + + it("update challenge with startDate only keeps derived dates stable", async () => { + const result = await service.updateChallenge( + { isMachine: true, sub: "sub3", userId: 22838965 }, + id, + { + startDate: testChallengeData.startDate, + }, + ); - should.equal(result.id, id) - should.exist(result.startDate) - should.equal(testHelper.getDatesDiff(result.startDate, testChallengeData.startDate), 0) - }) + should.equal(result.id, id); + should.exist(result.startDate); + should.equal(testHelper.getDatesDiff(result.startDate, testChallengeData.startDate), 0); + }); - it('preserves existing terms when update payload omits the terms field', async () => { - const challengeData = _.cloneDeep(testChallengeData) - challengeData.name = `${challengeData.name} Terms ${Date.now()}` - challengeData.legacyId = Math.floor(Math.random() * 1000000) + it("preserves existing terms when update payload omits the terms field", async () => { + const challengeData = _.cloneDeep(testChallengeData); + challengeData.name = `${challengeData.name} Terms ${Date.now()}`; + challengeData.legacyId = Math.floor(Math.random() * 1000000); const challengeWithTerms = await service.createChallenge( - { isMachine: true, sub: 'sub-terms', userId: 22838965 }, + { isMachine: true, sub: "sub-terms", userId: 22838965 }, challengeData, - config.M2M_FULL_ACCESS_TOKEN - ) + config.M2M_FULL_ACCESS_TOKEN, + ); const termRecords = [ { challengeId: challengeWithTerms.id, termId: uuid(), roleId: uuid(), - createdBy: 'unit-test', - updatedBy: 'unit-test' + createdBy: "unit-test", + updatedBy: "unit-test", }, { challengeId: challengeWithTerms.id, termId: uuid(), roleId: uuid(), - createdBy: 'unit-test', - updatedBy: 'unit-test' - } - ] - await prisma.challengeTerm.createMany({ data: termRecords }) + createdBy: "unit-test", + updatedBy: "unit-test", + }, + ]; + await prisma.challengeTerm.createMany({ data: termRecords }); try { const updated = await service.updateChallenge( - { isMachine: true, sub: 'sub-terms', userId: 22838965 }, + { isMachine: true, sub: "sub-terms", userId: 22838965 }, challengeWithTerms.id, { - description: 'Updated description to ensure persistence of terms' - } - ) + description: "Updated description to ensure persistence of terms", + }, + ); - should.exist(updated.terms) - should.equal(updated.terms.length, termRecords.length) - const sortedTerms = _.sortBy(updated.terms, ['id', 'roleId']) + should.exist(updated.terms); + should.equal(updated.terms.length, termRecords.length); + const sortedTerms = _.sortBy(updated.terms, ["id", "roleId"]); const expectedTerms = _.sortBy( termRecords.map((t) => ({ id: t.termId, roleId: t.roleId })), - ['id', 'roleId'] - ) - sortedTerms.should.deep.equal(expectedTerms) + ["id", "roleId"], + ); + sortedTerms.should.deep.equal(expectedTerms); } finally { - await prisma.challenge.delete({ where: { id: challengeWithTerms.id } }) + await prisma.challenge.delete({ where: { id: challengeWithTerms.id } }); } - }).timeout(5000) - - it('update challenge successfully with winners', async () => { - const result = await service.updateChallenge({ isMachine: true, sub: 'sub3', userId: 22838965 }, data.challenge.id, { - winners: [{ - userId: 12345678, - handle: 'thomaskranitsas', - placement: 1, - type: PrizeSetTypeEnum.PLACEMENT - }] - }) - should.equal(result.id, data.challenge.id) - should.equal(result.typeId, data.challenge.typeId) - should.equal(result.trackId, data.challenge.trackId) - should.equal(result.name, data.challenge.name) - should.equal(result.description, data.challenge.description) - should.equal(result.timelineTemplateId, data.challenge.timelineTemplateId) - should.equal(result.phases.length, 0) - should.equal(result.prizeSets.length, 0) - should.equal(result.reviewType, data.challenge.reviewType) - should.equal(result.tags.length, 1) - should.equal(result.tags[0], data.challenge.tags[0]) - should.equal(result.projectId, data.challenge.projectId) - should.equal(result.legacyId, data.challenge.legacyId) - should.equal(result.forumId, data.challenge.forumId) - should.equal(result.status.toUpperCase(), data.challenge.status.toUpperCase()) - should.equal(result.winners.length, 1) - should.equal(result.winners[0].userId, winners[0].userId) - should.equal(result.winners[0].handle, winners[0].handle) - should.equal(result.winners[0].placement, winners[0].placement) - should.equal(result.winners[0].type, PrizeSetTypeEnum.PLACEMENT) - should.equal(result.createdBy, 'admin') - should.equal(result.updatedBy, '22838965') - should.exist(result.startDate) - should.exist(result.created) - should.exist(result.updated) - }) - - it('update challenge - triggers payments for task challenges stored without legacy.pureV5Task', async () => { - const originalGetChallengeResources = helper.getChallengeResources - const originalGenerateChallengePayments = helper.generateChallengePayments - let generatedPaymentsChallengeId + }).timeout(5000); + + it("preserves existing attachments when update payload omits the attachments field", async () => { + const challengeData = _.cloneDeep(testChallengeData); + challengeData.name = `${challengeData.name} Attachments ${Date.now()}`; + challengeData.legacyId = Math.floor(Math.random() * 1000000); + const challengeWithAttachment = await service.createChallenge( + { isMachine: true, sub: "sub-attachments-create", userId: 22838965 }, + challengeData, + config.M2M_FULL_ACCESS_TOKEN, + ); + + const createdAttachment = await prisma.attachment.create({ + data: { + challengeId: challengeWithAttachment.id, + createdBy: "unit-test", + fileSize: 1234, + name: "specification.pdf", + updatedBy: "unit-test", + url: "https://example.com/specification.pdf", + }, + }); + + try { + const updated = await service.updateChallenge( + { isMachine: true, sub: "sub-attachments-update", userId: 22838965 }, + challengeWithAttachment.id, + { + description: "Updated description while preserving attachments", + }, + ); + + should.exist(updated.attachments); + should.equal(updated.attachments.length, 1); + should.equal(updated.attachments[0].id, createdAttachment.id); + should.equal(updated.attachments[0].name, createdAttachment.name); + should.equal(updated.attachments[0].url, createdAttachment.url); + should.equal(updated.attachments[0].fileSize, createdAttachment.fileSize); + + const persistedAttachments = await prisma.attachment.findMany({ + where: { challengeId: challengeWithAttachment.id }, + }); + + should.equal(persistedAttachments.length, 1); + should.equal(persistedAttachments[0].id, createdAttachment.id); + } finally { + await prisma.challenge.delete({ where: { id: challengeWithAttachment.id } }); + } + }).timeout(5000); + + it("update challenge successfully with winners", async () => { + const result = await service.updateChallenge( + { isMachine: true, sub: "sub3", userId: 22838965 }, + data.challenge.id, + { + winners: [ + { + userId: 12345678, + handle: "thomaskranitsas", + placement: 1, + type: PrizeSetTypeEnum.PLACEMENT, + }, + ], + }, + ); + should.equal(result.id, data.challenge.id); + should.equal(result.typeId, data.challenge.typeId); + should.equal(result.trackId, data.challenge.trackId); + should.equal(result.name, data.challenge.name); + should.equal(result.description, data.challenge.description); + should.equal(result.timelineTemplateId, data.challenge.timelineTemplateId); + should.equal(result.phases.length, 0); + should.equal(result.prizeSets.length, 0); + should.equal(result.reviewType, data.challenge.reviewType); + should.equal(result.tags.length, 1); + should.equal(result.tags[0], data.challenge.tags[0]); + should.equal(result.projectId, data.challenge.projectId); + should.equal(result.legacyId, data.challenge.legacyId); + should.equal(result.forumId, data.challenge.forumId); + should.equal(result.status.toUpperCase(), data.challenge.status.toUpperCase()); + should.equal(result.winners.length, 1); + should.equal(result.winners[0].userId, winners[0].userId); + should.equal(result.winners[0].handle, winners[0].handle); + should.equal(result.winners[0].placement, winners[0].placement); + should.equal(result.winners[0].type, PrizeSetTypeEnum.PLACEMENT); + should.equal(result.createdBy, "admin"); + should.equal(result.updatedBy, "22838965"); + should.exist(result.startDate); + should.exist(result.created); + should.exist(result.updated); + }); + + it("update challenge - triggers payments for task challenges stored without legacy.pureV5Task", async () => { + const originalGetChallengeResources = helper.getChallengeResources; + const originalGenerateChallengePayments = helper.generateChallengePayments; + let generatedPaymentsChallengeId; helper.getChallengeResources = async (challengeId) => { if (challengeId === data.taskChallenge.id) { - return [{ - roleId: config.SUBMITTER_ROLE_ID, - memberId: 12345678, - memberHandle: 'thomaskranitsas' - }] + return [ + { + roleId: config.SUBMITTER_ROLE_ID, + memberId: 12345678, + memberHandle: "thomaskranitsas", + }, + ]; } - return originalGetChallengeResources(challengeId) - } + return originalGetChallengeResources(challengeId); + }; helper.generateChallengePayments = async (challengeId) => { - generatedPaymentsChallengeId = challengeId - return true - } + generatedPaymentsChallengeId = challengeId; + return true; + }; try { await prisma.challenge.update({ where: { id: data.taskChallenge.id }, data: { status: ChallengeStatusEnum.ACTIVE, - updatedBy: 'admin' - } - }) + updatedBy: "admin", + }, + }); const result = await service.updateChallenge( - { isMachine: true, sub: 'sub-task', userId: 22838965 }, + { isMachine: true, sub: "sub-task", userId: 22838965 }, data.taskChallenge.id, { status: ChallengeStatusEnum.COMPLETED, - winners: [{ - userId: 12345678, - handle: 'thomaskranitsas', - placement: 1 - }] - } - ) + winners: [ + { + userId: 12345678, + handle: "thomaskranitsas", + placement: 1, + }, + ], + }, + ); - should.equal(result.status, ChallengeStatusEnum.COMPLETED) - should.equal(generatedPaymentsChallengeId, data.taskChallenge.id) + should.equal(result.status, ChallengeStatusEnum.COMPLETED); + should.equal(generatedPaymentsChallengeId, data.taskChallenge.id); } finally { - helper.getChallengeResources = originalGetChallengeResources - helper.generateChallengePayments = originalGenerateChallengePayments + helper.getChallengeResources = originalGetChallengeResources; + helper.generateChallengePayments = originalGenerateChallengePayments; } - }) + }); - describe('reviewer scorecard changes', () => { - const originalScorecardId = 'sc-original' - const newScorecardId = 'sc-updated' + describe("reviewer scorecard changes", () => { + const originalScorecardId = "sc-original"; + const newScorecardId = "sc-updated"; beforeEach(async () => { - await prisma.challengeReviewer.deleteMany({ where: { challengeId: data.challenge.id } }) + await prisma.challengeReviewer.deleteMany({ where: { challengeId: data.challenge.id } }); await prisma.challengeReviewer.create({ data: { challengeId: data.challenge.id, scorecardId: originalScorecardId, isMemberReview: false, phaseId: data.phase.id, - createdBy: 'admin', - updatedBy: 'admin' - } - }) + createdBy: "admin", + updatedBy: "admin", + }, + }); if (reviewClient) { - await reviewClient.$executeRawUnsafe(`DELETE FROM ${reviewTableName}`) + await reviewClient.$executeRawUnsafe(`DELETE FROM ${reviewTableName}`); } - }) + }); afterEach(async () => { - await prisma.challengeReviewer.deleteMany({ where: { challengeId: data.challenge.id } }) + await prisma.challengeReviewer.deleteMany({ where: { challengeId: data.challenge.id } }); if (reviewClient) { - await reviewClient.$executeRawUnsafe(`DELETE FROM ${reviewTableName}`) + await reviewClient.$executeRawUnsafe(`DELETE FROM ${reviewTableName}`); } - }) + }); - it('allows scorecard change when no reviews exist', async () => { + it("allows scorecard change when no reviews exist", async () => { const payload = { reviewers: [ { phaseId: data.phase.id, scorecardId: newScorecardId, - isMemberReview: false - } - ] - } + isMemberReview: false, + }, + ], + }; const updated = await service.updateChallenge( - { isMachine: true, sub: 'sub3', userId: 22838965 }, + { isMachine: true, sub: "sub3", userId: 22838965 }, data.challenge.id, - payload - ) + payload, + ); - should.exist(updated.reviewers) - should.equal(updated.reviewers.length, 1) - should.equal(updated.reviewers[0].scorecardId, newScorecardId) - }) + should.exist(updated.reviewers); + should.equal(updated.reviewers.length, 1); + should.equal(updated.reviewers[0].scorecardId, newScorecardId); + }); - it('blocks scorecard change when reviews already started', async () => { + it("blocks scorecard change when reviews already started", async () => { if (reviewClient) { - await reviewClient.$executeRawUnsafe(`INSERT INTO ${reviewTableName} ("id", "phaseId", "scorecardId", "status") VALUES ('${uuid()}', '${data.challengePhase1Id}', '${originalScorecardId}', 'IN_PROGRESS')`) + await reviewClient.$executeRawUnsafe( + `INSERT INTO ${reviewTableName} ("id", "phaseId", "scorecardId", "status") VALUES ('${uuid()}', '${data.challengePhase1Id}', '${originalScorecardId}', 'IN_PROGRESS')`, + ); } try { - await service.updateChallenge({ isMachine: true, sub: 'sub3', userId: 22838965 }, data.challenge.id, { - reviewers: [ - { - phaseId: data.phase.id, - scorecardId: newScorecardId, - isMemberReview: false - } - ] - }) + await service.updateChallenge( + { isMachine: true, sub: "sub3", userId: 22838965 }, + data.challenge.id, + { + reviewers: [ + { + phaseId: data.phase.id, + scorecardId: newScorecardId, + isMemberReview: false, + }, + ], + }, + ); } catch (e) { should.equal( e.message, - "Can't change the scorecard at this time because at least one review has already started with the old scorecard" - ) - return + "Can't change the scorecard at this time because at least one review has already started with the old scorecard", + ); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('allows scorecard change via reviews alias when no reviews exist', async () => { + it("allows scorecard change via reviews alias when no reviews exist", async () => { const payload = { reviews: [ { phaseId: data.phase.id, scorecardId: newScorecardId, - isMemberReview: false - } - ] - } + isMemberReview: false, + }, + ], + }; const updated = await service.updateChallenge( - { isMachine: true, sub: 'sub3', userId: 22838965 }, + { isMachine: true, sub: "sub3", userId: 22838965 }, data.challenge.id, - payload - ) + payload, + ); - should.exist(updated.reviewers) - should.equal(updated.reviewers.length, 1) - should.equal(updated.reviewers[0].scorecardId, newScorecardId) - }) + should.exist(updated.reviewers); + should.equal(updated.reviewers.length, 1); + should.equal(updated.reviewers[0].scorecardId, newScorecardId); + }); - it('blocks scorecard change via reviews alias when reviews already started', async () => { + it("blocks scorecard change via reviews alias when reviews already started", async () => { if (reviewClient) { - await reviewClient.$executeRawUnsafe(`INSERT INTO ${reviewTableName} ("id", "phaseId", "scorecardId", "status") VALUES ('${uuid()}', '${data.challengePhase1Id}', '${originalScorecardId}', 'IN_PROGRESS')`) + await reviewClient.$executeRawUnsafe( + `INSERT INTO ${reviewTableName} ("id", "phaseId", "scorecardId", "status") VALUES ('${uuid()}', '${data.challengePhase1Id}', '${originalScorecardId}', 'IN_PROGRESS')`, + ); } try { - await service.updateChallenge({ isMachine: true, sub: 'sub3', userId: 22838965 }, data.challenge.id, { - reviews: [ - { - phaseId: data.phase.id, - scorecardId: newScorecardId, - isMemberReview: false - } - ] - }) + await service.updateChallenge( + { isMachine: true, sub: "sub3", userId: 22838965 }, + data.challenge.id, + { + reviews: [ + { + phaseId: data.phase.id, + scorecardId: newScorecardId, + isMemberReview: false, + }, + ], + }, + ); } catch (e) { should.equal( e.message, - "Can't change the scorecard at this time because at least one review has already started with the old scorecard" - ) - return + "Can't change the scorecard at this time because at least one review has already started with the old scorecard", + ); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('blocks scorecard change when an active review phase has started reviews', async () => { + it("blocks scorecard change when an active review phase has started reviews", async () => { if (!reviewClient) { - return + return; } - let reviewPhase - let reviewChallengePhaseId + let reviewPhase; + let reviewChallengePhaseId; - const insertedReviewId = uuid() + const insertedReviewId = uuid(); try { reviewPhase = await prisma.phase.create({ data: { id: uuid(), - name: 'Review', - description: 'desc', + name: "Review", + description: "desc", isOpen: true, duration: 86400, - createdBy: 'admin', - updatedBy: 'admin' - } - }) + createdBy: "admin", + updatedBy: "admin", + }, + }); - reviewChallengePhaseId = uuid() - const now = new Date() + reviewChallengePhaseId = uuid(); + const now = new Date(); await prisma.challengePhase.create({ data: { id: reviewChallengePhaseId, challengeId: data.challenge.id, phaseId: reviewPhase.id, - name: 'Review', + name: "Review", isOpen: true, actualStartDate: now, - createdBy: 'admin', - updatedBy: 'admin' - } - }) + createdBy: "admin", + updatedBy: "admin", + }, + }); await prisma.challengeReviewer.updateMany({ where: { challengeId: data.challenge.id }, - data: { phaseId: reviewPhase.id } - }) + data: { phaseId: reviewPhase.id }, + }); await reviewClient.$executeRawUnsafe( - `INSERT INTO ${reviewTableName} ("id", "phaseId", "scorecardId", "status") VALUES ('${insertedReviewId}', '${reviewChallengePhaseId}', '${originalScorecardId}', 'COMPLETED')` - ) + `INSERT INTO ${reviewTableName} ("id", "phaseId", "scorecardId", "status") VALUES ('${insertedReviewId}', '${reviewChallengePhaseId}', '${originalScorecardId}', 'COMPLETED')`, + ); - await service.updateChallenge({ isMachine: true, sub: 'sub3', userId: 22838965 }, data.challenge.id, { - reviewers: [ - { - phaseId: reviewPhase.id, - scorecardId: newScorecardId, - isMemberReview: false - } - ] - }) + await service.updateChallenge( + { isMachine: true, sub: "sub3", userId: 22838965 }, + data.challenge.id, + { + reviewers: [ + { + phaseId: reviewPhase.id, + scorecardId: newScorecardId, + isMemberReview: false, + }, + ], + }, + ); } catch (e) { should.equal( e.message, - "Cannot change the scorecard for phase 'Review' because reviews are already in progress or completed" - ) - return + "Cannot change the scorecard for phase 'Review' because reviews are already in progress or completed", + ); + return; } finally { await reviewClient.$executeRawUnsafe( - `DELETE FROM ${reviewTableName} WHERE "id" = '${insertedReviewId}'` - ) + `DELETE FROM ${reviewTableName} WHERE "id" = '${insertedReviewId}'`, + ); if (reviewChallengePhaseId) { - await prisma.challengePhase.delete({ where: { id: reviewChallengePhaseId } }) + await prisma.challengePhase.delete({ where: { id: reviewChallengePhaseId } }); } if (reviewPhase) { - await prisma.phase.delete({ where: { id: reviewPhase.id } }) + await prisma.phase.delete({ where: { id: reviewPhase.id } }); } } - throw new Error('should not reach here') - }) - }) + throw new Error("should not reach here"); + }); + }); - it('update challenge - creator memberId can modify without matching handle', async () => { - const updatePayload = { privateDescription: 'Creator update via memberId' } - const result = await service.updateChallenge({ userId: 'testuser', handle: 'different-handle' }, id, updatePayload) - should.equal(result.id, id) - should.equal(result.privateDescription, updatePayload.privateDescription) - }) + it("update challenge - creator memberId can modify without matching handle", async () => { + const updatePayload = { privateDescription: "Creator update via memberId" }; + const result = await service.updateChallenge( + { userId: "testuser", handle: "different-handle" }, + id, + updatePayload, + ); + should.equal(result.id, id); + should.equal(result.privateDescription, updatePayload.privateDescription); + }); - it('update challenge - project not found', async () => { + it("update challenge - project not found", async () => { try { await service.updateChallenge( - { userId: '16096823', handle: '', roles: [constants.UserRoles.Admin] }, + { userId: "16096823", handle: "", roles: [constants.UserRoles.Admin] }, id, - { projectId: 100000 }) + { projectId: 100000 }, + ); } catch (e) { - should.equal(e.message, 'Project with id: 100000 doesn\'t exist') - return + should.equal(e.message, "Project with id: 100000 doesn't exist"); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('update challenge - user doesn\'t have permission to update challenge under specific project', async () => { + it("update challenge - user doesn't have permission to update challenge under specific project", async () => { try { - await service.updateChallenge({ userId: '16096823', handle: '' }, id, { projectId: 200 }) + await service.updateChallenge({ userId: "16096823", handle: "" }, id, { projectId: 200 }); } catch (e) { should.equal( e.message, - 'Only M2M, admin, challenge\'s copilot, users with full access, or project members with write/full/copilot access can perform modification.' - ) - return + "Only M2M, admin, challenge's copilot, users with full access, or project members with write/full/copilot access can perform modification.", + ); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('update challenge - timeline template not found', async () => { + it("update challenge - timeline template not found", async () => { try { - await service.updateChallenge({ isMachine: true, sub: 'sub3' }, id, { - timelineTemplateId: notFoundId - }) + await service.updateChallenge({ isMachine: true, sub: "sub3" }, id, { + timelineTemplateId: notFoundId, + }); } catch (e) { - should.equal(e.message, `TimelineTemplate with id: ${notFoundId} doesn't exist`) - return + should.equal(e.message, `TimelineTemplate with id: ${notFoundId} doesn't exist`); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('update challenge - challenge not found', async () => { + it("update challenge - challenge not found", async () => { try { - await service.updateChallenge({ isMachine: true, sub: 'sub3' }, notFoundId, { - privateDescription: 'track 333' - }) + await service.updateChallenge({ isMachine: true, sub: "sub3" }, notFoundId, { + privateDescription: "track 333", + }); } catch (e) { - should.equal(e.message, `Challenge with id: ${notFoundId} doesn't exist`) - return + should.equal(e.message, `Challenge with id: ${notFoundId} doesn't exist`); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('update challenge - invalid type id', async () => { + it("update challenge - invalid type id", async () => { try { - await service.updateChallenge({ isMachine: true, sub: 'sub3' }, id, { - typeId: 'invalid' - }) + await service.updateChallenge({ isMachine: true, sub: "sub3" }, id, { + typeId: "invalid", + }); } catch (e) { - should.equal(e.message.indexOf('"typeId" must be a valid GUID') >= 0, true) - return + should.equal(e.message.indexOf('"typeId" must be a valid GUID') >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('update challenge - invalid start date', async () => { + it("update challenge - invalid start date", async () => { try { - await service.updateChallenge({ isMachine: true, sub: 'sub3' }, id, { - startDate: 'abc' - }) + await service.updateChallenge({ isMachine: true, sub: "sub3" }, id, { + startDate: "abc", + }); } catch (e) { - should.equal(e.message.indexOf('"startDate" must be a valid ISO 8601 date') >= 0, true) - return + should.equal(e.message.indexOf('"startDate" must be a valid ISO 8601 date') >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('update challenge - empty name', async () => { + it("update challenge - empty name", async () => { try { - await service.updateChallenge({ isMachine: true, sub: 'sub3' }, id, { - name: '' - }) + await service.updateChallenge({ isMachine: true, sub: "sub3" }, id, { + name: "", + }); } catch (e) { - should.equal(e.message.indexOf('"name" is not allowed to be empty') >= 0, true) - return + should.equal(e.message.indexOf('"name" is not allowed to be empty') >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('update challenge - Completed to Active status', async () => { + it("update challenge - Completed to Active status", async () => { try { - await service.updateChallenge({ isMachine: true, sub: 'sub3' }, data.challenge.id, { - status: ChallengeStatusEnum.ACTIVE - }) + await service.updateChallenge({ isMachine: true, sub: "sub3" }, data.challenge.id, { + status: ChallengeStatusEnum.ACTIVE, + }); } catch (e) { - should.equal(e.message.indexOf('Cannot change COMPLETED challenge status to ACTIVE status') >= 0, true) - return + should.equal( + e.message.indexOf("Cannot change COMPLETED challenge status to ACTIVE status") >= 0, + true, + ); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('update challenge - prevent activating without reviewers', async () => { - const activationChallenge = await createActivationChallenge(ChallengeStatusEnum.DRAFT) + it("update challenge - prevent activating without reviewers", async () => { + const activationChallenge = await createActivationChallenge(ChallengeStatusEnum.DRAFT); try { - await service.updateChallenge({ isMachine: true, sub: 'sub-activate' }, activationChallenge.id, { - status: ChallengeStatusEnum.ACTIVE - }) + await service.updateChallenge( + { isMachine: true, sub: "sub-activate" }, + activationChallenge.id, + { + status: ChallengeStatusEnum.ACTIVE, + }, + ); } catch (e) { - should.equal(e.message.indexOf('reviewer configured') >= 0, true) - return + should.equal(e.message.indexOf("reviewer configured") >= 0, true); + return; } finally { - await prisma.challenge.delete({ where: { id: activationChallenge.id } }) + await prisma.challenge.delete({ where: { id: activationChallenge.id } }); } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('update challenge - allow activating with reviewers provided', async () => { - const activationChallenge = await createActivationChallenge() + it("update challenge - allow activating with reviewers provided", async () => { + const activationChallenge = await createActivationChallenge(); try { const updated = await service.updateChallenge( - { isMachine: true, sub: 'sub-activate', userId: 22838965 }, + { isMachine: true, sub: "sub-activate", userId: 22838965 }, activationChallenge.id, { status: ChallengeStatusEnum.ACTIVE, reviewers: [ { phaseId: data.phase.id, - scorecardId: 'activation-scorecard', + scorecardId: "activation-scorecard", isMemberReview: false, - aiWorkflowId: 'workflow-123' - } - ] - } - ) - should.equal(updated.status, ChallengeStatusEnum.ACTIVE) - should.exist(updated.reviewers) - should.equal(updated.reviewers.length, 1) - should.equal(updated.reviewers[0].scorecardId, 'activation-scorecard') + aiWorkflowId: "workflow-123", + }, + ], + }, + ); + should.equal(updated.status, ChallengeStatusEnum.ACTIVE); + should.exist(updated.reviewers); + should.equal(updated.reviewers.length, 1); + should.equal(updated.reviewers[0].scorecardId, "activation-scorecard"); } finally { - await prisma.challenge.delete({ where: { id: activationChallenge.id } }) + await prisma.challenge.delete({ where: { id: activationChallenge.id } }); } - }) + }); - it('update challenge - prevent activating when reviewer is missing required fields', async () => { - const activationChallenge = await createActivationChallenge() + it("update challenge - prevent activating when reviewer is missing required fields", async () => { + const activationChallenge = await createActivationChallenge(); await prisma.challengeReviewer.create({ data: { id: uuid(), challengeId: activationChallenge.id, - scorecardId: '', + scorecardId: "", isMemberReview: false, phaseId: data.phase.id, - aiWorkflowId: 'wf-missing', - createdBy: 'activation-test', - updatedBy: 'activation-test' - } - }) + aiWorkflowId: "wf-missing", + createdBy: "activation-test", + updatedBy: "activation-test", + }, + }); try { await service.updateChallenge( - { isMachine: true, sub: 'sub-activate', userId: 22838965 }, + { isMachine: true, sub: "sub-activate", userId: 22838965 }, activationChallenge.id, { - status: ChallengeStatusEnum.ACTIVE - } - ) + status: ChallengeStatusEnum.ACTIVE, + }, + ); } catch (e) { - should.equal(e.message.indexOf('reviewers are missing required fields') >= 0, true) - return + should.equal(e.message.indexOf("reviewers are missing required fields") >= 0, true); + return; } finally { - await prisma.challenge.delete({ where: { id: activationChallenge.id } }) + await prisma.challenge.delete({ where: { id: activationChallenge.id } }); } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('update challenge - enforce reviewers for required phases', async () => { - const setup = await createChallengeWithRequiredReviewPhases() + it("update challenge - enforce reviewers for required phases", async () => { + const setup = await createChallengeWithRequiredReviewPhases(); try { await service.updateChallenge( - { isMachine: true, sub: 'sub-activate', userId: 22838965 }, + { isMachine: true, sub: "sub-activate", userId: 22838965 }, setup.challenge.id, { status: ChallengeStatusEnum.ACTIVE, reviewers: [ { phaseId: setup.phaseRecords[0].id, - scorecardId: 'screening-scorecard', + scorecardId: "screening-scorecard", isMemberReview: false, - aiWorkflowId: 'workflow-screening' - } - ] - } - ) + aiWorkflowId: "workflow-screening", + }, + ], + }, + ); } catch (e) { - should.equal(e.message.indexOf('missing reviewers for phase(s): Review') >= 0, true) - return + should.equal(e.message.indexOf("missing reviewers for phase(s): Review") >= 0, true); + return; } finally { - await cleanupChallengeWithRequiredReviewPhases(setup) + await cleanupChallengeWithRequiredReviewPhases(setup); } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('update challenge - allow activation when required phases have reviewers', async () => { - const setup = await createChallengeWithRequiredReviewPhases() + it("update challenge - allow activation when required phases have reviewers", async () => { + const setup = await createChallengeWithRequiredReviewPhases(); try { const updated = await service.updateChallenge( - { isMachine: true, sub: 'sub-activate', userId: 22838965 }, + { isMachine: true, sub: "sub-activate", userId: 22838965 }, setup.challenge.id, { status: ChallengeStatusEnum.ACTIVE, reviewers: [ { phaseId: setup.phaseRecords[0].id, - scorecardId: 'screening-scorecard', + scorecardId: "screening-scorecard", isMemberReview: false, - aiWorkflowId: 'workflow-screening' + aiWorkflowId: "workflow-screening", }, { phaseId: setup.phaseRecords[1].id, - scorecardId: 'review-scorecard', + scorecardId: "review-scorecard", isMemberReview: false, - aiWorkflowId: 'workflow-review' - } - ] - } - ) - should.equal(updated.status, ChallengeStatusEnum.ACTIVE) - should.exist(updated.reviewers) - should.equal(updated.reviewers.length, 2) + aiWorkflowId: "workflow-review", + }, + ], + }, + ); + should.equal(updated.status, ChallengeStatusEnum.ACTIVE); + should.exist(updated.reviewers); + should.equal(updated.reviewers.length, 2); } finally { - await cleanupChallengeWithRequiredReviewPhases(setup) + await cleanupChallengeWithRequiredReviewPhases(setup); } - }) + }); - it('update challenge - set winners with non-completed Active status', async () => { + it("update challenge - set winners with non-completed Active status", async () => { try { - await service.updateChallenge({ isMachine: true, sub: 'sub3' }, id, { - winners - }) + await service.updateChallenge({ isMachine: true, sub: "sub3" }, id, { + winners, + }); } catch (e) { - should.equal(e.message.indexOf('Cannot set winners for challenge with non-completed') >= 0, true) - return + should.equal( + e.message.indexOf("Cannot set winners for challenge with non-completed") >= 0, + true, + ); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('update challenge - Duplicate member with placement 1', async () => { + it("update challenge - Duplicate member with placement 1", async () => { try { - await service.updateChallenge({ isMachine: true, sub: 'sub3' }, data.challenge.id, { - winners: [{ - userId: 12345678, - handle: 'thomaskranitsas', - placement: 1, - type: PrizeSetTypeEnum.PLACEMENT - }, - { - userId: 12345678, - handle: 'thomaskranitsas', - placement: 1, - type: PrizeSetTypeEnum.PLACEMENT - }] - }) + await service.updateChallenge({ isMachine: true, sub: "sub3" }, data.challenge.id, { + winners: [ + { + userId: 12345678, + handle: "thomaskranitsas", + placement: 1, + type: PrizeSetTypeEnum.PLACEMENT, + }, + { + userId: 12345678, + handle: "thomaskranitsas", + placement: 1, + type: PrizeSetTypeEnum.PLACEMENT, + }, + ], + }); } catch (e) { - should.equal(e.message.indexOf('Duplicate member with placement') >= 0, true) - return + should.equal(e.message.indexOf("Duplicate member with placement") >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('update challenge - Only one member can have placement 1', async () => { + it("update challenge - Only one member can have placement 1", async () => { try { - await service.updateChallenge({ isMachine: true, sub: 'sub3' }, data.challenge.id, { + await service.updateChallenge({ isMachine: true, sub: "sub3" }, data.challenge.id, { winners: [ { userId: 12345678, - handle: 'thomaskranitsas', + handle: "thomaskranitsas", placement: 1, - type: PrizeSetTypeEnum.PLACEMENT + type: PrizeSetTypeEnum.PLACEMENT, }, { userId: 3456789, - handle: 'tonyj', + handle: "tonyj", placement: 1, - type: PrizeSetTypeEnum.PLACEMENT - } - ] - }) + type: PrizeSetTypeEnum.PLACEMENT, + }, + ], + }); } catch (e) { - should.equal(e.message.indexOf('Only one member can have a placement') >= 0, true) - return + should.equal(e.message.indexOf("Only one member can have a placement") >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('update challenge - The same member 12345678 cannot have multiple placements', async () => { + it("update challenge - The same member 12345678 cannot have multiple placements", async () => { try { - await service.updateChallenge({ isMachine: true, sub: 'sub3' }, data.challenge.id, { + await service.updateChallenge({ isMachine: true, sub: "sub3" }, data.challenge.id, { winners: [ { userId: 12345678, - handle: 'thomaskranitsas', + handle: "thomaskranitsas", placement: 1, - type: PrizeSetTypeEnum.PLACEMENT + type: PrizeSetTypeEnum.PLACEMENT, }, { userId: 12345678, - handle: 'thomaskranitsas', + handle: "thomaskranitsas", placement: 2, - type: PrizeSetTypeEnum.PLACEMENT - } - ] - }) + type: PrizeSetTypeEnum.PLACEMENT, + }, + ], + }); } catch (e) { - should.equal(e.message.indexOf('The same member 12345678 cannot have multiple placements') >= 0, true) - return + should.equal( + e.message.indexOf("The same member 12345678 cannot have multiple placements") >= 0, + true, + ); + return; } - throw new Error('should not reach here') - }) - }) + throw new Error("should not reach here"); + }); + }); - describe('close marathon match tests', () => { - const adminUser = { isMachine: false, roles: [constants.UserRoles.Admin], userId: 'admin' } - const m2mUser = { isMachine: true } - const nonAdminUser = { isMachine: false, userId: 'user123', roles: [constants.UserRoles.User] } + describe("close marathon match tests", () => { + const adminUser = { isMachine: false, roles: [constants.UserRoles.Admin], userId: "admin" }; + const m2mUser = { isMachine: true }; + const nonAdminUser = { isMachine: false, userId: "user123", roles: [constants.UserRoles.User] }; - let originalReviewSummations - let originalChallengeResources + let originalReviewSummations; + let originalChallengeResources; beforeEach(async () => { - originalReviewSummations = helper.getReviewSummations - originalChallengeResources = helper.getChallengeResources + originalReviewSummations = helper.getReviewSummations; + originalChallengeResources = helper.getChallengeResources; if (data && data.marathonMatchChallenge) { - await prisma.challengeWinner.deleteMany({ where: { challengeId: data.marathonMatchChallenge.id } }) + await prisma.challengeWinner.deleteMany({ + where: { challengeId: data.marathonMatchChallenge.id }, + }); await prisma.challenge.update({ where: { id: data.marathonMatchChallenge.id }, data: { status: ChallengeStatusEnum.ACTIVE, - updatedBy: 'admin' - } - }) + updatedBy: "admin", + }, + }); await prisma.challengePhase.updateMany({ where: { challengeId: data.marathonMatchChallenge.id }, data: { isOpen: true, actualEndDate: null, - updatedBy: 'admin' - } - }) + updatedBy: "admin", + }, + }); } - }) + }); afterEach(() => { - helper.getReviewSummations = originalReviewSummations - helper.getChallengeResources = originalChallengeResources - }) + helper.getReviewSummations = originalReviewSummations; + helper.getChallengeResources = originalChallengeResources; + }); - it('close marathon match successfully with multiple final review summations', async () => { - const originalGetReviewSummations = helper.getReviewSummations - helper.getReviewSummations = async () => ([ + it("close marathon match successfully with multiple final review summations", async () => { + const originalGetReviewSummations = helper.getReviewSummations; + helper.getReviewSummations = async () => [ { id: uuid(), challengeId: data.marathonMatchChallenge.id, isFinal: true, aggregateScore: 95.5, - submitterId: '12345678', - submitterHandle: 'thomaskranitsas', - createdAt: '2024-02-01T10:00:00.000Z' + submitterId: "12345678", + submitterHandle: "thomaskranitsas", + createdAt: "2024-02-01T10:00:00.000Z", }, { id: uuid(), challengeId: data.marathonMatchChallenge.id, isFinal: true, aggregateScore: 92.1, - submitterId: '9876543', - submitterHandle: 'tonyj', - createdAt: '2024-02-01T11:00:00.000Z' + submitterId: "9876543", + submitterHandle: "tonyj", + createdAt: "2024-02-01T11:00:00.000Z", }, { id: uuid(), challengeId: data.marathonMatchChallenge.id, isFinal: true, aggregateScore: 87.3, - submitterId: '3456789', - submitterHandle: 'nathanael', - createdAt: '2024-02-01T12:00:00.000Z' + submitterId: "3456789", + submitterHandle: "nathanael", + createdAt: "2024-02-01T12:00:00.000Z", }, { id: uuid(), challengeId: data.marathonMatchChallenge.id, isFinal: false, aggregateScore: 99.9, - submitterId: '5555555', - submitterHandle: 'ignored', - createdAt: '2024-02-01T13:00:00.000Z' - } - ]) - originalReviewSummations = originalGetReviewSummations - - const originalGetChallengeResources = helper.getChallengeResources - helper.getChallengeResources = async () => ([ - { roleId: config.SUBMITTER_ROLE_ID, memberId: 12345678, memberHandle: 'thomaskranitsas' }, - { roleId: config.SUBMITTER_ROLE_ID, memberId: 9876543, memberHandle: 'tonyj' }, - { roleId: config.SUBMITTER_ROLE_ID, memberId: 3456789, memberHandle: 'nathanael' }, - { roleId: 'some-other-role', memberId: 11111111 } - ]) - originalChallengeResources = originalGetChallengeResources - - const result = await service.closeMarathonMatch(adminUser, data.marathonMatchChallenge.id) - - should.exist(result) - should.equal(result.status, ChallengeStatusEnum.COMPLETED) - should.equal(result.winners.length, 3) - should.equal(result.winners[0].placement, 1) - should.equal(result.winners[0].userId, 12345678) - should.equal(result.winners[0].type, PrizeSetTypeEnum.PLACEMENT) - should.equal(result.winners[1].placement, 2) - should.equal(result.winners[1].userId, 9876543) - should.equal(result.winners[2].placement, 3) - should.equal(result.winners[2].userId, 3456789) - result.phases.forEach(phase => { - should.equal(phase.isOpen, false) - should.exist(phase.actualEndDate) - }) - }) - - it('close marathon match successfully with M2M token', async () => { - const originalGetReviewSummations = helper.getReviewSummations - helper.getReviewSummations = async () => ([ + submitterId: "5555555", + submitterHandle: "ignored", + createdAt: "2024-02-01T13:00:00.000Z", + }, + ]; + originalReviewSummations = originalGetReviewSummations; + + const originalGetChallengeResources = helper.getChallengeResources; + helper.getChallengeResources = async () => [ + { roleId: config.SUBMITTER_ROLE_ID, memberId: 12345678, memberHandle: "thomaskranitsas" }, + { roleId: config.SUBMITTER_ROLE_ID, memberId: 9876543, memberHandle: "tonyj" }, + { roleId: config.SUBMITTER_ROLE_ID, memberId: 3456789, memberHandle: "nathanael" }, + { roleId: "some-other-role", memberId: 11111111 }, + ]; + originalChallengeResources = originalGetChallengeResources; + + const result = await service.closeMarathonMatch(adminUser, data.marathonMatchChallenge.id); + + should.exist(result); + should.equal(result.status, ChallengeStatusEnum.COMPLETED); + should.equal(result.winners.length, 3); + should.equal(result.winners[0].placement, 1); + should.equal(result.winners[0].userId, 12345678); + should.equal(result.winners[0].type, PrizeSetTypeEnum.PLACEMENT); + should.equal(result.winners[1].placement, 2); + should.equal(result.winners[1].userId, 9876543); + should.equal(result.winners[2].placement, 3); + should.equal(result.winners[2].userId, 3456789); + result.phases.forEach((phase) => { + should.equal(phase.isOpen, false); + should.exist(phase.actualEndDate); + }); + }); + + it("close marathon match successfully with M2M token", async () => { + const originalGetReviewSummations = helper.getReviewSummations; + helper.getReviewSummations = async () => [ { id: uuid(), challengeId: data.marathonMatchChallenge.id, isFinal: true, aggregateScore: 88.4, - submitterId: '12345678', - submitterHandle: 'thomaskranitsas', - createdAt: '2024-03-01T10:00:00.000Z' + submitterId: "12345678", + submitterHandle: "thomaskranitsas", + createdAt: "2024-03-01T10:00:00.000Z", }, { id: uuid(), challengeId: data.marathonMatchChallenge.id, isFinal: true, aggregateScore: 84.1, - submitterId: '9876543', - submitterHandle: 'tonyj', - createdAt: '2024-03-01T11:00:00.000Z' - } - ]) - originalReviewSummations = originalGetReviewSummations - - const originalGetChallengeResources = helper.getChallengeResources - helper.getChallengeResources = async () => ([ - { roleId: config.SUBMITTER_ROLE_ID, memberId: 12345678, memberHandle: 'thomaskranitsas' }, - { roleId: config.SUBMITTER_ROLE_ID, memberId: 9876543, memberHandle: 'tonyj' } - ]) - originalChallengeResources = originalGetChallengeResources - - const result = await service.closeMarathonMatch(m2mUser, data.marathonMatchChallenge.id) - - should.exist(result) - should.equal(result.status, ChallengeStatusEnum.COMPLETED) - should.equal(result.winners.length, 2) - should.equal(result.winners[0].userId, 12345678) - should.equal(result.winners[0].placement, 1) - should.equal(result.winners[1].userId, 9876543) - should.equal(result.winners[1].placement, 2) - result.phases.forEach(phase => { - should.equal(phase.isOpen, false) - should.exist(phase.actualEndDate) - }) - }) - - it('close marathon match with tie-breaking logic (same aggregateScore, different createdAt)', async () => { - const originalGetReviewSummations = helper.getReviewSummations - helper.getReviewSummations = async () => ([ + submitterId: "9876543", + submitterHandle: "tonyj", + createdAt: "2024-03-01T11:00:00.000Z", + }, + ]; + originalReviewSummations = originalGetReviewSummations; + + const originalGetChallengeResources = helper.getChallengeResources; + helper.getChallengeResources = async () => [ + { roleId: config.SUBMITTER_ROLE_ID, memberId: 12345678, memberHandle: "thomaskranitsas" }, + { roleId: config.SUBMITTER_ROLE_ID, memberId: 9876543, memberHandle: "tonyj" }, + ]; + originalChallengeResources = originalGetChallengeResources; + + const result = await service.closeMarathonMatch(m2mUser, data.marathonMatchChallenge.id); + + should.exist(result); + should.equal(result.status, ChallengeStatusEnum.COMPLETED); + should.equal(result.winners.length, 2); + should.equal(result.winners[0].userId, 12345678); + should.equal(result.winners[0].placement, 1); + should.equal(result.winners[1].userId, 9876543); + should.equal(result.winners[1].placement, 2); + result.phases.forEach((phase) => { + should.equal(phase.isOpen, false); + should.exist(phase.actualEndDate); + }); + }); + + it("close marathon match with tie-breaking logic (same aggregateScore, different createdAt)", async () => { + const originalGetReviewSummations = helper.getReviewSummations; + helper.getReviewSummations = async () => [ { id: uuid(), challengeId: data.marathonMatchChallenge.id, isFinal: true, aggregateScore: 90.0, - submitterId: '12345678', - submitterHandle: 'thomaskranitsas', - createdAt: '2024-04-01T09:00:00.000Z' + submitterId: "12345678", + submitterHandle: "thomaskranitsas", + createdAt: "2024-04-01T09:00:00.000Z", }, { id: uuid(), challengeId: data.marathonMatchChallenge.id, isFinal: true, aggregateScore: 90.0, - submitterId: '9876543', - submitterHandle: 'tonyj', - createdAt: '2024-04-01T12:00:00.000Z' + submitterId: "9876543", + submitterHandle: "tonyj", + createdAt: "2024-04-01T12:00:00.000Z", }, { id: uuid(), challengeId: data.marathonMatchChallenge.id, isFinal: true, aggregateScore: 80.5, - submitterId: '3456789', - submitterHandle: 'nathanael', - createdAt: '2024-04-01T13:00:00.000Z' - } - ]) - originalReviewSummations = originalGetReviewSummations - - const originalGetChallengeResources = helper.getChallengeResources - helper.getChallengeResources = async () => ([ - { roleId: config.SUBMITTER_ROLE_ID, memberId: 12345678, memberHandle: 'thomaskranitsas' }, - { roleId: config.SUBMITTER_ROLE_ID, memberId: 9876543, memberHandle: 'tonyj' }, - { roleId: config.SUBMITTER_ROLE_ID, memberId: 3456789, memberHandle: 'nathanael' } - ]) - originalChallengeResources = originalGetChallengeResources - - const result = await service.closeMarathonMatch(adminUser, data.marathonMatchChallenge.id) - - should.exist(result) - should.equal(result.winners.length, 3) - should.equal(result.winners[0].userId, 12345678) - should.equal(result.winners[0].placement, 1) - should.equal(result.winners[1].userId, 9876543) - should.equal(result.winners[1].placement, 2) - should.equal(result.winners[2].userId, 3456789) - should.equal(result.winners[2].placement, 3) - }) - - it('close marathon match - non-Marathon Match challenge type', async () => { + submitterId: "3456789", + submitterHandle: "nathanael", + createdAt: "2024-04-01T13:00:00.000Z", + }, + ]; + originalReviewSummations = originalGetReviewSummations; + + const originalGetChallengeResources = helper.getChallengeResources; + helper.getChallengeResources = async () => [ + { roleId: config.SUBMITTER_ROLE_ID, memberId: 12345678, memberHandle: "thomaskranitsas" }, + { roleId: config.SUBMITTER_ROLE_ID, memberId: 9876543, memberHandle: "tonyj" }, + { roleId: config.SUBMITTER_ROLE_ID, memberId: 3456789, memberHandle: "nathanael" }, + ]; + originalChallengeResources = originalGetChallengeResources; + + const result = await service.closeMarathonMatch(adminUser, data.marathonMatchChallenge.id); + + should.exist(result); + should.equal(result.winners.length, 3); + should.equal(result.winners[0].userId, 12345678); + should.equal(result.winners[0].placement, 1); + should.equal(result.winners[1].userId, 9876543); + should.equal(result.winners[1].placement, 2); + should.equal(result.winners[2].userId, 3456789); + should.equal(result.winners[2].placement, 3); + }); + + it("close marathon match - non-Marathon Match challenge type", async () => { try { - await service.closeMarathonMatch(adminUser, data.challenge.id) + await service.closeMarathonMatch(adminUser, data.challenge.id); } catch (e) { - should.equal(e.name, 'BadRequestError') - should.equal(e.message.indexOf('is not a Marathon Match challenge') >= 0, true) - return + should.equal(e.name, "BadRequestError"); + should.equal(e.message.indexOf("is not a Marathon Match challenge") >= 0, true); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('close marathon match - forbidden for non-admin user', async () => { + it("close marathon match - forbidden for non-admin user", async () => { try { - await service.closeMarathonMatch(nonAdminUser, data.marathonMatchChallenge.id) + await service.closeMarathonMatch(nonAdminUser, data.marathonMatchChallenge.id); } catch (e) { - should.equal(e.name, 'ForbiddenError') - should.equal(e.message.indexOf('Admin role or an M2M token is required to close the marathon match.') >= 0, true) - return + should.equal(e.name, "ForbiddenError"); + should.equal( + e.message.indexOf( + "Admin role or an M2M token is required to close the marathon match.", + ) >= 0, + true, + ); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('close marathon match - challenge not found', async () => { + it("close marathon match - challenge not found", async () => { try { - await service.closeMarathonMatch(adminUser, notFoundId) + await service.closeMarathonMatch(adminUser, notFoundId); } catch (e) { - should.equal(e.name, 'NotFoundError') - should.equal(e.message.indexOf(`Challenge with id: ${notFoundId} doesn't exist`) >= 0, true) - return + should.equal(e.name, "NotFoundError"); + should.equal( + e.message.indexOf(`Challenge with id: ${notFoundId} doesn't exist`) >= 0, + true, + ); + return; } - throw new Error('should not reach here') - }) + throw new Error("should not reach here"); + }); - it('close marathon match - missing submitter resources', async () => { - const originalGetReviewSummations = helper.getReviewSummations - helper.getReviewSummations = async () => ([ + it("close marathon match - missing submitter resources", async () => { + const originalGetReviewSummations = helper.getReviewSummations; + helper.getReviewSummations = async () => [ { id: uuid(), challengeId: data.marathonMatchChallenge.id, isFinal: true, aggregateScore: 70.0, - submitterId: '12345678', - submitterHandle: 'thomaskranitsas', - createdAt: '2024-05-01T09:00:00.000Z' - } - ]) - originalReviewSummations = originalGetReviewSummations + submitterId: "12345678", + submitterHandle: "thomaskranitsas", + createdAt: "2024-05-01T09:00:00.000Z", + }, + ]; + originalReviewSummations = originalGetReviewSummations; - const originalGetChallengeResources = helper.getChallengeResources - helper.getChallengeResources = async () => ([]) - originalChallengeResources = originalGetChallengeResources + const originalGetChallengeResources = helper.getChallengeResources; + helper.getChallengeResources = async () => []; + originalChallengeResources = originalGetChallengeResources; try { - await service.closeMarathonMatch(adminUser, data.marathonMatchChallenge.id) + await service.closeMarathonMatch(adminUser, data.marathonMatchChallenge.id); } catch (e) { - should.equal(e.name, 'BadRequestError') - should.equal(e.message.indexOf('Submitter resources are required to close Marathon Match challenge') >= 0, true) - return - } - throw new Error('should not reach here') - }) - - }) -}) + should.equal(e.name, "BadRequestError"); + should.equal( + e.message.indexOf("Submitter resources are required to close Marathon Match challenge") >= + 0, + true, + ); + return; + } + throw new Error("should not reach here"); + }); + }); +}); From 6a38297ad56c1d7b845c8b684639e3a0e53cafb3 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 1 Apr 2026 09:03:07 +1100 Subject: [PATCH 04/27] Add deterministic historical MM planning CLI and report output Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .factory/init.sh | 23 + .factory/library/architecture.md | 169 +++++++ .factory/library/environment.md | 56 +++ .factory/library/legacy-data.md | 63 +++ .factory/library/user-testing.md | 51 +++ .factory/services.yaml | 9 + .factory/skills/migration-worker/SKILL.md | 121 +++++ data-migration/src/migrators/_baseMigrator.js | 2 +- .../importHistoricalMarathonMatches.js | 40 ++ .../argParser.js | 189 ++++++++ .../existingState.js | 80 ++++ .../legacyDataReader.js | 79 ++++ .../planning.js | 429 ++++++++++++++++++ .../reporting.js | 12 + ...portHistoricalMarathonMatches.plan.test.js | 250 ++++++++++ 15 files changed, 1572 insertions(+), 1 deletion(-) create mode 100755 .factory/init.sh create mode 100644 .factory/library/architecture.md create mode 100644 .factory/library/environment.md create mode 100644 .factory/library/legacy-data.md create mode 100644 .factory/library/user-testing.md create mode 100644 .factory/services.yaml create mode 100644 .factory/skills/migration-worker/SKILL.md create mode 100644 data-migration/src/scripts/importHistoricalMarathonMatches.js create mode 100644 data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js create mode 100644 data-migration/src/scripts/importHistoricalMarathonMatches/existingState.js create mode 100644 data-migration/src/scripts/importHistoricalMarathonMatches/legacyDataReader.js create mode 100644 data-migration/src/scripts/importHistoricalMarathonMatches/planning.js create mode 100644 data-migration/src/scripts/importHistoricalMarathonMatches/reporting.js create mode 100644 data-migration/test/importHistoricalMarathonMatches.plan.test.js diff --git a/.factory/init.sh b/.factory/init.sh new file mode 100755 index 0000000..c2038cb --- /dev/null +++ b/.factory/init.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$HOME/.config/nvm/nvm.sh" + +nvm use >/dev/null +if [ ! -d node_modules ]; then + pnpm install +fi + +if [ -d data-migration ]; then + ( + cd data-migration + nvm use 18.19.0 >/dev/null + if [ ! -d node_modules ]; then + pnpm install + fi + ) +fi + +if [ ! -f .env.importer.local ]; then + echo "warning: .env.importer.local is missing; live validation will remain blocked" >&2 +fi diff --git a/.factory/library/architecture.md b/.factory/library/architecture.md new file mode 100644 index 0000000..b5b532a --- /dev/null +++ b/.factory/library/architecture.md @@ -0,0 +1,169 @@ +# Architecture + +How the historical marathon-match importer works at a high level. + +**What belongs here:** major components, branch behavior, data flow, invariants, and cross-service ownership. +**What does NOT belong here:** step-by-step implementation tasks or validator commands. + +--- + +## System Boundary + +The mission adds a reusable importer inside `challenge-api-v6/data-migration/` that reads legacy Informix JSON exports and reconciles them into the v6 challenge/resource/review stack. + +### Read surfaces + +- `/mnt/Informix` JSON exports (read-only) +- existing v6 challenge data through the challenge DB / challenge-api schema +- existing v6 resource data through the Resource API +- existing v6 submission and review-summation data through the review DB / review-api schema + +### Write surfaces + +- Challenge and ChallengePhase records in the challenge DB +- submitter Resource records through the Resource API +- Submission and ReviewSummation records in the review DB + +## Import Pipeline + +### 1. Selection and planning + +The importer accepts an explicit round filter and builds a per-round plan. Each selected round is classified as one of: + +- `create` — no matching v6 marathon challenge exists +- `reuse/backfill-only` — a v6 marathon challenge already exists and only linked records may be added +- `skip` / `unresolved` — the round cannot be safely applied without more input + +Planning is required to surface traceability, counts, and entity-level deltas before writes occur. + +### Existing-challenge match rule + +Safe reuse is authoritative, not fuzzy: + +1. first try an exact existing `challenge.legacyId == round.id` match +2. if there is not exactly one such match, treat any name-based or heuristic candidates as planning diagnostics only +3. before reusing a matched challenge, verify it is a safe historical MM target: Marathon Match type, Data Science track, and no conflicting duplicate standard phase rows +4. if the round still is not matched unambiguously, or if the matched challenge fails those shape checks, emit `unresolved` and require an explicit override rather than auto-reusing a challenge + +This keeps backfill-only behavior deterministic and avoids silent challenge-level rewrites. + +### 2. Challenge reconciliation + +For each selected round: + +- if no v6 challenge exists, create one completed `Marathon Match` challenge on the `Data Science` track +- if a v6 challenge is matched unambiguously and passes the reuse preconditions above, keep the same challenge id and preserve challenge-level fields + +Created challenges must use `challenge.legacyId = round.id`. Reused challenges are not challenge-level rewrite candidates; they must already be matched unambiguously by the rule above or remain `unresolved`. + +### 3. Phase materialization + +Canonical MM history in v6 is represented by exactly three standard phases: + +- `Registration` +- `Submission` +- `Review` + +For newly created historical challenges, these phases must exist and be closed. For reused challenges, already-present standard phase rows are preserved as-is and only absent standard phase rows may be added. + +### Timeline derivation rule + +When creating a historical challenge: + +- choose the canonical Marathon Match/Data Science timeline mapping used by the target environment by resolving exactly one valid template candidate; if zero or multiple candidates remain, stop with `unresolved` +- derive `Registration` from the min/max eligible `round_registration.timestamp` +- derive `Submission` from the earliest available legacy submission-open signal for the round, falling back to the earliest non-example submit timestamp when needed, and end it at the latest non-example submit timestamp +- synthesize `Review` as a coherent closed interval starting at or after the imported submission end; if no explicit review timestamps exist, collapse it to a closed interval at the end of submission rather than inventing a separate open window + +If required timestamps are missing or contradictory enough that a coherent closed timeline cannot be produced, the round should remain `unresolved` instead of being half-created. + +### 4. Participant materialization + +Submitter resources come from legacy registrations, not just from members with submissions. The importer must create or reuse exactly one submitter-role resource per eligible registrant. + +**Eligible registrant rule:** every distinct `round_registration.coder_id` for the selected round where `eligible == '1'`. + +**Identity normalization rule:** resolve each legacy `coder_id` once through the same normalized member lookup and reuse that normalized identity for Resource API writes, imported submissions, and imported review records so the same member cannot surface with conflicting cross-service identities. + +**Stable resource dedup key:** `(challengeId, memberId, roleId=submitter)`. + +### 5. Submission materialization + +Only non-example legacy submissions are imported. The importer must preserve the full non-example history per member. + +**Stable submission identity invariant:** imported `Submission.legacySubmissionId` must be a deterministic composite derived from legacy submission identity so round-wide and rerun validation can compare exact sets. The contract assumes `legacySubmissionId` is the stable external identity for imported submissions. + +### 6. Score materialization + +Two score streams are imported: + +- **provisional history** — one provisional review summation per imported non-example submission, using `long_submission.submission_points` +- **final result** — one final review summation per member, attached to the member's latest imported non-example submission + +Final-score derivation uses legacy final-result fields with the agreed precedence: + +1. `long_comp_result.system_point_total` +2. `long_comp_result.point_total` +3. the ranking score from legacy state data used for final ordering + +If a legacy finalist has no imported non-example submission to attach to, the importer must skip that final score explicitly rather than create an orphan final review summation. + +**Stable review-summation dedup keys:** + +- provisional: exactly one provisional review summation per imported submission (`submissionId + provisional`) +- final: exactly one final review summation on the member's latest imported non-example submission (`submissionId + final`) + +## Reuse / Backfill Rules + +These are core safety invariants: + +- existing v6 marathon challenges are source of truth for challenge-level fields +- backfill may add missing linked records only +- already-present standard phase rows on reused challenges are preserved +- reruns must not duplicate challenges, phases, resources, submissions, or review summations +- example submissions and example review summations are never imported + +## Apply / Resume Behavior + +Cross-service writes are not a single distributed transaction. The importer therefore must be round-scoped and restart-safe: + +- plan a round before applying it +- read before write on every owned surface +- treat rerun reconciliation as the recovery path after partial failure +- never assume a round is absent just because a previous apply stopped mid-flight + +The observable result of rerunning a partially imported round should be reconciliation to the same steady state, not duplication or destructive rewrite. + +## Data Ownership Invariants + +### Challenge DB + +Owns: + +- challenge identity and completion state +- phase rows and challenge timeline shape + +### Resource API + +Owns: + +- submitter resource creation/reuse +- externally visible `(memberId, roleId)` participant footprint + +### Review DB / Review API + +Owns: + +- imported submissions +- provisional review summations per submission +- final review summations attached to the latest imported non-example submission per member + +## Validation-Oriented Invariants + +The validation contract relies on these high-level invariants being preserved: + +- round `9892` is the primary missing-historical create-path fixture +- round `10089` is the score-rich final-ranking fixture +- round `14272` is the second selected round for multi-round blast-radius checks +- imported submission identity is externally testable via `legacySubmissionId` +- reused-round verification depends on comparing both identity sets and externally visible field snapshots diff --git a/.factory/library/environment.md b/.factory/library/environment.md new file mode 100644 index 0000000..f2247ba --- /dev/null +++ b/.factory/library/environment.md @@ -0,0 +1,56 @@ +# Environment + +Environment variables, external dependencies, and setup notes. + +**What belongs here:** required env vars, external API URLs, credentials/setup expectations, Node/runtime requirements, read-only source locations. +**What does NOT belong here:** service start/stop commands or ports to manage locally (use `.factory/services.yaml`). + +--- + +## Required Environment + +The importer must load `challenge-api-v6/.env.importer.local` for local/dev execution. + +Required values: + +- `DATABASE_URL` — challenge DB used by `challenge-api-v6` +- `REVIEW_DB_URL` — review DB used for submissions and review summations +- `RESOURCES_API_URL` — base URL for Resource API writes and reads +- `AUTH0_URL` +- `AUTH0_AUDIENCE` +- `AUTH0_CLIENT_ID` +- `AUTH0_CLIENT_SECRET` + +Optional / useful values: + +- `DATA_DIRECTORY=/mnt/Informix` +- importer-scoped attribution values such as `CREATED_BY` / `UPDATED_BY` + +## Runtime Boundaries + +- `/mnt/Informix` is a read-only legacy data source. +- Existing v6 marathon matches are backfill-only at the challenge level. +- Do not commit secrets from `.env.importer.local`. +- The validation target is the existing dev environment referenced by the env file; workers should not assume they are allowed to start replacement local services. + +## Node / Tooling Versions + +- Repo root (`challenge-api-v6`): Node `22.19.0` +- `challenge-api-v6/data-migration`: Node `18.19.0` +- `pnpm` is installed and available (`10.32.1` during planning) + +Workers switching between repo root and `data-migration/` must switch Node versions in the same shell command. + +## Existing Local Processes Observed During Planning + +These are informational boundaries for worker safety: + +- port `3100` already has a running process; do not kill or repurpose it unless the user later explicitly asks +- local postgres is already listening on `54329`; only use it if the env file points there + +## Source Data Notes + +- Marathon matches come from legacy `round` rows with `round_type_id='13'`. +- Primary join path: `round -> long_component_state -> long_submission -> long_comp_result`. +- `round_registration_*.json` is the source of submitter resources. +- `user_*.json` resolves `coder_id` identities. diff --git a/.factory/library/legacy-data.md b/.factory/library/legacy-data.md new file mode 100644 index 0000000..7477f9a --- /dev/null +++ b/.factory/library/legacy-data.md @@ -0,0 +1,63 @@ +# Legacy Data + +Legacy source facts that workers should reuse instead of rediscovering. + +**What belongs here:** source tables/files, join paths, score-source facts, and fixture-round notes. +**What does NOT belong here:** v6 write-side implementation steps. + +--- + +## Primary Files + +- `round_1.json` +- `round_registration_*.json` +- `long_component_state_1.json` +- `long_submission_*.json` +- `long_comp_result_*.json` +- `user_*.json` + +## Marathon Match Identification + +- Marathon matches are legacy `round` rows with `round_type_id='13'`. +- Planning discovered `309` MM rounds in the available export set. + +## Join Path + +Use this legacy relationship when deriving participant/submission/final-score data: + +- `round -> long_component_state -> long_submission -> long_comp_result` + +## Resource Source + +- submitter resources come from `round_registration_*.json` +- resources are registration-driven, not submission-driven +- eligible registrants are rows where `round_registration.eligible == '1'` + +## Submission Rules + +- import full **non-example** history only +- example submissions are excluded from imported submissions and imported score history +- imported `Submission.legacySubmissionId` must be deterministic and stable across reruns + +## Score Rules + +### Provisional + +- source: `long_submission.submission_points` +- cardinality: one provisional review summation per imported non-example submission + +### Final + +- source precedence: + 1. `long_comp_result.system_point_total` + 2. `long_comp_result.point_total` + 3. ranking score from legacy state data used for final ordering +- attachment target: latest imported non-example submission for the member +- if no non-example submission exists, skip explicitly; do not create orphan finals + +## Fixture Rounds + +- `9892`: `1108` eligible registrations, `3217` non-example submissions, `2381` example submissions, `354` submitters with non-example history +- `10089`: clean final-score round with `115` non-null `system_point_total` finalists +- `14272`: second multi-round blast-radius fixture with `3326` non-example submissions +- `10722`: useful edge-case round for finalists without attachable non-example submissions and duplicate placements diff --git a/.factory/library/user-testing.md b/.factory/library/user-testing.md new file mode 100644 index 0000000..43c0bcf --- /dev/null +++ b/.factory/library/user-testing.md @@ -0,0 +1,51 @@ +# User Testing + +Validation surface findings, setup expectations, and concurrency guidance. + +**What belongs here:** validation surfaces, required tools, setup notes, fixture rounds, and concurrency limits. +**What does NOT belong here:** implementation details or feature decomposition. + +--- + +## Validation Surface + +### Surface: Importer CLI + API verification + +Primary validation is black-box and uses: + +- importer CLI (`node ...`) +- `curl` against Challenge API / Resource API / Review API +- `python` for read-only comparison against `/mnt/Informix` + +There is no browser or TUI surface for this mission. + +### Expected validation flow + +1. Run importer dry-run for a selected round set. +2. Capture per-round decision records and deltas. +3. Run apply for the same selected round set. +4. Verify challenge/resource/submission/review state through API responses. +5. Compare imported data to legacy data using read-only Python scripts. +6. Re-run apply or dry-run to prove idempotency. + +### Fixture rounds + +- `9892` — missing-historical create-path round +- `10089` — score-rich final-score ranking round +- `14272` — second round for multi-round filter checks +- one existing-v6 round chosen from dry-run output in the validation environment +- one round with unattachable finalists (for explicit skip/report validation), e.g. `10722` + +## Validation Concurrency + +### Surface: importer CLI + API verification + +- **Max concurrent validators:** `5` +- **Rationale:** machine inspection during planning showed `32` CPUs, `46.83 GB` total RAM, and `32.25 GB` available RAM. Using the required 70% headroom rule gives about `22.58 GB` usable headroom. CLI + `curl` + `python` validators are lightweight and mostly share the same external services, so even five concurrent validators remain comfortably within CPU and memory budget. +- **Practical note:** concurrency should still be reduced if validators must run apply-mode writes against the same validation rounds; prefer partitioning by round or by assertion group to avoid data races. + +## Readiness Notes + +- Validation uses the existing dev environment referenced by `.env.importer.local`. +- `.env.importer.local` is populated, so live end-to-end apply-mode validation can proceed on the selected dev environment. +- Pre-existing repo-wide `standard-lint` noise in `challenge-api-v6` should not be mistaken for importer regressions; validators should focus on mission-owned surfaces. diff --git a/.factory/services.yaml b/.factory/services.yaml new file mode 100644 index 0000000..37619f1 --- /dev/null +++ b/.factory/services.yaml @@ -0,0 +1,9 @@ +commands: + install: source "$HOME/.config/nvm/nvm.sh" && nvm use >/dev/null && pnpm install && (cd data-migration && nvm use 18.19.0 >/dev/null && pnpm install) + test: source "$HOME/.config/nvm/nvm.sh" && (cd data-migration && nvm use 18.19.0 >/dev/null && pnpm test -- --maxWorkers=16) + lint: source "$HOME/.config/nvm/nvm.sh" && (cd data-migration && nvm use 18.19.0 >/dev/null && pnpm lint) + typecheck: echo "No dedicated typecheck command for this JavaScript importer surface" + build: echo "No build script is defined for the historical importer surface" + root_smoke_test: source "$HOME/.config/nvm/nvm.sh" && nvm use >/dev/null && pnpm test + +services: {} diff --git a/.factory/skills/migration-worker/SKILL.md b/.factory/skills/migration-worker/SKILL.md new file mode 100644 index 0000000..ad14f74 --- /dev/null +++ b/.factory/skills/migration-worker/SKILL.md @@ -0,0 +1,121 @@ +--- +name: migration-worker +description: Build and verify historical marathon-match importer features in challenge-api-v6/data-migration with real legacy-data reconciliation and cross-service validation. +--- + +# Migration Worker + +NOTE: Startup and cleanup are handled by `worker-base`. This skill defines the work procedure for importer features. + +## When to Use This Skill + +Use this skill for features that add or modify: + +- importer planning / dry-run behavior +- challenge and phase reconciliation logic +- resource derivation and Resource API writes +- submission import and stable legacy submission identity +- final/provisional score import +- importer-focused tests, fixtures, and validation helpers inside `challenge-api-v6/data-migration` + +## Required Skills + +None. + +## Work Procedure + +1. Read the assigned feature, `mission.md`, `validation-contract.md`, `AGENTS.md`, `.factory/library/architecture.md`, `.factory/library/environment.md`, `.factory/library/legacy-data.md`, and `.factory/library/user-testing.md` before changing code. +2. Identify which validation-contract assertion IDs the feature fulfills and restate them in your own notes before editing. If the feature description and the contract seem inconsistent, return to the orchestrator. +3. Switch to the correct Node version in the same shell command before running anything: + - repo root: `nvm use` + - `data-migration/`: `nvm use 18.19.0` +4. Write tests first (red) for the behavior you are adding. Prefer `data-migration/test/**` for unit/integration tests that exercise: + - round planning and filtering + - deterministic `legacySubmissionId` + - create vs reuse/backfill-only reconciliation + - score derivation and attachment + - idempotent reruns +5. Run the new tests and confirm they fail before implementing. Record the exact failing command and observation in the handoff. +6. Implement the minimal code needed to satisfy the feature. Keep write paths aligned with architecture boundaries: + - challenge / phase writes in the challenge DB + - resource writes through the Resource API + - submission / review-summation writes in the review DB +7. Preserve mission invariants while implementing: + - existing v6 marathon challenges are challenge-level source of truth + - already-present standard phase rows on reused challenges are preserved + - example submissions and example review summations are never imported + - imported submissions expose stable `legacySubmissionId` + - reruns must not create duplicates or rewrite preserved records +8. After implementation, run targeted validators from `.factory/services.yaml`: + - `commands.test` + - `commands.lint` + - if you touched repo-root code outside `data-migration/`, also run `commands.root_smoke_test` and any targeted repo-root checks needed for the changed files +9. Manually verify the feature at the CLI/API surface when possible: + - use dry-run for planning features + - use apply-mode only when the env file and target round selection are ready + - verify the exact API-visible data that corresponds to the feature's `fulfills` assertions +10. End with a precise handoff. Be explicit about what was implemented, which assertions became testable, what commands ran, what manual checks were performed, and any tech debt or unresolved ambiguity. + +## Example Handoff + +```json +{ + "salientSummary": "Implemented round-plan reporting plus deterministic reuse-target selection for the importer. Added failing tests first, then made dry-run emit stable per-round records including matched challenge id and entity-level deltas.", + "whatWasImplemented": "Added planning/reconciliation modules under data-migration/src plus CLI wiring so dry-run now reports one labeled record per selected round with legacy round id, matched v6 challenge id, decision, reason, resource/submission/final/provisional deltas, and traceability identifiers. Added deterministic no-stdin failure behavior for unresolved matches and covered rerun no-op classification.", + "whatWasLeftUndone": "Live apply-mode verification against the dev environment is still blocked until .env.importer.local contains real DATABASE_URL / REVIEW_DB_URL / RESOURCES_API_URL / Auth0 values.", + "verification": { + "commandsRun": [ + { + "command": "source \\\"$HOME/.config/nvm/nvm.sh\\\" && cd /home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration && nvm use 18.19.0 >/dev/null && pnpm test -- --maxWorkers=16 --runInBand plan-reporting.test.js", + "exitCode": 0, + "observation": "New planning tests passed after implementation; they failed before the code change because the CLI report omitted matched challenge ids and delta fields." + }, + { + "command": "source \\\"$HOME/.config/nvm/nvm.sh\\\" && cd /home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration && nvm use 18.19.0 >/dev/null && pnpm lint", + "exitCode": 0, + "observation": "ESLint passed for the importer package." + } + ], + "interactiveChecks": [ + { + "action": "Ran importer dry-run for round 9892 with missing env writes disabled and inspected the labeled per-round record.", + "observed": "CLI reported decision=create with separate resource/submission/final/provisional deltas, traceability identifiers, and no stdin prompt." + } + ] + }, + "tests": { + "added": [ + { + "file": "data-migration/test/plan-reporting.test.js", + "cases": [ + { + "name": "emits one labeled record per selected round with matched challenge id and delta fields", + "verifies": "VAL-PLAN-007, VAL-PLAN-008, VAL-PLAN-013" + }, + { + "name": "rerun dry-run classifies already imported work as unchanged", + "verifies": "VAL-PLAN-014" + } + ] + } + ] + }, + "discoveredIssues": [ + { + "severity": "medium", + "description": "Existing-v6 target matching still depends on the env-backed challenge dataset; no representative reuse-round fixture is checked into the repo yet.", + "suggestedFix": "Add a small reusable reuse-round fixture bundle or seed helper so apply-mode integration tests can cover the reuse/backfill-only branch deterministically." + } + ] +} +``` + +## When to Return to Orchestrator + +Return to the orchestrator when: + +- the feature requires changing the backfill-only rule or any challenge-level overwrite behavior +- the feature needs a write path outside the allowed boundaries (`/mnt/Informix` mutation, direct Resource DB writes, etc.) +- the env-backed validation target is unavailable or credentials are missing for a feature that cannot be verified with fixtures alone +- a required legacy identity rule is still ambiguous (for example, how to derive a stable submission identity) +- existing-v6 data contradicts the mission invariants in a way that requires a product decision diff --git a/data-migration/src/migrators/_baseMigrator.js b/data-migration/src/migrators/_baseMigrator.js index d68e4cc..284b09b 100644 --- a/data-migration/src/migrators/_baseMigrator.js +++ b/data-migration/src/migrators/_baseMigrator.js @@ -724,7 +724,7 @@ class BaseMigrator { extractNumericFieldTypesFromError(error) { const message = error?.message || ''; const fieldTypeMap = new Map(); - const argumentPattern = /Argument\s+(?:["'`])?([A-Za-z0-9_.\[\]]+)(?:["'`])?\s*:\s*Invalid value provided\.?\s*Expected\s+([A-Za-z0-9]+)[^,]*,\s*provided\s+String/gi; + const argumentPattern = /Argument\s+(?:["'`])?([A-Za-z0-9_.[\]]+)(?:["'`])?\s*:\s*Invalid value provided\.?\s*Expected\s+([A-Za-z0-9]+)[^,]*,\s*provided\s+String/gi; let match; while ((match = argumentPattern.exec(message)) !== null) { diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches.js b/data-migration/src/scripts/importHistoricalMarathonMatches.js new file mode 100644 index 0000000..f85ba8e --- /dev/null +++ b/data-migration/src/scripts/importHistoricalMarathonMatches.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node +"use strict"; + +const path = require("path"); +const dotenv = require("dotenv"); +const { parseArgs, usage } = require("./importHistoricalMarathonMatches/argParser"); +const { buildDryRunPlan } = require("./importHistoricalMarathonMatches/planning"); +const { emitPlanReport } = require("./importHistoricalMarathonMatches/reporting"); +const { loadExistingState } = require("./importHistoricalMarathonMatches/existingState"); + +dotenv.config({ + path: path.resolve(__dirname, "..", "..", "..", ".env.importer.local"), + override: false, + quiet: true, +}); +dotenv.config({ quiet: true }); + +const run = async () => { + const options = parseArgs(process.argv.slice(2)); + + if (options.help) { + process.stdout.write(usage); + return; + } + + if (options.apply) { + throw new Error( + "Apply mode is not available in this planning milestone. Use --dry-run to generate reconciliation output." + ); + } + + const existingStateByRoundId = loadExistingState(options.dataDir, options.existingStateFile); + const plan = await buildDryRunPlan(options, existingStateByRoundId); + emitPlanReport(plan); +}; + +run().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; +}); diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js b/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js new file mode 100644 index 0000000..246ec8c --- /dev/null +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js @@ -0,0 +1,189 @@ +"use strict"; + +const DEFAULT_OPTIONS = { + dataDir: process.env.DATA_DIRECTORY || "/mnt/Informix", + roundFile: "round_1.json", + roundComponentFile: "round_component_1.json", + componentFile: "component_1.json", + problemFile: "problem_1.json", + longComponentStateFile: "long_component_state_1.json", + roundRegistrationPattern: "^round_registration_\\d+\\.json$", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + existingStateFile: null, + dryRun: true, + apply: false, + roundIds: [], + help: false, +}; + +const isPositiveIntegerString = (value) => /^[1-9]\d*$/.test(String(value || "").trim()); + +const parseRoundIdValue = (value, optionName) => { + const normalized = String(value || "").trim(); + if (!normalized) { + throw new Error(`${optionName} requires a value`); + } + if (!isPositiveIntegerString(normalized)) { + throw new Error(`Invalid round id value "${normalized}" for ${optionName}. Expected a positive integer.`); + } + return normalized; +}; + +const requireNextValue = (argv, index, optionName) => { + const next = argv[index + 1]; + if (next === undefined || next.startsWith("--")) { + throw new Error(`${optionName} requires a value`); + } + return next; +}; + +const parseRoundIdsList = (value, optionName) => { + const normalized = String(value || "").trim(); + if (!normalized) { + throw new Error(`${optionName} requires a comma-separated list`); + } + + const parsed = []; + normalized.split(",").forEach((entry) => { + const candidate = String(entry || "").trim(); + if (!candidate) { + throw new Error(`Invalid round id value "${entry}" for ${optionName}.`); + } + parsed.push(parseRoundIdValue(candidate, optionName)); + }); + + return parsed; +}; + +const sortRoundIds = (roundIds) => + roundIds.sort((left, right) => Number.parseInt(left, 10) - Number.parseInt(right, 10)); + +const parseArgs = (argv) => { + const options = { ...DEFAULT_OPTIONS }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--") { + continue; + } + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + if (arg === "--data-dir") { + options.dataDir = requireNextValue(argv, index, "--data-dir"); + index += 1; + continue; + } + if (arg === "--round-file") { + options.roundFile = requireNextValue(argv, index, "--round-file"); + index += 1; + continue; + } + if (arg === "--round-component-file") { + options.roundComponentFile = requireNextValue(argv, index, "--round-component-file"); + index += 1; + continue; + } + if (arg === "--component-file") { + options.componentFile = requireNextValue(argv, index, "--component-file"); + index += 1; + continue; + } + if (arg === "--problem-file") { + options.problemFile = requireNextValue(argv, index, "--problem-file"); + index += 1; + continue; + } + if (arg === "--long-component-state-file") { + options.longComponentStateFile = requireNextValue(argv, index, "--long-component-state-file"); + index += 1; + continue; + } + if (arg === "--round-registration-pattern") { + options.roundRegistrationPattern = requireNextValue(argv, index, "--round-registration-pattern"); + index += 1; + continue; + } + if (arg === "--long-submission-pattern") { + options.longSubmissionPattern = requireNextValue(argv, index, "--long-submission-pattern"); + index += 1; + continue; + } + if (arg === "--long-comp-result-pattern") { + options.longCompResultPattern = requireNextValue(argv, index, "--long-comp-result-pattern"); + index += 1; + continue; + } + if (arg === "--existing-state-file") { + options.existingStateFile = requireNextValue(argv, index, "--existing-state-file"); + index += 1; + continue; + } + if (arg === "--round-id") { + const value = requireNextValue(argv, index, "--round-id"); + options.roundIds.push(parseRoundIdValue(value, "--round-id")); + index += 1; + continue; + } + if (arg === "--round-ids") { + const value = requireNextValue(argv, index, "--round-ids"); + options.roundIds.push(...parseRoundIdsList(value, "--round-ids")); + index += 1; + continue; + } + if (arg === "--dry-run") { + options.dryRun = true; + options.apply = false; + continue; + } + if (arg === "--apply") { + options.apply = true; + options.dryRun = false; + continue; + } + + throw new Error(`Unknown option: ${arg}`); + } + + options.roundIds = sortRoundIds(Array.from(new Set(options.roundIds))); + + if (!options.help && options.roundIds.length === 0) { + throw new Error("At least one round filter is required. Use --round-id or --round-ids."); + } + + return options; +}; + +const usage = `Usage: + node data-migration/src/scripts/importHistoricalMarathonMatches.js --dry-run --round-id [options] + +Planning options: + --round-id Select one round id (repeatable) + --round-ids Select comma-separated round ids + --dry-run Build a non-mutating deterministic reconciliation plan (default) + --existing-state-file Optional JSON snapshot for matched challenge ids + existing entity counts + +Input options: + --data-dir Legacy data directory (default: DATA_DIRECTORY or /mnt/Informix) + --round-file Legacy round file (default: round_1.json) + --round-component-file Legacy round_component file (default: round_component_1.json) + --component-file Legacy component file (default: component_1.json) + --problem-file Legacy problem file (default: problem_1.json) + --long-component-state-file Legacy long_component_state file (default: long_component_state_1.json) + --round-registration-pattern Regex for round_registration files (default: ^round_registration_\\d+\\.json$) + --long-submission-pattern Regex for long_submission files (default: ^long_submission_\\d+\\.json$) + --long-comp-result-pattern Regex for long_comp_result files (default: ^long_comp_result_\\d+\\.json$) + +Apply mode: + --apply Reserved for later milestones (not available yet) + +Other: + --help, -h Show this help +`; + +module.exports = { + parseArgs, + usage, +}; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/existingState.js b/data-migration/src/scripts/importHistoricalMarathonMatches/existingState.js new file mode 100644 index 0000000..7e983db --- /dev/null +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/existingState.js @@ -0,0 +1,80 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const safeParseObject = (raw, filePath) => { + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Expected a JSON object"); + } + return parsed; + } catch (error) { + throw new Error(`Failed to parse existing state file ${filePath}: ${error.message}`); + } +}; + +const normalizeExistingStateEntry = (legacyRoundId, payload) => { + const normalizedRoundId = String(legacyRoundId || "").trim(); + if (!normalizedRoundId) { + return null; + } + + const source = payload && typeof payload === "object" ? payload : {}; + const existing = source.existing && typeof source.existing === "object" ? source.existing : {}; + + return { + legacyRoundId: normalizedRoundId, + challengeId: source.challengeId ? String(source.challengeId) : null, + existing: { + phases: existing.phases, + resources: existing.resources, + submissions: existing.submissions, + finalScores: existing.finalScores, + provisionalScores: existing.provisionalScores, + }, + }; +}; + +const entriesFromPayload = (payload) => { + if (Array.isArray(payload.rounds)) { + return payload.rounds + .map((entry) => normalizeExistingStateEntry(entry.legacyRoundId || entry.roundId, entry)) + .filter(Boolean); + } + + if (payload.rounds && typeof payload.rounds === "object") { + return Object.entries(payload.rounds) + .map(([roundId, entry]) => normalizeExistingStateEntry(roundId, entry)) + .filter(Boolean); + } + + return Object.entries(payload) + .map(([roundId, entry]) => normalizeExistingStateEntry(roundId, entry)) + .filter(Boolean); +}; + +const loadExistingState = (baseDir, filePath) => { + if (!filePath) { + return new Map(); + } + + const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(baseDir, filePath); + if (!fs.existsSync(resolvedPath)) { + throw new Error(`Existing state file not found: ${resolvedPath}`); + } + + const raw = fs.readFileSync(resolvedPath, "utf8"); + const payload = safeParseObject(raw, resolvedPath); + const entries = entriesFromPayload(payload); + const byRoundId = new Map(); + entries.forEach((entry) => { + byRoundId.set(entry.legacyRoundId, entry); + }); + return byRoundId; +}; + +module.exports = { + loadExistingState, +}; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/legacyDataReader.js b/data-migration/src/scripts/importHistoricalMarathonMatches/legacyDataReader.js new file mode 100644 index 0000000..2415914 --- /dev/null +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/legacyDataReader.js @@ -0,0 +1,79 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const JSONStream = require("JSONStream"); + +const ensureFileExists = (filePath, label) => { + if (!fs.existsSync(filePath)) { + throw new Error(`${label} file not found: ${filePath}`); + } +}; + +const resolveFilePath = (baseDir, maybeRelativePath) => { + if (path.isAbsolute(maybeRelativePath)) { + return maybeRelativePath; + } + return path.resolve(baseDir, maybeRelativePath); +}; + +const listFilesByPattern = (baseDir, pattern, label) => { + let regex; + try { + regex = new RegExp(pattern); + } catch { + throw new Error(`Invalid regex for ${label}: ${pattern}`); + } + + const matched = fs + .readdirSync(baseDir) + .filter((entry) => regex.test(entry)) + .sort() + .map((entry) => path.join(baseDir, entry)); + + if (matched.length === 0) { + throw new Error(`No files matched ${label} pattern ${pattern} in ${baseDir}`); + } + + return matched; +}; + +const streamJsonArray = async (filePath, rootKey, onRow) => + new Promise((resolve, reject) => { + const stream = fs.createReadStream(filePath, { encoding: "utf8" }); + const parser = JSONStream.parse(`${rootKey}.*`); + let settled = false; + + const fail = (error) => { + if (settled) { + return; + } + settled = true; + reject(new Error(`Failed while parsing ${filePath}: ${error.message}`)); + }; + + stream.on("error", fail); + parser.on("error", fail); + parser.on("data", (row) => { + try { + onRow(row); + } catch (error) { + fail(error); + } + }); + parser.on("end", () => { + if (!settled) { + settled = true; + resolve(); + } + }); + + stream.pipe(parser); + }); + +module.exports = { + ensureFileExists, + resolveFilePath, + listFilesByPattern, + streamJsonArray, +}; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js b/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js new file mode 100644 index 0000000..718e31e --- /dev/null +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js @@ -0,0 +1,429 @@ +"use strict"; + +const { + ensureFileExists, + listFilesByPattern, + resolveFilePath, + streamJsonArray, +} = require("./legacyDataReader"); + +const createEmptyCounters = () => ({ + round: null, + componentIds: new Set(), + problemIds: new Set(), + eligibleRegistrants: new Set(), + nonExampleSubmissions: 0, + exampleSubmissions: 0, + nonExampleSubmitterCoderIds: new Set(), + finalCandidateCoderIds: new Set(), +}); + +const sortIds = (values) => + Array.from(values).sort((left, right) => { + const leftNum = Number.parseInt(left, 10); + const rightNum = Number.parseInt(right, 10); + if (Number.isFinite(leftNum) && Number.isFinite(rightNum)) { + return leftNum - rightNum; + } + return String(left).localeCompare(String(right)); + }); + +const parseNonNegativeInteger = (value) => { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return 0; + } + return parsed; +}; + +const hasAnyFinalSignal = (finalResultRow) => { + const candidates = [ + finalResultRow && finalResultRow.system_point_total, + finalResultRow && finalResultRow.point_total, + finalResultRow && finalResultRow.placed, + ]; + return candidates.some((value) => { + const normalized = String(value || "").trim().toLowerCase(); + return normalized && normalized !== "null"; + }); +}; + +const buildEntityDelta = (target, existing) => { + const safeTarget = parseNonNegativeInteger(target); + const safeExisting = parseNonNegativeInteger(existing); + const unchanged = Math.min(safeTarget, safeExisting); + return { + target: safeTarget, + existing: safeExisting, + toCreate: Math.max(0, safeTarget - safeExisting), + unchanged, + }; +}; + +const evaluateRoundPlan = (roundId, counters, existingStateEntry) => { + if (!counters.round) { + return { + recordType: "round-plan", + legacyRoundId: roundId, + decision: "unmatched", + reason: "selected-round-not-found-in-legacy-source", + matchedChallengeId: null, + rerunClassification: "unresolved", + traceability: { + legacyRoundId: roundId, + legacyComponentIds: [], + legacyProblemIds: [], + }, + summaryCounts: { + eligibleRegistrants: 0, + nonExampleSubmissions: 0, + exampleSubmissionsFiltered: 0, + plannedFinalScores: 0, + plannedProvisionalScores: 0, + finalistsWithoutAttachableSubmission: 0, + }, + entityDeltas: { + phases: buildEntityDelta(0, 0), + resources: buildEntityDelta(0, 0), + submissions: buildEntityDelta(0, 0), + finalScores: { ...buildEntityDelta(0, 0), skippedUnattachableFinalists: 0 }, + provisionalScores: buildEntityDelta(0, 0), + }, + }; + } + + const hasMarathonSignals = + counters.componentIds.size > 0 && + (counters.nonExampleSubmissions > 0 || + counters.exampleSubmissions > 0 || + counters.finalCandidateCoderIds.size > 0 || + counters.eligibleRegistrants.size > 0); + + if (!hasMarathonSignals) { + return { + recordType: "round-plan", + legacyRoundId: roundId, + decision: "unresolved", + reason: "selected-round-lacks-marathon-signal-data", + matchedChallengeId: null, + rerunClassification: "unresolved", + traceability: { + legacyRoundId: roundId, + legacyComponentIds: sortIds(counters.componentIds), + legacyProblemIds: sortIds(counters.problemIds), + }, + summaryCounts: { + eligibleRegistrants: counters.eligibleRegistrants.size, + nonExampleSubmissions: counters.nonExampleSubmissions, + exampleSubmissionsFiltered: counters.exampleSubmissions, + plannedFinalScores: 0, + plannedProvisionalScores: 0, + finalistsWithoutAttachableSubmission: 0, + }, + entityDeltas: { + phases: buildEntityDelta(0, 0), + resources: buildEntityDelta(0, 0), + submissions: buildEntityDelta(0, 0), + finalScores: { ...buildEntityDelta(0, 0), skippedUnattachableFinalists: 0 }, + provisionalScores: buildEntityDelta(0, 0), + }, + }; + } + + const finalAttachableMemberCount = Array.from(counters.finalCandidateCoderIds).filter((coderId) => + counters.nonExampleSubmitterCoderIds.has(coderId) + ).length; + const finalistsWithoutAttachableSubmission = Math.max( + 0, + counters.finalCandidateCoderIds.size - finalAttachableMemberCount + ); + + const targets = { + phases: 3, + resources: counters.eligibleRegistrants.size, + submissions: counters.nonExampleSubmissions, + finalScores: finalAttachableMemberCount, + provisionalScores: counters.nonExampleSubmissions, + }; + + const existingCounts = existingStateEntry && existingStateEntry.existing ? existingStateEntry.existing : {}; + const entityDeltas = { + phases: buildEntityDelta(targets.phases, existingCounts.phases), + resources: buildEntityDelta(targets.resources, existingCounts.resources), + submissions: buildEntityDelta(targets.submissions, existingCounts.submissions), + finalScores: { + ...buildEntityDelta(targets.finalScores, existingCounts.finalScores), + skippedUnattachableFinalists: finalistsWithoutAttachableSubmission, + }, + provisionalScores: buildEntityDelta(targets.provisionalScores, existingCounts.provisionalScores), + }; + + const hasMatchedChallenge = Boolean(existingStateEntry && existingStateEntry.challengeId); + const decision = hasMatchedChallenge ? "reuse/backfill-only" : "create"; + const reason = hasMatchedChallenge + ? "existing-v6-challenge-found" + : "no-matching-v6-challenge-in-provided-state"; + const rerunClassification = + decision === "reuse/backfill-only" && + Object.values(entityDeltas) + .map((value) => value.toCreate || 0) + .every((toCreate) => toCreate === 0) + ? "no-op" + : decision === "reuse/backfill-only" + ? "partial-backfill" + : "new-work"; + + return { + recordType: "round-plan", + legacyRoundId: roundId, + decision, + reason, + matchedChallengeId: hasMatchedChallenge ? existingStateEntry.challengeId : null, + rerunClassification, + traceability: { + legacyRoundId: roundId, + legacyComponentIds: sortIds(counters.componentIds), + legacyProblemIds: sortIds(counters.problemIds), + }, + summaryCounts: { + eligibleRegistrants: counters.eligibleRegistrants.size, + nonExampleSubmissions: counters.nonExampleSubmissions, + exampleSubmissionsFiltered: counters.exampleSubmissions, + plannedFinalScores: finalAttachableMemberCount, + plannedProvisionalScores: counters.nonExampleSubmissions, + finalistsWithoutAttachableSubmission, + }, + entityDeltas, + }; +}; + +const summarizePlan = (records, selectedRoundIds) => { + const countsByDecision = { + create: 0, + "reuse/backfill-only": 0, + unresolved: 0, + unmatched: 0, + }; + + const totals = { + eligibleRegistrants: 0, + nonExampleSubmissions: 0, + exampleSubmissionsFiltered: 0, + plannedFinalScores: 0, + plannedProvisionalScores: 0, + finalistsWithoutAttachableSubmission: 0, + toCreate: { + phases: 0, + resources: 0, + submissions: 0, + finalScores: 0, + provisionalScores: 0, + }, + }; + + records.forEach((record) => { + if (countsByDecision[record.decision] !== undefined) { + countsByDecision[record.decision] += 1; + } + totals.eligibleRegistrants += record.summaryCounts.eligibleRegistrants; + totals.nonExampleSubmissions += record.summaryCounts.nonExampleSubmissions; + totals.exampleSubmissionsFiltered += record.summaryCounts.exampleSubmissionsFiltered; + totals.plannedFinalScores += record.summaryCounts.plannedFinalScores; + totals.plannedProvisionalScores += record.summaryCounts.plannedProvisionalScores; + totals.finalistsWithoutAttachableSubmission += + record.summaryCounts.finalistsWithoutAttachableSubmission; + totals.toCreate.phases += record.entityDeltas.phases.toCreate; + totals.toCreate.resources += record.entityDeltas.resources.toCreate; + totals.toCreate.submissions += record.entityDeltas.submissions.toCreate; + totals.toCreate.finalScores += record.entityDeltas.finalScores.toCreate; + totals.toCreate.provisionalScores += record.entityDeltas.provisionalScores.toCreate; + }); + + return { + recordType: "plan-summary", + selectedRoundIds, + roundsRequested: selectedRoundIds.length, + countsByDecision, + totals, + }; +}; + +const buildRoundDataById = (selectedRoundIds) => { + const map = new Map(); + selectedRoundIds.forEach((roundId) => { + map.set(roundId, createEmptyCounters()); + }); + return map; +}; + +const readLegacyPlanningInputs = async (options, roundDataById) => { + const fixedFiles = { + round: resolveFilePath(options.dataDir, options.roundFile), + roundComponent: resolveFilePath(options.dataDir, options.roundComponentFile), + component: resolveFilePath(options.dataDir, options.componentFile), + problem: resolveFilePath(options.dataDir, options.problemFile), + longComponentState: resolveFilePath(options.dataDir, options.longComponentStateFile), + }; + + Object.entries(fixedFiles).forEach(([label, filePath]) => { + ensureFileExists(filePath, label); + }); + + const roundRegistrationFiles = listFilesByPattern( + options.dataDir, + options.roundRegistrationPattern, + "round registration" + ); + const longSubmissionFiles = listFilesByPattern( + options.dataDir, + options.longSubmissionPattern, + "long submission" + ); + const longCompResultFiles = listFilesByPattern( + options.dataDir, + options.longCompResultPattern, + "long comp result" + ); + + const selectedRoundIdSet = new Set(roundDataById.keys()); + const selectedComponentIds = new Set(); + const longComponentStateById = new Map(); + + await streamJsonArray(fixedFiles.round, "round", (row) => { + const roundId = String(row && row.round_id ? row.round_id : "").trim(); + if (!selectedRoundIdSet.has(roundId)) { + return; + } + roundDataById.get(roundId).round = row; + }); + + await streamJsonArray(fixedFiles.roundComponent, "round_component", (row) => { + const roundId = String(row && row.round_id ? row.round_id : "").trim(); + if (!selectedRoundIdSet.has(roundId)) { + return; + } + const componentId = String(row && row.component_id ? row.component_id : "").trim(); + if (!componentId) { + return; + } + roundDataById.get(roundId).componentIds.add(componentId); + selectedComponentIds.add(componentId); + }); + + await streamJsonArray(fixedFiles.component, "component", (row) => { + const componentId = String(row && row.component_id ? row.component_id : "").trim(); + if (!selectedComponentIds.has(componentId)) { + return; + } + const problemId = String(row && row.problem_id ? row.problem_id : "").trim(); + if (!problemId) { + return; + } + for (const counters of roundDataById.values()) { + if (counters.componentIds.has(componentId)) { + counters.problemIds.add(problemId); + } + } + }); + + await Promise.all( + roundRegistrationFiles.map((filePath) => + streamJsonArray(filePath, "round_registration", (row) => { + const roundId = String(row && row.round_id ? row.round_id : "").trim(); + if (!selectedRoundIdSet.has(roundId)) { + return; + } + const isEligible = String(row && row.eligible ? row.eligible : "").trim() === "1"; + if (!isEligible) { + return; + } + const coderId = String(row && row.coder_id ? row.coder_id : "").trim(); + if (!coderId) { + return; + } + roundDataById.get(roundId).eligibleRegistrants.add(coderId); + }) + ) + ); + + await streamJsonArray(fixedFiles.longComponentState, "long_component_state", (row) => { + const roundId = String(row && row.round_id ? row.round_id : "").trim(); + if (!selectedRoundIdSet.has(roundId)) { + return; + } + const longComponentStateId = String( + row && row.long_component_state_id ? row.long_component_state_id : "" + ).trim(); + if (!longComponentStateId) { + return; + } + const coderId = String(row && row.coder_id ? row.coder_id : "").trim(); + longComponentStateById.set(longComponentStateId, { + roundId, + coderId, + }); + }); + + await Promise.all( + longSubmissionFiles.map((filePath) => + streamJsonArray(filePath, "long_submission", (row) => { + const longComponentStateId = String( + row && row.long_component_state_id ? row.long_component_state_id : "" + ).trim(); + const stateInfo = longComponentStateById.get(longComponentStateId); + if (!stateInfo) { + return; + } + const counters = roundDataById.get(stateInfo.roundId); + if (!counters) { + return; + } + + const isExample = String(row && row.example ? row.example : "").trim() === "1"; + if (isExample) { + counters.exampleSubmissions += 1; + return; + } + counters.nonExampleSubmissions += 1; + if (stateInfo.coderId) { + counters.nonExampleSubmitterCoderIds.add(stateInfo.coderId); + } + }) + ) + ); + + await Promise.all( + longCompResultFiles.map((filePath) => + streamJsonArray(filePath, "long_comp_result", (row) => { + const roundId = String(row && row.round_id ? row.round_id : "").trim(); + if (!selectedRoundIdSet.has(roundId)) { + return; + } + if (!hasAnyFinalSignal(row)) { + return; + } + const coderId = String(row && row.coder_id ? row.coder_id : "").trim(); + if (!coderId) { + return; + } + roundDataById.get(roundId).finalCandidateCoderIds.add(coderId); + }) + ) + ); +}; + +const buildDryRunPlan = async (options, existingStateByRoundId) => { + const selectedRoundIds = [...options.roundIds]; + const roundDataById = buildRoundDataById(selectedRoundIds); + await readLegacyPlanningInputs(options, roundDataById); + + const records = selectedRoundIds.map((roundId) => + evaluateRoundPlan(roundId, roundDataById.get(roundId), existingStateByRoundId.get(roundId)) + ); + const summary = summarizePlan(records, selectedRoundIds); + return { records, summary }; +}; + +module.exports = { + buildDryRunPlan, +}; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/reporting.js b/data-migration/src/scripts/importHistoricalMarathonMatches/reporting.js new file mode 100644 index 0000000..9194054 --- /dev/null +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/reporting.js @@ -0,0 +1,12 @@ +"use strict"; + +const emitPlanReport = ({ records, summary }) => { + records.forEach((record) => { + process.stdout.write(`PLAN_RECORD ${JSON.stringify(record)}\n`); + }); + process.stdout.write(`PLAN_SUMMARY ${JSON.stringify(summary)}\n`); +}; + +module.exports = { + emitPlanReport, +}; diff --git a/data-migration/test/importHistoricalMarathonMatches.plan.test.js b/data-migration/test/importHistoricalMarathonMatches.plan.test.js new file mode 100644 index 0000000..14154ef --- /dev/null +++ b/data-migration/test/importHistoricalMarathonMatches.plan.test.js @@ -0,0 +1,250 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const scriptPath = path.resolve( + __dirname, + "../src/scripts/importHistoricalMarathonMatches.js" +); + +const writeJson = (baseDir, fileName, rootKey, rows) => { + fs.writeFileSync( + path.join(baseDir, fileName), + `${JSON.stringify({ [rootKey]: rows }, null, 2)}\n`, + "utf8" + ); +}; + +const buildFixtureDataDirectory = () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-plan-fixture-")); + + writeJson(baseDir, "round_1.json", "round", [ + { round_id: "9892", round_type_id: "13", name: "MM 9892", short_name: "MM 9892" }, + { round_id: "7000", round_type_id: "13", name: "MM 7000", short_name: "MM 7000" }, + ]); + + writeJson(baseDir, "round_component_1.json", "round_component", [ + { round_id: "9892", component_id: "5503" }, + { round_id: "9892", component_id: "5504" }, + { round_id: "7000", component_id: "7777" }, + ]); + + writeJson(baseDir, "component_1.json", "component", [ + { component_id: "5503", problem_id: "9001" }, + { component_id: "5504", problem_id: "9002" }, + { component_id: "7777", problem_id: "9999" }, + ]); + + writeJson(baseDir, "problem_1.json", "problem", [ + { problem_id: "9001" }, + { problem_id: "9002" }, + { problem_id: "9999" }, + ]); + + writeJson(baseDir, "long_component_state_1.json", "long_component_state", [ + { long_component_state_id: "lcs-1", round_id: "9892", coder_id: "1", component_id: "5503" }, + { long_component_state_id: "lcs-2", round_id: "9892", coder_id: "2", component_id: "5504" }, + { long_component_state_id: "lcs-3", round_id: "7000", coder_id: "8", component_id: "7777" }, + ]); + + writeJson(baseDir, "long_submission_1.json", "long_submission", [ + { long_component_state_id: "lcs-1", submission_number: "1", example: "0", submit_time: "100", submission_points: "10.0" }, + { long_component_state_id: "lcs-1", submission_number: "2", example: "1", submit_time: "101", submission_points: "11.0" }, + { long_component_state_id: "lcs-1", submission_number: "3", example: "0", submit_time: "102", submission_points: "12.0" }, + { long_component_state_id: "lcs-2", submission_number: "1", example: "0", submit_time: "103", submission_points: "13.0" }, + { long_component_state_id: "lcs-3", submission_number: "1", example: "0", submit_time: "104", submission_points: "14.0" }, + ]); + + writeJson(baseDir, "long_comp_result_1.json", "long_comp_result", [ + { round_id: "9892", coder_id: "1", system_point_total: "98.1", point_total: null, placed: "1" }, + { round_id: "9892", coder_id: "2", system_point_total: null, point_total: "91.5", placed: "2" }, + { round_id: "9892", coder_id: "3", system_point_total: null, point_total: null, placed: "3" }, + { round_id: "7000", coder_id: "8", system_point_total: "77.0", point_total: null, placed: "1" }, + ]); + + writeJson(baseDir, "round_registration_1.json", "round_registration", [ + { round_id: "9892", coder_id: "1", eligible: "1", timestamp: "2020-01-01 00:00:00.0" }, + { round_id: "9892", coder_id: "2", eligible: "1", timestamp: "2020-01-01 00:01:00.0" }, + { round_id: "9892", coder_id: "2", eligible: "1", timestamp: "2020-01-01 00:02:00.0" }, + { round_id: "9892", coder_id: "3", eligible: "0", timestamp: "2020-01-01 00:03:00.0" }, + { round_id: "7000", coder_id: "8", eligible: "1", timestamp: "2020-01-01 00:04:00.0" }, + ]); + + fs.writeFileSync( + path.join(baseDir, "existing-state.json"), + `${JSON.stringify( + { + rounds: [ + { + legacyRoundId: "9892", + challengeId: "e3f97773-2f76-4657-b22d-9cb5a95d310a", + existing: { + phases: 3, + resources: 2, + submissions: 3, + finalScores: 2, + provisionalScores: 3, + }, + }, + ], + }, + null, + 2 + )}\n`, + "utf8" + ); + + return baseDir; +}; + +const runImporter = (args, fixtureDir, extraEnv = {}) => + spawnSync(process.execPath, [scriptPath, ...args], { + env: { ...process.env, ...extraEnv }, + cwd: fixtureDir, + encoding: "utf8", + }); + +const parseRecords = (stdout) => + stdout + .split("\n") + .filter((line) => line.startsWith("PLAN_RECORD ")) + .map((line) => JSON.parse(line.replace("PLAN_RECORD ", ""))); + +describe("importHistoricalMarathonMatches CLI planning behavior", () => { + let fixtureDir; + + beforeEach(() => { + fixtureDir = buildFixtureDataDirectory(); + }); + + afterEach(() => { + fs.rmSync(fixtureDir, { recursive: true, force: true }); + }); + + test("help is safe and exits successfully without write-side env", () => { + const result = runImporter(["--help"], fixtureDir, { + DATABASE_URL: "", + REVIEW_DB_URL: "", + RESOURCES_API_URL: "", + AUTH0_CLIENT_ID: "", + AUTH0_CLIENT_SECRET: "", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Usage:"); + expect(result.stdout).toContain("--round-id"); + expect(result.stdout).toContain("--round-ids"); + expect(result.stdout).toContain("--dry-run"); + }); + + test("unknown options fail fast with actionable error", () => { + const result = runImporter(["--wat"], fixtureDir); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Unknown option: --wat"); + }); + + test("malformed round filters fail fast with actionable error", () => { + const result = runImporter(["--data-dir", fixtureDir, "--round-ids", "9892,abc"], fixtureDir); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Invalid round id value \"abc\""); + }); + + test("dry-run emits one deterministic parseable record per selected round including unmatched", () => { + const args = [ + "--data-dir", + fixtureDir, + "--dry-run", + "--round-id", + "9892", + "--round-ids", + " 9892,9999 ", + ]; + const firstRun = runImporter(args, fixtureDir); + const secondRun = runImporter(args, fixtureDir); + + expect(firstRun.status).toBe(0); + expect(secondRun.status).toBe(0); + expect(firstRun.stdout).toBe(secondRun.stdout); + + const records = parseRecords(firstRun.stdout); + expect(records).toHaveLength(2); + expect(records.map((entry) => entry.legacyRoundId)).toEqual(["9892", "9999"]); + + const matched = records.find((entry) => entry.legacyRoundId === "9892"); + expect(matched.decision).toBe("create"); + expect(matched.summaryCounts).toEqual({ + eligibleRegistrants: 2, + nonExampleSubmissions: 3, + exampleSubmissionsFiltered: 1, + plannedFinalScores: 2, + plannedProvisionalScores: 3, + finalistsWithoutAttachableSubmission: 1, + }); + expect(matched.traceability).toEqual({ + legacyRoundId: "9892", + legacyComponentIds: ["5503", "5504"], + legacyProblemIds: ["9001", "9002"], + }); + + const unmatched = records.find((entry) => entry.legacyRoundId === "9999"); + expect(unmatched.decision).toBe("unmatched"); + expect(unmatched.reason).toBe("selected-round-not-found-in-legacy-source"); + }); + + test("existing challenge snapshots produce reuse/backfill-only deltas and rerun no-op classification", () => { + const result = runImporter( + [ + "--data-dir", + fixtureDir, + "--dry-run", + "--round-id", + "9892", + "--existing-state-file", + path.join(fixtureDir, "existing-state.json"), + ], + fixtureDir + ); + + expect(result.status).toBe(0); + + const [record] = parseRecords(result.stdout); + expect(record.decision).toBe("reuse/backfill-only"); + expect(record.reason).toBe("existing-v6-challenge-found"); + expect(record.matchedChallengeId).toBe("e3f97773-2f76-4657-b22d-9cb5a95d310a"); + expect(record.rerunClassification).toBe("no-op"); + expect(record.entityDeltas.phases).toEqual({ + target: 3, + existing: 3, + toCreate: 0, + unchanged: 3, + }); + expect(record.entityDeltas.resources).toEqual({ + target: 2, + existing: 2, + toCreate: 0, + unchanged: 2, + }); + expect(record.entityDeltas.submissions).toEqual({ + target: 3, + existing: 3, + toCreate: 0, + unchanged: 3, + }); + expect(record.entityDeltas.finalScores).toEqual({ + target: 2, + existing: 2, + toCreate: 0, + unchanged: 2, + skippedUnattachableFinalists: 1, + }); + expect(record.entityDeltas.provisionalScores).toEqual({ + target: 3, + existing: 3, + toCreate: 0, + unchanged: 3, + }); + }); +}); From 13106e58b307b6587a79c1252fe203d01aeada61 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 1 Apr 2026 10:54:25 +1100 Subject: [PATCH 05/27] Implement create-path challenge and phase reconciliation for historical MM importer Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .factory/services.yaml | 3 +- .../importHistoricalMarathonMatches.js | 37 +- .../importHistoricalMarathonMatches/apply.js | 400 ++++++++++++++++++ .../argParser.js | 2 +- .../planning.js | 66 ++- .../reporting.js | 8 + ...ortHistoricalMarathonMatches.apply.test.js | 182 ++++++++ 7 files changed, 686 insertions(+), 12 deletions(-) create mode 100644 data-migration/src/scripts/importHistoricalMarathonMatches/apply.js create mode 100644 data-migration/test/importHistoricalMarathonMatches.apply.test.js diff --git a/.factory/services.yaml b/.factory/services.yaml index 37619f1..c4568e2 100644 --- a/.factory/services.yaml +++ b/.factory/services.yaml @@ -1,9 +1,10 @@ commands: install: source "$HOME/.config/nvm/nvm.sh" && nvm use >/dev/null && pnpm install && (cd data-migration && nvm use 18.19.0 >/dev/null && pnpm install) - test: source "$HOME/.config/nvm/nvm.sh" && (cd data-migration && nvm use 18.19.0 >/dev/null && pnpm test -- --maxWorkers=16) + test: source "$HOME/.config/nvm/nvm.sh" && (cd data-migration && nvm use 18.19.0 >/dev/null && pnpm test --maxWorkers=16 --testPathIgnorePatterns test/challenge-migration.test.js) lint: source "$HOME/.config/nvm/nvm.sh" && (cd data-migration && nvm use 18.19.0 >/dev/null && pnpm lint) typecheck: echo "No dedicated typecheck command for this JavaScript importer surface" build: echo "No build script is defined for the historical importer surface" root_smoke_test: source "$HOME/.config/nvm/nvm.sh" && nvm use >/dev/null && pnpm test + test_full_existing: source "$HOME/.config/nvm/nvm.sh" && (cd data-migration && nvm use 18.19.0 >/dev/null && pnpm test --maxWorkers=16) services: {} diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches.js b/data-migration/src/scripts/importHistoricalMarathonMatches.js index f85ba8e..23caf99 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches.js @@ -2,12 +2,17 @@ "use strict"; const path = require("path"); +const { createRequire } = require("module"); const dotenv = require("dotenv"); const { parseArgs, usage } = require("./importHistoricalMarathonMatches/argParser"); const { buildDryRunPlan } = require("./importHistoricalMarathonMatches/planning"); -const { emitPlanReport } = require("./importHistoricalMarathonMatches/reporting"); +const { runApplyMode } = require("./importHistoricalMarathonMatches/apply"); +const { emitPlanReport, emitApplyReport } = require("./importHistoricalMarathonMatches/reporting"); const { loadExistingState } = require("./importHistoricalMarathonMatches/existingState"); +const appRoot = path.resolve(__dirname, "..", "..", ".."); +const requireFromRoot = createRequire(path.join(appRoot, "package.json")); + dotenv.config({ path: path.resolve(__dirname, "..", "..", "..", ".env.importer.local"), override: false, @@ -15,6 +20,9 @@ dotenv.config({ }); dotenv.config({ quiet: true }); +const DEFAULT_ACTOR = + process.env.UPDATED_BY || process.env.CREATED_BY || "historical-mm-importer"; + const run = async () => { const options = parseArgs(process.argv.slice(2)); @@ -23,15 +31,28 @@ const run = async () => { return; } - if (options.apply) { - throw new Error( - "Apply mode is not available in this planning milestone. Use --dry-run to generate reconciliation output." - ); - } - const existingStateByRoundId = loadExistingState(options.dataDir, options.existingStateFile); const plan = await buildDryRunPlan(options, existingStateByRoundId); - emitPlanReport(plan); + if (!options.apply) { + emitPlanReport(plan); + return; + } + + // Lazy load Prisma only when apply mode is requested so --help / dry-run + // keep working in environments without generated client artifacts. + const { PrismaClient } = requireFromRoot("@prisma/client"); + const prisma = new PrismaClient(); + try { + const applyResult = await runApplyMode({ + prisma, + options, + plan, + actor: DEFAULT_ACTOR, + }); + emitApplyReport(applyResult); + } finally { + await prisma.$disconnect(); + } }; run().catch((error) => { diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js new file mode 100644 index 0000000..f6fc5dc --- /dev/null +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js @@ -0,0 +1,400 @@ +"use strict"; + +const STANDARD_PHASE_NAMES = ["Registration", "Submission", "Review"]; + +const parseRoundLegacyId = (roundId) => { + const parsed = Number.parseInt(String(roundId || "").trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Invalid legacy round id "${roundId}"`); + } + return parsed; +}; + +const derivePhaseWindows = (roundId, counters) => { + const registrationStartMs = counters && counters.registrationStartMs; + const registrationEndMs = counters && counters.registrationEndMs; + const latestSubmissionMs = counters && counters.latestNonExampleSubmitMs; + const earliestSubmissionOpenMs = counters && counters.earliestSubmissionOpenMs; + const earliestSubmissionMs = counters && counters.earliestNonExampleSubmitMs; + + if (!Number.isFinite(registrationStartMs) || !Number.isFinite(registrationEndMs)) { + throw new Error( + `Round ${roundId} is missing eligible registration timestamps needed for phase derivation.` + ); + } + if (!Number.isFinite(latestSubmissionMs)) { + throw new Error( + `Round ${roundId} is missing non-example submission timestamps needed for phase derivation.` + ); + } + + const registrationStart = Math.min(registrationStartMs, registrationEndMs); + const registrationEnd = Math.max(registrationStartMs, registrationEndMs); + + const rawSubmissionStartMs = Number.isFinite(earliestSubmissionOpenMs) + ? earliestSubmissionOpenMs + : earliestSubmissionMs; + if (!Number.isFinite(rawSubmissionStartMs)) { + throw new Error( + `Round ${roundId} is missing both submission open_time and non-example submission start timestamps.` + ); + } + + const submissionStart = Math.min(rawSubmissionStartMs, latestSubmissionMs); + const submissionEnd = Math.max(rawSubmissionStartMs, latestSubmissionMs); + const reviewStart = submissionEnd; + const reviewEnd = submissionEnd; + + return { + registration: { + startDate: new Date(registrationStart), + endDate: new Date(registrationEnd), + }, + submission: { + startDate: new Date(submissionStart), + endDate: new Date(submissionEnd), + }, + review: { + startDate: new Date(reviewStart), + endDate: new Date(reviewEnd), + }, + }; +}; + +const phaseDurationSeconds = (startDate, endDate) => + Math.max(0, Math.floor((endDate.getTime() - startDate.getTime()) / 1000)); + +const buildChallengePhaseRows = ({ challengeId, phaseIdsByName, windows, actor }) => { + const rows = []; + + STANDARD_PHASE_NAMES.forEach((phaseName) => { + const phaseId = phaseIdsByName[phaseName]; + if (!phaseId) { + throw new Error(`Missing phase id for standard phase "${phaseName}"`); + } + const window = windows[phaseName.toLowerCase()]; + if (!window) { + throw new Error(`Missing phase window for standard phase "${phaseName}"`); + } + + rows.push({ + challengeId, + phaseId, + name: phaseName, + isOpen: false, + duration: phaseDurationSeconds(window.startDate, window.endDate), + scheduledStartDate: window.startDate, + scheduledEndDate: window.endDate, + actualStartDate: window.startDate, + actualEndDate: window.endDate, + createdBy: actor, + updatedBy: actor, + }); + }); + + return rows; +}; + +const buildChallengeCreateData = ({ + roundId, + round, + actor, + marathonTypeId, + dataScienceTrackId, + timelineTemplateId, + counters, + windows, +}) => { + const legacyId = parseRoundLegacyId(roundId); + const registrationCount = counters && counters.eligibleRegistrants ? counters.eligibleRegistrants.size : 0; + const submissionCount = counters && Number.isFinite(counters.nonExampleSubmissions) + ? counters.nonExampleSubmissions + : 0; + + return { + legacyId, + name: + String((round && (round.short_name || round.name)) || "").trim() || + `Historical Marathon Match ${legacyId}`, + description: `Imported historical Marathon Match from legacy round ${legacyId}`, + typeId: marathonTypeId, + trackId: dataScienceTrackId, + timelineTemplateId, + status: "COMPLETED", + currentPhaseNames: [], + tags: [], + groups: [], + numOfRegistrants: registrationCount, + numOfSubmissions: submissionCount, + registrationStartDate: windows.registration.startDate, + registrationEndDate: windows.registration.endDate, + submissionStartDate: windows.submission.startDate, + submissionEndDate: windows.submission.endDate, + startDate: windows.registration.startDate, + endDate: windows.review.endDate, + createdBy: actor, + updatedBy: actor, + }; +}; + +const applyCreateRound = async ({ + prisma, + roundId, + round, + counters, + actor, + marathonTypeId, + dataScienceTrackId, + timelineTemplateId, + phaseIdsByName, +}) => { + const legacyId = parseRoundLegacyId(roundId); + + return prisma.$transaction(async (tx) => { + const existing = await tx.challenge.findMany({ + where: { legacyId }, + select: { id: true }, + take: 2, + }); + if (existing.length > 0) { + return { + status: "existing", + challengeId: existing[0].id, + legacyRoundId: roundId, + }; + } + + const windows = derivePhaseWindows(roundId, counters); + const challenge = await tx.challenge.create({ + data: buildChallengeCreateData({ + roundId, + round, + actor, + marathonTypeId, + dataScienceTrackId, + timelineTemplateId, + counters, + windows, + }), + select: { id: true }, + }); + + const phaseRows = buildChallengePhaseRows({ + challengeId: challenge.id, + phaseIdsByName, + windows, + actor, + }); + await tx.challengePhase.createMany({ data: phaseRows }); + + return { + status: "created", + challengeId: challenge.id, + legacyRoundId: roundId, + }; + }); +}; + +const requireSingleMatch = (items, label) => { + if (items.length !== 1) { + throw new Error(`Expected exactly one ${label}, found ${items.length}.`); + } + return items[0]; +}; + +const resolveMarathonTypeId = async (prisma) => { + const candidates = await prisma.challengeType.findMany({ + where: { + OR: [{ name: { equals: "Marathon Match", mode: "insensitive" } }, { abbreviation: "MM" }], + }, + select: { id: true }, + }); + return requireSingleMatch(candidates, "Marathon Match challenge type").id; +}; + +const resolveDataScienceTrackId = async (prisma) => { + const candidates = await prisma.challengeTrack.findMany({ + where: { + OR: [ + { name: { equals: "Data Science", mode: "insensitive" } }, + { abbreviation: "DS" }, + { track: "DATA_SCIENCE" }, + ], + }, + select: { id: true }, + }); + return requireSingleMatch(candidates, "Data Science track").id; +}; + +const resolveStandardPhaseIds = async (prisma) => { + const phases = await prisma.phase.findMany({ + where: { name: { in: STANDARD_PHASE_NAMES } }, + select: { id: true, name: true }, + }); + const grouped = phases.reduce((acc, phase) => { + if (!acc[phase.name]) { + acc[phase.name] = []; + } + acc[phase.name].push(phase.id); + return acc; + }, {}); + + const result = {}; + STANDARD_PHASE_NAMES.forEach((phaseName) => { + const ids = grouped[phaseName] || []; + if (ids.length !== 1) { + throw new Error(`Expected exactly one "${phaseName}" phase row, found ${ids.length}.`); + } + result[phaseName] = ids[0]; + }); + return result; +}; + +const hasStandardMarathonShape = (phaseNames) => { + if (!Array.isArray(phaseNames) || phaseNames.length !== STANDARD_PHASE_NAMES.length) { + return false; + } + const normalized = phaseNames.map((name) => String(name || "").trim().toLowerCase()); + const unique = new Set(normalized); + if (unique.size !== STANDARD_PHASE_NAMES.length) { + return false; + } + return STANDARD_PHASE_NAMES.every((name) => unique.has(name.toLowerCase())); +}; + +const resolveCanonicalTimelineTemplateId = async (prisma, marathonTypeId, dataScienceTrackId) => { + const mappings = await prisma.challengeTimelineTemplate.findMany({ + where: { typeId: marathonTypeId, trackId: dataScienceTrackId }, + select: { + id: true, + isDefault: true, + timelineTemplateId: true, + timelineTemplate: { + select: { + id: true, + phases: { select: { phaseId: true } }, + }, + }, + }, + }); + if (mappings.length === 0) { + throw new Error("No ChallengeTimelineTemplate mappings found for Marathon Match/Data Science."); + } + + const phaseIds = Array.from( + new Set( + mappings.flatMap((mapping) => + (mapping.timelineTemplate && mapping.timelineTemplate.phases) || [] + ).map((phase) => phase.phaseId) + ) + ); + const phaseRows = phaseIds.length + ? await prisma.phase.findMany({ where: { id: { in: phaseIds } }, select: { id: true, name: true } }) + : []; + const phaseNameById = new Map(phaseRows.map((phase) => [phase.id, phase.name])); + + const valid = mappings.filter((mapping) => { + const phaseNames = (mapping.timelineTemplate && mapping.timelineTemplate.phases) || []; + const names = phaseNames + .map((phase) => phaseNameById.get(phase.phaseId)) + .filter((name) => Boolean(name)); + return hasStandardMarathonShape(names); + }); + + if (valid.length === 0) { + throw new Error( + "No canonical Marathon Match/Data Science timeline template mapping found with Registration/Submission/Review shape." + ); + } + if (valid.length === 1) { + return valid[0].timelineTemplateId; + } + + const defaultCandidates = valid.filter((candidate) => candidate.isDefault); + if (defaultCandidates.length === 1) { + return defaultCandidates[0].timelineTemplateId; + } + + throw new Error( + `Expected one canonical Marathon Match/Data Science timeline mapping, found ${valid.length} valid candidates.` + ); +}; + +const runApplyMode = async ({ prisma, options, plan, actor }) => { + const marathonTypeId = await resolveMarathonTypeId(prisma); + const dataScienceTrackId = await resolveDataScienceTrackId(prisma); + const phaseIdsByName = await resolveStandardPhaseIds(prisma); + const timelineTemplateId = await resolveCanonicalTimelineTemplateId( + prisma, + marathonTypeId, + dataScienceTrackId + ); + + const applyRecords = []; + for (const roundId of options.roundIds) { + const counters = plan.roundDataById.get(roundId); + if (!counters || !counters.round) { + applyRecords.push({ + recordType: "apply-record", + legacyRoundId: roundId, + status: "unmatched", + reason: "selected-round-not-found-in-legacy-source", + }); + continue; + } + + try { + const result = await applyCreateRound({ + prisma, + roundId, + round: counters.round, + counters, + actor, + marathonTypeId, + dataScienceTrackId, + timelineTemplateId, + phaseIdsByName, + }); + applyRecords.push({ + recordType: "apply-record", + legacyRoundId: roundId, + status: result.status, + challengeId: result.challengeId, + }); + } catch (error) { + applyRecords.push({ + recordType: "apply-record", + legacyRoundId: roundId, + status: "error", + reason: error.message, + }); + throw error; + } + } + + const summary = applyRecords.reduce( + (acc, record) => { + if (record.status === "created") { + acc.created += 1; + } else if (record.status === "existing") { + acc.existing += 1; + } else if (record.status === "unmatched") { + acc.unmatched += 1; + } else if (record.status === "error") { + acc.errors += 1; + } + return acc; + }, + { recordType: "apply-summary", created: 0, existing: 0, unmatched: 0, errors: 0 } + ); + + return { records: applyRecords, summary }; +}; + +module.exports = { + STANDARD_PHASE_NAMES, + derivePhaseWindows, + buildChallengePhaseRows, + applyCreateRound, + runApplyMode, +}; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js b/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js index 246ec8c..5330bf3 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js @@ -177,7 +177,7 @@ Input options: --long-comp-result-pattern Regex for long_comp_result files (default: ^long_comp_result_\\d+\\.json$) Apply mode: - --apply Reserved for later milestones (not available yet) + --apply Apply reconciliation writes (challenge + phase create path) Other: --help, -h Show this help diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js b/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js index 718e31e..ab7c516 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js @@ -16,6 +16,11 @@ const createEmptyCounters = () => ({ exampleSubmissions: 0, nonExampleSubmitterCoderIds: new Set(), finalCandidateCoderIds: new Set(), + registrationStartMs: null, + registrationEndMs: null, + earliestSubmissionOpenMs: null, + earliestNonExampleSubmitMs: null, + latestNonExampleSubmitMs: null, }); const sortIds = (values) => @@ -36,6 +41,50 @@ const parseNonNegativeInteger = (value) => { return parsed; }; +const parseLegacySqlTimestamp = (value) => { + const normalized = String(value || "").trim(); + if (!normalized || normalized.toLowerCase() === "null") { + return null; + } + const isoLike = normalized.includes("T") + ? normalized + : normalized.replace(" ", "T"); + const withZone = /([+-]\d{2}:?\d{2}|Z)$/i.test(isoLike) ? isoLike : `${isoLike}Z`; + const parsed = Date.parse(withZone); + if (!Number.isFinite(parsed)) { + return null; + } + return parsed; +}; + +const parseEpochMs = (value) => { + const parsed = Number.parseInt(String(value || "").trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +}; + +const minMs = (left, right) => { + if (!Number.isFinite(left)) { + return Number.isFinite(right) ? right : null; + } + if (!Number.isFinite(right)) { + return left; + } + return Math.min(left, right); +}; + +const maxMs = (left, right) => { + if (!Number.isFinite(left)) { + return Number.isFinite(right) ? right : null; + } + if (!Number.isFinite(right)) { + return left; + } + return Math.max(left, right); +}; + const hasAnyFinalSignal = (finalResultRow) => { const candidates = [ finalResultRow && finalResultRow.system_point_total, @@ -341,7 +390,12 @@ const readLegacyPlanningInputs = async (options, roundDataById) => { if (!coderId) { return; } - roundDataById.get(roundId).eligibleRegistrants.add(coderId); + const counters = roundDataById.get(roundId); + counters.eligibleRegistrants.add(coderId); + + const registrationMs = parseLegacySqlTimestamp(row.timestamp); + counters.registrationStartMs = minMs(counters.registrationStartMs, registrationMs); + counters.registrationEndMs = maxMs(counters.registrationEndMs, registrationMs); }) ) ); @@ -379,12 +433,20 @@ const readLegacyPlanningInputs = async (options, roundDataById) => { return; } + const submissionOpenMs = parseEpochMs(row && row.open_time); + counters.earliestSubmissionOpenMs = minMs(counters.earliestSubmissionOpenMs, submissionOpenMs); + const isExample = String(row && row.example ? row.example : "").trim() === "1"; if (isExample) { counters.exampleSubmissions += 1; return; } counters.nonExampleSubmissions += 1; + + const submitMs = parseEpochMs(row && row.submit_time); + counters.earliestNonExampleSubmitMs = minMs(counters.earliestNonExampleSubmitMs, submitMs); + counters.latestNonExampleSubmitMs = maxMs(counters.latestNonExampleSubmitMs, submitMs); + if (stateInfo.coderId) { counters.nonExampleSubmitterCoderIds.add(stateInfo.coderId); } @@ -421,7 +483,7 @@ const buildDryRunPlan = async (options, existingStateByRoundId) => { evaluateRoundPlan(roundId, roundDataById.get(roundId), existingStateByRoundId.get(roundId)) ); const summary = summarizePlan(records, selectedRoundIds); - return { records, summary }; + return { records, summary, roundDataById }; }; module.exports = { diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/reporting.js b/data-migration/src/scripts/importHistoricalMarathonMatches/reporting.js index 9194054..f287975 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/reporting.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/reporting.js @@ -7,6 +7,14 @@ const emitPlanReport = ({ records, summary }) => { process.stdout.write(`PLAN_SUMMARY ${JSON.stringify(summary)}\n`); }; +const emitApplyReport = ({ records, summary }) => { + records.forEach((record) => { + process.stdout.write(`APPLY_RECORD ${JSON.stringify(record)}\n`); + }); + process.stdout.write(`APPLY_SUMMARY ${JSON.stringify(summary)}\n`); +}; + module.exports = { emitPlanReport, + emitApplyReport, }; diff --git a/data-migration/test/importHistoricalMarathonMatches.apply.test.js b/data-migration/test/importHistoricalMarathonMatches.apply.test.js new file mode 100644 index 0000000..6a9ac12 --- /dev/null +++ b/data-migration/test/importHistoricalMarathonMatches.apply.test.js @@ -0,0 +1,182 @@ +const { + derivePhaseWindows, + buildChallengePhaseRows, + applyCreateRound, +} = require("../src/scripts/importHistoricalMarathonMatches/apply"); + +describe("importHistoricalMarathonMatches apply create-path behavior", () => { + test("derives coherent closed MM phase windows from legacy activity", () => { + const windows = derivePhaseWindows("9892", { + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + }); + + expect(windows.registration.startDate.toISOString()).toBe("2020-01-01T00:00:00.000Z"); + expect(windows.registration.endDate.toISOString()).toBe("2020-01-01T12:00:00.000Z"); + expect(windows.submission.startDate.toISOString()).toBe("2020-01-01T01:00:00.000Z"); + expect(windows.submission.endDate.toISOString()).toBe("2020-01-02T00:00:00.000Z"); + expect(windows.review.startDate.toISOString()).toBe("2020-01-02T00:00:00.000Z"); + expect(windows.review.endDate.toISOString()).toBe("2020-01-02T00:00:00.000Z"); + }); + + test("falls back to earliest non-example submit when open_time is missing", () => { + const windows = derivePhaseWindows("9892", { + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: null, + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + }); + + expect(windows.submission.startDate.toISOString()).toBe("2020-01-01T02:00:00.000Z"); + }); + + test("phase row builder materializes exactly one closed Registration/Submission/Review trio", () => { + const windows = derivePhaseWindows("9892", { + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + }); + + const rows = buildChallengePhaseRows({ + challengeId: "challenge-1", + actor: "importer", + phaseIdsByName: { + Registration: "phase-registration", + Submission: "phase-submission", + Review: "phase-review", + }, + windows, + }); + + expect(rows).toHaveLength(3); + expect(rows.map((row) => row.name)).toEqual(["Registration", "Submission", "Review"]); + rows.forEach((row) => { + expect(row.isOpen).toBe(false); + expect(row.actualStartDate).toBeInstanceOf(Date); + expect(row.actualEndDate).toBeInstanceOf(Date); + expect(row.actualEndDate.getTime()).toBeGreaterThanOrEqual(row.actualStartDate.getTime()); + }); + }); + + test("apply create-path inserts one completed challenge and phase trio for missing rounds", async () => { + const calls = { createdChallenge: null, createdPhases: null }; + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockImplementation(async ({ data }) => { + calls.createdChallenge = data; + return { id: "challenge-1" }; + }), + }, + challengePhase: { + createMany: jest.fn().mockImplementation(async ({ data }) => { + calls.createdPhases = data; + return { count: data.length }; + }), + }, + }; + + const prisma = { + $transaction: async (callback) => callback(tx), + }; + + const result = await applyCreateRound({ + prisma, + roundId: "9892", + round: { round_id: "9892", name: "Intel Multi-Threading Competition 2", short_name: "Intel 2" }, + counters: { + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2"]), + nonExampleSubmissions: 3, + }, + actor: "importer", + marathonTypeId: "type-mm", + dataScienceTrackId: "track-ds", + timelineTemplateId: "timeline-mm", + phaseIdsByName: { + Registration: "phase-registration", + Submission: "phase-submission", + Review: "phase-review", + }, + }); + + expect(result).toEqual({ + status: "created", + challengeId: "challenge-1", + legacyRoundId: "9892", + }); + expect(calls.createdChallenge).toMatchObject({ + legacyId: 9892, + typeId: "type-mm", + trackId: "track-ds", + timelineTemplateId: "timeline-mm", + status: "COMPLETED", + currentPhaseNames: [], + numOfRegistrants: 2, + numOfSubmissions: 3, + }); + expect(calls.createdPhases).toHaveLength(3); + expect(calls.createdPhases.map((row) => row.name)).toEqual([ + "Registration", + "Submission", + "Review", + ]); + }); + + test("apply create-path is idempotent when challenge already exists", async () => { + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([{ id: "existing-challenge-1" }]), + create: jest.fn(), + }, + challengePhase: { + createMany: jest.fn(), + }, + }; + const prisma = { + $transaction: async (callback) => callback(tx), + }; + + const result = await applyCreateRound({ + prisma, + roundId: "9892", + round: { round_id: "9892" }, + counters: { + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2"]), + nonExampleSubmissions: 3, + }, + actor: "importer", + marathonTypeId: "type-mm", + dataScienceTrackId: "track-ds", + timelineTemplateId: "timeline-mm", + phaseIdsByName: { + Registration: "phase-registration", + Submission: "phase-submission", + Review: "phase-review", + }, + }); + + expect(result).toEqual({ + status: "existing", + challengeId: "existing-challenge-1", + legacyRoundId: "9892", + }); + expect(tx.challenge.create).not.toHaveBeenCalled(); + expect(tx.challengePhase.createMany).not.toHaveBeenCalled(); + }); +}); From 29d9b4211134fc9deed48308e9f9414b3c16c61c Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 1 Apr 2026 11:00:03 +1100 Subject: [PATCH 06/27] Harden reused MM matching and phase backfill safety --- .../importHistoricalMarathonMatches/apply.js | 77 ++++++- ...ortHistoricalMarathonMatches.apply.test.js | 210 +++++++++++++++++- 2 files changed, 282 insertions(+), 5 deletions(-) diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js index f6fc5dc..f60b9d4 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js @@ -137,6 +137,29 @@ const buildChallengeCreateData = ({ }; }; +const countStandardPhaseRows = (phaseRows) => { + const counts = {}; + STANDARD_PHASE_NAMES.forEach((phaseName) => { + counts[phaseName] = 0; + }); + phaseRows.forEach((phaseRow) => { + if (counts[phaseRow.name] !== undefined) { + counts[phaseRow.name] += 1; + } + }); + return counts; +}; + +const findMissingStandardPhaseNames = (phaseRows) => { + const counts = countStandardPhaseRows(phaseRows); + STANDARD_PHASE_NAMES.forEach((phaseName) => { + if (counts[phaseName] > 1) { + throw new Error(`Matched challenge has duplicate "${phaseName}" phase rows.`); + } + }); + return STANDARD_PHASE_NAMES.filter((phaseName) => counts[phaseName] === 0); +}; + const applyCreateRound = async ({ prisma, roundId, @@ -153,13 +176,59 @@ const applyCreateRound = async ({ return prisma.$transaction(async (tx) => { const existing = await tx.challenge.findMany({ where: { legacyId }, - select: { id: true }, - take: 2, + select: { id: true, typeId: true, trackId: true }, + take: 3, }); - if (existing.length > 0) { + if (existing.length > 1) { + throw new Error( + `Round ${roundId} matched multiple existing v6 challenges by legacyId ${legacyId}; refusing unsafe reuse.` + ); + } + if (existing.length === 1) { + const existingChallenge = existing[0]; + if ( + existingChallenge.typeId !== marathonTypeId || + existingChallenge.trackId !== dataScienceTrackId + ) { + throw new Error( + `Round ${roundId} matched challenge ${existingChallenge.id} but it cannot be reused because it is not Marathon Match / Data Science.` + ); + } + + const existingStandardPhases = await tx.challengePhase.findMany({ + where: { + challengeId: existingChallenge.id, + name: { in: STANDARD_PHASE_NAMES }, + }, + select: { + id: true, + name: true, + isOpen: true, + scheduledStartDate: true, + scheduledEndDate: true, + actualStartDate: true, + actualEndDate: true, + }, + }); + const missingPhaseNames = findMissingStandardPhaseNames(existingStandardPhases); + + if (missingPhaseNames.length > 0) { + const windows = derivePhaseWindows(roundId, counters); + const newPhaseRows = buildChallengePhaseRows({ + challengeId: existingChallenge.id, + phaseIdsByName, + windows, + actor, + }).filter((phaseRow) => missingPhaseNames.includes(phaseRow.name)); + + if (newPhaseRows.length > 0) { + await tx.challengePhase.createMany({ data: newPhaseRows }); + } + } + return { status: "existing", - challengeId: existing[0].id, + challengeId: existingChallenge.id, legacyRoundId: roundId, }; } diff --git a/data-migration/test/importHistoricalMarathonMatches.apply.test.js b/data-migration/test/importHistoricalMarathonMatches.apply.test.js index 6a9ac12..2927836 100644 --- a/data-migration/test/importHistoricalMarathonMatches.apply.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.apply.test.js @@ -134,12 +134,77 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { }); test("apply create-path is idempotent when challenge already exists", async () => { + const calls = { createdPhases: null }; const tx = { challenge: { - findMany: jest.fn().mockResolvedValue([{ id: "existing-challenge-1" }]), + findMany: jest.fn().mockResolvedValue([ + { id: "existing-challenge-1", typeId: "type-mm", trackId: "track-ds" }, + ]), create: jest.fn(), }, challengePhase: { + findMany: jest.fn().mockResolvedValue([ + { id: "cp-1", name: "Registration", isOpen: false }, + { id: "cp-2", name: "Submission", isOpen: false }, + ]), + createMany: jest.fn().mockImplementation(async ({ data }) => { + calls.createdPhases = data; + return { count: data.length }; + }), + }, + }; + const prisma = { + $transaction: async (callback) => callback(tx), + }; + + const result = await applyCreateRound({ + prisma, + roundId: "9892", + round: { round_id: "9892" }, + counters: { + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2"]), + nonExampleSubmissions: 3, + }, + actor: "importer", + marathonTypeId: "type-mm", + dataScienceTrackId: "track-ds", + timelineTemplateId: "timeline-mm", + phaseIdsByName: { + Registration: "phase-registration", + Submission: "phase-submission", + Review: "phase-review", + }, + }); + + expect(result).toEqual({ + status: "existing", + challengeId: "existing-challenge-1", + legacyRoundId: "9892", + }); + expect(tx.challenge.create).not.toHaveBeenCalled(); + expect(calls.createdPhases).toHaveLength(1); + expect(calls.createdPhases[0].name).toBe("Review"); + }); + + test("reuse path is idempotent when all standard phases already exist", async () => { + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([ + { id: "existing-challenge-1", typeId: "type-mm", trackId: "track-ds" }, + ]), + create: jest.fn(), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([ + { id: "cp-1", name: "Registration", isOpen: false }, + { id: "cp-2", name: "Submission", isOpen: false }, + { id: "cp-3", name: "Review", isOpen: false }, + ]), createMany: jest.fn(), }, }; @@ -179,4 +244,147 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { expect(tx.challenge.create).not.toHaveBeenCalled(); expect(tx.challengePhase.createMany).not.toHaveBeenCalled(); }); + + test("reuse path rejects non-MM/DS challenge shape", async () => { + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([ + { id: "existing-challenge-1", typeId: "type-dev", trackId: "track-dev" }, + ]), + create: jest.fn(), + }, + challengePhase: { + findMany: jest.fn(), + createMany: jest.fn(), + }, + }; + const prisma = { + $transaction: async (callback) => callback(tx), + }; + + await expect( + applyCreateRound({ + prisma, + roundId: "9892", + round: { round_id: "9892" }, + counters: { + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2"]), + nonExampleSubmissions: 3, + }, + actor: "importer", + marathonTypeId: "type-mm", + dataScienceTrackId: "track-ds", + timelineTemplateId: "timeline-mm", + phaseIdsByName: { + Registration: "phase-registration", + Submission: "phase-submission", + Review: "phase-review", + }, + }) + ).rejects.toThrow("cannot be reused because it is not Marathon Match / Data Science"); + expect(tx.challenge.create).not.toHaveBeenCalled(); + expect(tx.challengePhase.createMany).not.toHaveBeenCalled(); + }); + + test("reuse path rejects ambiguous duplicate legacy challenge matches", async () => { + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([ + { id: "existing-challenge-1", typeId: "type-mm", trackId: "track-ds" }, + { id: "existing-challenge-2", typeId: "type-mm", trackId: "track-ds" }, + ]), + create: jest.fn(), + }, + challengePhase: { + findMany: jest.fn(), + createMany: jest.fn(), + }, + }; + const prisma = { + $transaction: async (callback) => callback(tx), + }; + + await expect( + applyCreateRound({ + prisma, + roundId: "9892", + round: { round_id: "9892" }, + counters: { + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2"]), + nonExampleSubmissions: 3, + }, + actor: "importer", + marathonTypeId: "type-mm", + dataScienceTrackId: "track-ds", + timelineTemplateId: "timeline-mm", + phaseIdsByName: { + Registration: "phase-registration", + Submission: "phase-submission", + Review: "phase-review", + }, + }) + ).rejects.toThrow("multiple existing v6 challenges"); + expect(tx.challenge.create).not.toHaveBeenCalled(); + expect(tx.challengePhase.createMany).not.toHaveBeenCalled(); + }); + + test("reuse path rejects duplicate standard phase rows", async () => { + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([ + { id: "existing-challenge-1", typeId: "type-mm", trackId: "track-ds" }, + ]), + create: jest.fn(), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([ + { id: "cp-1", name: "Registration", isOpen: false }, + { id: "cp-2", name: "Submission", isOpen: false }, + { id: "cp-3", name: "Submission", isOpen: false }, + ]), + createMany: jest.fn(), + }, + }; + const prisma = { + $transaction: async (callback) => callback(tx), + }; + + await expect( + applyCreateRound({ + prisma, + roundId: "9892", + round: { round_id: "9892" }, + counters: { + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2"]), + nonExampleSubmissions: 3, + }, + actor: "importer", + marathonTypeId: "type-mm", + dataScienceTrackId: "track-ds", + timelineTemplateId: "timeline-mm", + phaseIdsByName: { + Registration: "phase-registration", + Submission: "phase-submission", + Review: "phase-review", + }, + }) + ).rejects.toThrow('duplicate "Submission" phase rows'); + expect(tx.challenge.create).not.toHaveBeenCalled(); + expect(tx.challengePhase.createMany).not.toHaveBeenCalled(); + }); }); From 83c8ac843def95d2b578e3aefd9ca943dd6d6923 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 1 Apr 2026 11:34:46 +1100 Subject: [PATCH 07/27] Use authoritative existing-v6 matching for MM planning and apply Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../importHistoricalMarathonMatches.js | 68 +++++-- .../importHistoricalMarathonMatches/apply.js | 55 ++++- .../argParser.js | 2 +- .../existingState.js | 192 ++++++++++++++++++ .../planning.js | 138 ++++++++----- ...ortHistoricalMarathonMatches.apply.test.js | 100 +++++++++ ...ricalMarathonMatches.existingState.test.js | 183 +++++++++++++++++ ...portHistoricalMarathonMatches.plan.test.js | 62 +++--- 8 files changed, 684 insertions(+), 116 deletions(-) create mode 100644 data-migration/test/importHistoricalMarathonMatches.existingState.test.js diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches.js b/data-migration/src/scripts/importHistoricalMarathonMatches.js index 23caf99..98bf885 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches.js @@ -6,9 +6,16 @@ const { createRequire } = require("module"); const dotenv = require("dotenv"); const { parseArgs, usage } = require("./importHistoricalMarathonMatches/argParser"); const { buildDryRunPlan } = require("./importHistoricalMarathonMatches/planning"); -const { runApplyMode } = require("./importHistoricalMarathonMatches/apply"); +const { + runApplyMode, + resolveMarathonTypeId, + resolveDataScienceTrackId, +} = require("./importHistoricalMarathonMatches/apply"); const { emitPlanReport, emitApplyReport } = require("./importHistoricalMarathonMatches/reporting"); -const { loadExistingState } = require("./importHistoricalMarathonMatches/existingState"); +const { + loadExistingState, + buildExistingStateByRoundId, +} = require("./importHistoricalMarathonMatches/existingState"); const appRoot = path.resolve(__dirname, "..", "..", ".."); const requireFromRoot = createRequire(path.join(appRoot, "package.json")); @@ -31,18 +38,53 @@ const run = async () => { return; } - const existingStateByRoundId = loadExistingState(options.dataDir, options.existingStateFile); - const plan = await buildDryRunPlan(options, existingStateByRoundId); - if (!options.apply) { - emitPlanReport(plan); - return; + const snapshotByRoundId = loadExistingState(options.dataDir, options.existingStateFile); + const shouldAttemptDatabaseDiscovery = + options.apply || Boolean(String(process.env.DATABASE_URL || "").trim()); + let prisma = null; + + if (shouldAttemptDatabaseDiscovery) { + const { PrismaClient } = requireFromRoot("@prisma/client"); + prisma = new PrismaClient(); } - // Lazy load Prisma only when apply mode is requested so --help / dry-run - // keep working in environments without generated client artifacts. - const { PrismaClient } = requireFromRoot("@prisma/client"); - const prisma = new PrismaClient(); try { + let existingStateByRoundId = null; + if (prisma) { + try { + const marathonTypeId = await resolveMarathonTypeId(prisma); + const dataScienceTrackId = await resolveDataScienceTrackId(prisma); + existingStateByRoundId = await buildExistingStateByRoundId({ + prisma, + roundIds: options.roundIds, + marathonTypeId, + dataScienceTrackId, + snapshotByRoundId, + }); + } catch (error) { + if (options.apply) { + throw error; + } + process.stderr.write( + `Warning: unable to discover existing v6 state directly (${error.message}); continuing dry-run without reuse matching.\n` + ); + } + } + + if (!existingStateByRoundId) { + existingStateByRoundId = await buildExistingStateByRoundId({ + prisma: null, + roundIds: options.roundIds, + snapshotByRoundId, + }); + } + + const plan = await buildDryRunPlan(options, existingStateByRoundId); + if (!options.apply) { + emitPlanReport(plan); + return; + } + const applyResult = await runApplyMode({ prisma, options, @@ -51,7 +93,9 @@ const run = async () => { }); emitApplyReport(applyResult); } finally { - await prisma.$disconnect(); + if (prisma) { + await prisma.$disconnect(); + } } }; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js index f60b9d4..f110731 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js @@ -390,24 +390,53 @@ const resolveCanonicalTimelineTemplateId = async (prisma, marathonTypeId, dataSc }; const runApplyMode = async ({ prisma, options, plan, actor }) => { - const marathonTypeId = await resolveMarathonTypeId(prisma); - const dataScienceTrackId = await resolveDataScienceTrackId(prisma); - const phaseIdsByName = await resolveStandardPhaseIds(prisma); - const timelineTemplateId = await resolveCanonicalTimelineTemplateId( - prisma, - marathonTypeId, - dataScienceTrackId - ); + const planRecordByRoundId = new Map((plan.records || []).map((record) => [record.legacyRoundId, record])); + const actionableRoundIds = options.roundIds.filter((roundId) => { + const counters = plan.roundDataById.get(roundId); + if (!counters || !counters.round) { + return false; + } + const decision = planRecordByRoundId.get(roundId) && planRecordByRoundId.get(roundId).decision; + return decision === "create" || decision === "reuse/backfill-only"; + }); + + let marathonTypeId = null; + let dataScienceTrackId = null; + let phaseIdsByName = null; + let timelineTemplateId = null; + if (actionableRoundIds.length > 0) { + marathonTypeId = await resolveMarathonTypeId(prisma); + dataScienceTrackId = await resolveDataScienceTrackId(prisma); + phaseIdsByName = await resolveStandardPhaseIds(prisma); + timelineTemplateId = await resolveCanonicalTimelineTemplateId( + prisma, + marathonTypeId, + dataScienceTrackId + ); + } const applyRecords = []; for (const roundId of options.roundIds) { const counters = plan.roundDataById.get(roundId); - if (!counters || !counters.round) { + const planRecord = planRecordByRoundId.get(roundId); + const decision = planRecord && planRecord.decision; + if (!counters || !counters.round || decision === "unmatched") { applyRecords.push({ recordType: "apply-record", legacyRoundId: roundId, status: "unmatched", - reason: "selected-round-not-found-in-legacy-source", + reason: + (planRecord && planRecord.reason) || "selected-round-not-found-in-legacy-source", + }); + continue; + } + + if (decision !== "create" && decision !== "reuse/backfill-only") { + applyRecords.push({ + recordType: "apply-record", + legacyRoundId: roundId, + status: "unresolved", + reason: (planRecord && planRecord.reason) || "round-not-actionable-for-apply", }); continue; } @@ -449,12 +478,14 @@ const runApplyMode = async ({ prisma, options, plan, actor }) => { acc.existing += 1; } else if (record.status === "unmatched") { acc.unmatched += 1; + } else if (record.status === "unresolved") { + acc.unresolved += 1; } else if (record.status === "error") { acc.errors += 1; } return acc; }, - { recordType: "apply-summary", created: 0, existing: 0, unmatched: 0, errors: 0 } + { recordType: "apply-summary", created: 0, existing: 0, unmatched: 0, unresolved: 0, errors: 0 } ); return { records: applyRecords, summary }; @@ -465,5 +496,7 @@ module.exports = { derivePhaseWindows, buildChallengePhaseRows, applyCreateRound, + resolveMarathonTypeId, + resolveDataScienceTrackId, runApplyMode, }; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js b/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js index 5330bf3..ed23eca 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js @@ -163,7 +163,7 @@ Planning options: --round-id Select one round id (repeatable) --round-ids Select comma-separated round ids --dry-run Build a non-mutating deterministic reconciliation plan (default) - --existing-state-file Optional JSON snapshot for matched challenge ids + existing entity counts + --existing-state-file Optional snapshot for offline entity-count hints (not authoritative reuse matching) Input options: --data-dir Legacy data directory (default: DATA_DIRECTORY or /mnt/Informix) diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/existingState.js b/data-migration/src/scripts/importHistoricalMarathonMatches/existingState.js index 7e983db..49b0be6 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/existingState.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/existingState.js @@ -3,6 +3,8 @@ const fs = require("fs"); const path = require("path"); +const STANDARD_PHASE_NAMES = ["Registration", "Submission", "Review"]; + const safeParseObject = (raw, filePath) => { try { const parsed = JSON.parse(raw); @@ -55,6 +57,195 @@ const entriesFromPayload = (payload) => { .filter(Boolean); }; +const parseNonNegativeInteger = (value) => { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return 0; + } + return parsed; +}; + +const normalizeExistingCounts = (existing = {}) => ({ + phases: parseNonNegativeInteger(existing.phases), + resources: parseNonNegativeInteger(existing.resources), + submissions: parseNonNegativeInteger(existing.submissions), + finalScores: parseNonNegativeInteger(existing.finalScores), + provisionalScores: parseNonNegativeInteger(existing.provisionalScores), +}); + +const parseLegacyRoundIdAsInteger = (roundId) => { + const parsed = Number.parseInt(String(roundId || "").trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +}; + +const hasDuplicateStandardPhase = (phaseRows = []) => { + const counts = {}; + phaseRows.forEach((row) => { + const name = String(row && row.name ? row.name : "").trim(); + if (!STANDARD_PHASE_NAMES.includes(name)) { + return; + } + counts[name] = (counts[name] || 0) + 1; + }); + return Object.values(counts).some((count) => count > 1); +}; + +const normalizeSnapshotCountsForChallenge = (snapshotEntry, matchedChallengeId) => { + if (!snapshotEntry || !snapshotEntry.existing) { + return normalizeExistingCounts(); + } + + const snapshotChallengeId = snapshotEntry.challengeId ? String(snapshotEntry.challengeId) : null; + if (snapshotChallengeId && snapshotChallengeId !== String(matchedChallengeId)) { + return normalizeExistingCounts(); + } + return normalizeExistingCounts(snapshotEntry.existing); +}; + +const buildDefaultExistingStateEntry = (legacyRoundId) => ({ + legacyRoundId, + matchStatus: "none", + reason: "no-matching-v6-challenge-found", + challengeId: null, + existing: normalizeExistingCounts(), +}); + +const buildExistingStateByRoundId = async ({ + prisma, + roundIds, + marathonTypeId, + dataScienceTrackId, + snapshotByRoundId = new Map(), +}) => { + const byRoundId = new Map(); + roundIds.forEach((roundId) => { + byRoundId.set(roundId, buildDefaultExistingStateEntry(roundId)); + }); + + if (!prisma) { + return byRoundId; + } + + const legacyRoundIds = Array.from( + new Set( + roundIds + .map((roundId) => parseLegacyRoundIdAsInteger(roundId)) + .filter((legacyRoundId) => Number.isFinite(legacyRoundId)) + ) + ); + if (legacyRoundIds.length === 0) { + return byRoundId; + } + + const challengeRows = await prisma.challenge.findMany({ + where: { + legacyId: { + in: legacyRoundIds, + }, + }, + select: { + id: true, + legacyId: true, + typeId: true, + trackId: true, + }, + }); + const challengeIds = challengeRows.map((row) => row.id); + const phaseRows = challengeIds.length + ? await prisma.challengePhase.findMany({ + where: { + challengeId: { in: challengeIds }, + name: { in: STANDARD_PHASE_NAMES }, + }, + select: { + challengeId: true, + name: true, + }, + }) + : []; + + const challengeRowsByLegacyRoundId = new Map(); + challengeRows.forEach((row) => { + const legacyRoundId = String(row.legacyId); + if (!challengeRowsByLegacyRoundId.has(legacyRoundId)) { + challengeRowsByLegacyRoundId.set(legacyRoundId, []); + } + challengeRowsByLegacyRoundId.get(legacyRoundId).push(row); + }); + + const phaseRowsByChallengeId = new Map(); + phaseRows.forEach((row) => { + if (!phaseRowsByChallengeId.has(row.challengeId)) { + phaseRowsByChallengeId.set(row.challengeId, []); + } + phaseRowsByChallengeId.get(row.challengeId).push(row); + }); + + roundIds.forEach((roundId) => { + const candidates = challengeRowsByLegacyRoundId.get(roundId) || []; + if (candidates.length === 0) { + byRoundId.set(roundId, buildDefaultExistingStateEntry(roundId)); + return; + } + + if (candidates.length > 1) { + byRoundId.set(roundId, { + legacyRoundId: roundId, + matchStatus: "ambiguous", + reason: "existing-v6-challenge-match-ambiguous", + challengeId: null, + existing: normalizeExistingCounts(), + }); + return; + } + + const [candidate] = candidates; + const candidatePhaseRows = phaseRowsByChallengeId.get(candidate.id) || []; + + if (candidate.typeId !== marathonTypeId || candidate.trackId !== dataScienceTrackId) { + byRoundId.set(roundId, { + legacyRoundId: roundId, + matchStatus: "unsafe", + reason: "matched-v6-challenge-not-marathon-match-data-science", + challengeId: candidate.id, + existing: normalizeExistingCounts(), + }); + return; + } + if (hasDuplicateStandardPhase(candidatePhaseRows)) { + byRoundId.set(roundId, { + legacyRoundId: roundId, + matchStatus: "unsafe", + reason: "matched-v6-challenge-has-duplicate-standard-phases", + challengeId: candidate.id, + existing: normalizeExistingCounts(), + }); + return; + } + + const snapshotEntry = snapshotByRoundId.get(roundId); + const snapshotCounts = normalizeSnapshotCountsForChallenge(snapshotEntry, candidate.id); + byRoundId.set(roundId, { + legacyRoundId: roundId, + matchStatus: "safe", + reason: "existing-v6-challenge-found", + challengeId: candidate.id, + existing: { + phases: candidatePhaseRows.length, + resources: snapshotCounts.resources, + submissions: snapshotCounts.submissions, + finalScores: snapshotCounts.finalScores, + provisionalScores: snapshotCounts.provisionalScores, + }, + }); + }); + + return byRoundId; +}; + const loadExistingState = (baseDir, filePath) => { if (!filePath) { return new Map(); @@ -77,4 +268,5 @@ const loadExistingState = (baseDir, filePath) => { module.exports = { loadExistingState, + buildExistingStateByRoundId, }; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js b/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js index ab7c516..6658b8e 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js @@ -109,6 +109,47 @@ const buildEntityDelta = (target, existing) => { }; }; +const buildRoundSummaryCounts = ({ + counters, + plannedFinalScores = 0, + plannedProvisionalScores = 0, + finalistsWithoutAttachableSubmission = 0, +}) => ({ + eligibleRegistrants: counters.eligibleRegistrants.size, + nonExampleSubmissions: counters.nonExampleSubmissions, + exampleSubmissionsFiltered: counters.exampleSubmissions, + plannedFinalScores, + plannedProvisionalScores, + finalistsWithoutAttachableSubmission, +}); + +const buildZeroEntityDeltas = () => ({ + phases: buildEntityDelta(0, 0), + resources: buildEntityDelta(0, 0), + submissions: buildEntityDelta(0, 0), + finalScores: { ...buildEntityDelta(0, 0), skippedUnattachableFinalists: 0 }, + provisionalScores: buildEntityDelta(0, 0), +}); + +const buildUnresolvedRecord = ({ roundId, reason, counters, traceability, matchedChallengeId = null }) => ({ + recordType: "round-plan", + legacyRoundId: roundId, + decision: "unresolved", + reason, + matchedChallengeId, + rerunClassification: "unresolved", + traceability, + summaryCounts: buildRoundSummaryCounts({ + counters, + plannedFinalScores: 0, + plannedProvisionalScores: 0, + finalistsWithoutAttachableSubmission: 0, + }), + entityDeltas: buildZeroEntityDeltas(), +}); + +const isMarathonRoundType = (round) => String(round && round.round_type_id ? round.round_type_id : "").trim() === "13"; + const evaluateRoundPlan = (roundId, counters, existingStateEntry) => { if (!counters.round) { return { @@ -123,24 +164,28 @@ const evaluateRoundPlan = (roundId, counters, existingStateEntry) => { legacyComponentIds: [], legacyProblemIds: [], }, - summaryCounts: { - eligibleRegistrants: 0, - nonExampleSubmissions: 0, - exampleSubmissionsFiltered: 0, - plannedFinalScores: 0, - plannedProvisionalScores: 0, - finalistsWithoutAttachableSubmission: 0, - }, - entityDeltas: { - phases: buildEntityDelta(0, 0), - resources: buildEntityDelta(0, 0), - submissions: buildEntityDelta(0, 0), - finalScores: { ...buildEntityDelta(0, 0), skippedUnattachableFinalists: 0 }, - provisionalScores: buildEntityDelta(0, 0), - }, + summaryCounts: buildRoundSummaryCounts({ + counters: createEmptyCounters(), + }), + entityDeltas: buildZeroEntityDeltas(), }; } + const traceability = { + legacyRoundId: roundId, + legacyComponentIds: sortIds(counters.componentIds), + legacyProblemIds: sortIds(counters.problemIds), + }; + + if (!isMarathonRoundType(counters.round)) { + return buildUnresolvedRecord({ + roundId, + reason: "selected-round-round-type-is-not-marathon-match", + counters, + traceability, + }); + } + const hasMarathonSignals = counters.componentIds.size > 0 && (counters.nonExampleSubmissions > 0 || @@ -149,34 +194,12 @@ const evaluateRoundPlan = (roundId, counters, existingStateEntry) => { counters.eligibleRegistrants.size > 0); if (!hasMarathonSignals) { - return { - recordType: "round-plan", - legacyRoundId: roundId, - decision: "unresolved", + return buildUnresolvedRecord({ + roundId, reason: "selected-round-lacks-marathon-signal-data", - matchedChallengeId: null, - rerunClassification: "unresolved", - traceability: { - legacyRoundId: roundId, - legacyComponentIds: sortIds(counters.componentIds), - legacyProblemIds: sortIds(counters.problemIds), - }, - summaryCounts: { - eligibleRegistrants: counters.eligibleRegistrants.size, - nonExampleSubmissions: counters.nonExampleSubmissions, - exampleSubmissionsFiltered: counters.exampleSubmissions, - plannedFinalScores: 0, - plannedProvisionalScores: 0, - finalistsWithoutAttachableSubmission: 0, - }, - entityDeltas: { - phases: buildEntityDelta(0, 0), - resources: buildEntityDelta(0, 0), - submissions: buildEntityDelta(0, 0), - finalScores: { ...buildEntityDelta(0, 0), skippedUnattachableFinalists: 0 }, - provisionalScores: buildEntityDelta(0, 0), - }, - }; + counters, + traceability, + }); } const finalAttachableMemberCount = Array.from(counters.finalCandidateCoderIds).filter((coderId) => @@ -195,6 +218,19 @@ const evaluateRoundPlan = (roundId, counters, existingStateEntry) => { provisionalScores: counters.nonExampleSubmissions, }; + const matchStatus = existingStateEntry && existingStateEntry.matchStatus + ? existingStateEntry.matchStatus + : "none"; + if (matchStatus === "ambiguous" || matchStatus === "unsafe") { + return buildUnresolvedRecord({ + roundId, + reason: existingStateEntry.reason, + counters, + traceability, + matchedChallengeId: existingStateEntry.challengeId || null, + }); + } + const existingCounts = existingStateEntry && existingStateEntry.existing ? existingStateEntry.existing : {}; const entityDeltas = { phases: buildEntityDelta(targets.phases, existingCounts.phases), @@ -207,11 +243,11 @@ const evaluateRoundPlan = (roundId, counters, existingStateEntry) => { provisionalScores: buildEntityDelta(targets.provisionalScores, existingCounts.provisionalScores), }; - const hasMatchedChallenge = Boolean(existingStateEntry && existingStateEntry.challengeId); + const hasMatchedChallenge = matchStatus === "safe" && Boolean(existingStateEntry.challengeId); const decision = hasMatchedChallenge ? "reuse/backfill-only" : "create"; const reason = hasMatchedChallenge ? "existing-v6-challenge-found" - : "no-matching-v6-challenge-in-provided-state"; + : "no-matching-v6-challenge-found"; const rerunClassification = decision === "reuse/backfill-only" && Object.values(entityDeltas) @@ -229,19 +265,13 @@ const evaluateRoundPlan = (roundId, counters, existingStateEntry) => { reason, matchedChallengeId: hasMatchedChallenge ? existingStateEntry.challengeId : null, rerunClassification, - traceability: { - legacyRoundId: roundId, - legacyComponentIds: sortIds(counters.componentIds), - legacyProblemIds: sortIds(counters.problemIds), - }, - summaryCounts: { - eligibleRegistrants: counters.eligibleRegistrants.size, - nonExampleSubmissions: counters.nonExampleSubmissions, - exampleSubmissionsFiltered: counters.exampleSubmissions, + traceability, + summaryCounts: buildRoundSummaryCounts({ + counters, plannedFinalScores: finalAttachableMemberCount, plannedProvisionalScores: counters.nonExampleSubmissions, finalistsWithoutAttachableSubmission, - }, + }), entityDeltas, }; }; diff --git a/data-migration/test/importHistoricalMarathonMatches.apply.test.js b/data-migration/test/importHistoricalMarathonMatches.apply.test.js index 2927836..4394bb3 100644 --- a/data-migration/test/importHistoricalMarathonMatches.apply.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.apply.test.js @@ -2,6 +2,7 @@ const { derivePhaseWindows, buildChallengePhaseRows, applyCreateRound, + runApplyMode, } = require("../src/scripts/importHistoricalMarathonMatches/apply"); describe("importHistoricalMarathonMatches apply create-path behavior", () => { @@ -387,4 +388,103 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { expect(tx.challenge.create).not.toHaveBeenCalled(); expect(tx.challengePhase.createMany).not.toHaveBeenCalled(); }); + + test("apply mode skips unresolved planned rounds instead of creating challenges", async () => { + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn(), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([]), + createMany: jest.fn(), + }, + }; + + const phaseRows = [ + { id: "phase-registration", name: "Registration" }, + { id: "phase-submission", name: "Submission" }, + { id: "phase-review", name: "Review" }, + ]; + + const prisma = { + challengeType: { + findMany: jest.fn().mockResolvedValue([{ id: "type-mm" }]), + }, + challengeTrack: { + findMany: jest.fn().mockResolvedValue([{ id: "track-ds" }]), + }, + phase: { + findMany: jest + .fn() + .mockResolvedValueOnce(phaseRows) + .mockResolvedValueOnce(phaseRows), + }, + challengeTimelineTemplate: { + findMany: jest.fn().mockResolvedValue([ + { + timelineTemplateId: "timeline-mm", + isDefault: true, + timelineTemplate: { + phases: [ + { phaseId: "phase-registration" }, + { phaseId: "phase-submission" }, + { phaseId: "phase-review" }, + ], + }, + }, + ]), + }, + $transaction: async (callback) => callback(tx), + }; + + const result = await runApplyMode({ + prisma, + options: { roundIds: ["7000"] }, + plan: { + records: [ + { + legacyRoundId: "7000", + decision: "unresolved", + reason: "selected-round-round-type-is-not-marathon-match", + }, + ], + roundDataById: new Map([ + [ + "7000", + { + round: { round_id: "7000", round_type_id: "1" }, + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2"]), + nonExampleSubmissions: 3, + }, + ], + ]), + }, + actor: "importer", + }); + + expect(result.records).toEqual([ + { + recordType: "apply-record", + legacyRoundId: "7000", + status: "unresolved", + reason: "selected-round-round-type-is-not-marathon-match", + }, + ]); + expect(result.summary).toEqual({ + recordType: "apply-summary", + created: 0, + existing: 0, + unmatched: 0, + unresolved: 1, + errors: 0, + }); + expect(tx.challenge.create).not.toHaveBeenCalled(); + expect(tx.challengePhase.createMany).not.toHaveBeenCalled(); + }); }); diff --git a/data-migration/test/importHistoricalMarathonMatches.existingState.test.js b/data-migration/test/importHistoricalMarathonMatches.existingState.test.js new file mode 100644 index 0000000..85ecdb9 --- /dev/null +++ b/data-migration/test/importHistoricalMarathonMatches.existingState.test.js @@ -0,0 +1,183 @@ +const { + buildExistingStateByRoundId, +} = require("../src/scripts/importHistoricalMarathonMatches/existingState"); + +describe("importHistoricalMarathonMatches existing v6 state discovery", () => { + test("does not use snapshot-only challenge ids as authoritative reuse matches", async () => { + const snapshotByRoundId = new Map([ + [ + "9892", + { + legacyRoundId: "9892", + challengeId: "snapshot-challenge", + existing: { + phases: 3, + resources: 2, + submissions: 3, + finalScores: 2, + provisionalScores: 3, + }, + }, + ], + ]); + + const existingStateByRoundId = await buildExistingStateByRoundId({ + prisma: null, + roundIds: ["9892"], + marathonTypeId: "type-mm", + dataScienceTrackId: "track-ds", + snapshotByRoundId, + }); + + expect(existingStateByRoundId.get("9892")).toEqual({ + legacyRoundId: "9892", + matchStatus: "none", + reason: "no-matching-v6-challenge-found", + challengeId: null, + existing: { + phases: 0, + resources: 0, + submissions: 0, + finalScores: 0, + provisionalScores: 0, + }, + }); + }); + + test("returns safe match for a unique MM/DS challenge and merges phase + snapshot counts", async () => { + const prisma = { + challenge: { + findMany: jest.fn().mockResolvedValue([ + { + id: "challenge-1", + legacyId: 9892, + typeId: "type-mm", + trackId: "track-ds", + }, + ]), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([ + { challengeId: "challenge-1", name: "Registration" }, + { challengeId: "challenge-1", name: "Submission" }, + ]), + }, + }; + + const existingStateByRoundId = await buildExistingStateByRoundId({ + prisma, + roundIds: ["9892"], + marathonTypeId: "type-mm", + dataScienceTrackId: "track-ds", + snapshotByRoundId: new Map([ + [ + "9892", + { + challengeId: "challenge-1", + existing: { + resources: 2, + submissions: 3, + finalScores: 2, + provisionalScores: 3, + }, + }, + ], + ]), + }); + + expect(existingStateByRoundId.get("9892")).toEqual({ + legacyRoundId: "9892", + matchStatus: "safe", + reason: "existing-v6-challenge-found", + challengeId: "challenge-1", + existing: { + phases: 2, + resources: 2, + submissions: 3, + finalScores: 2, + provisionalScores: 3, + }, + }); + }); + + test("marks duplicate legacy matches as ambiguous", async () => { + const prisma = { + challenge: { + findMany: jest.fn().mockResolvedValue([ + { id: "challenge-1", legacyId: 9892, typeId: "type-mm", trackId: "track-ds" }, + { id: "challenge-2", legacyId: 9892, typeId: "type-mm", trackId: "track-ds" }, + ]), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([]), + }, + }; + + const existingStateByRoundId = await buildExistingStateByRoundId({ + prisma, + roundIds: ["9892"], + marathonTypeId: "type-mm", + dataScienceTrackId: "track-ds", + snapshotByRoundId: new Map(), + }); + + expect(existingStateByRoundId.get("9892").matchStatus).toBe("ambiguous"); + expect(existingStateByRoundId.get("9892").reason).toBe("existing-v6-challenge-match-ambiguous"); + }); + + test("marks non-MM/DS challenge matches as unsafe", async () => { + const prisma = { + challenge: { + findMany: jest.fn().mockResolvedValue([ + { id: "challenge-1", legacyId: 9892, typeId: "type-dev", trackId: "track-dev" }, + ]), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([]), + }, + }; + + const existingStateByRoundId = await buildExistingStateByRoundId({ + prisma, + roundIds: ["9892"], + marathonTypeId: "type-mm", + dataScienceTrackId: "track-ds", + snapshotByRoundId: new Map(), + }); + + expect(existingStateByRoundId.get("9892").matchStatus).toBe("unsafe"); + expect(existingStateByRoundId.get("9892").reason).toBe( + "matched-v6-challenge-not-marathon-match-data-science" + ); + }); + + test("marks duplicate standard phase rows as unsafe", async () => { + const prisma = { + challenge: { + findMany: jest.fn().mockResolvedValue([ + { id: "challenge-1", legacyId: 9892, typeId: "type-mm", trackId: "track-ds" }, + ]), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([ + { challengeId: "challenge-1", name: "Registration" }, + { challengeId: "challenge-1", name: "Submission" }, + { challengeId: "challenge-1", name: "Submission" }, + ]), + }, + }; + + const existingStateByRoundId = await buildExistingStateByRoundId({ + prisma, + roundIds: ["9892"], + marathonTypeId: "type-mm", + dataScienceTrackId: "track-ds", + snapshotByRoundId: new Map(), + }); + + expect(existingStateByRoundId.get("9892").matchStatus).toBe("unsafe"); + expect(existingStateByRoundId.get("9892").reason).toBe( + "matched-v6-challenge-has-duplicate-standard-phases" + ); + }); +}); diff --git a/data-migration/test/importHistoricalMarathonMatches.plan.test.js b/data-migration/test/importHistoricalMarathonMatches.plan.test.js index 14154ef..baa920d 100644 --- a/data-migration/test/importHistoricalMarathonMatches.plan.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.plan.test.js @@ -21,7 +21,7 @@ const buildFixtureDataDirectory = () => { writeJson(baseDir, "round_1.json", "round", [ { round_id: "9892", round_type_id: "13", name: "MM 9892", short_name: "MM 9892" }, - { round_id: "7000", round_type_id: "13", name: "MM 7000", short_name: "MM 7000" }, + { round_id: "7000", round_type_id: "1", name: "Algo 7000", short_name: "Algo 7000" }, ]); writeJson(baseDir, "round_component_1.json", "round_component", [ @@ -100,7 +100,7 @@ const buildFixtureDataDirectory = () => { const runImporter = (args, fixtureDir, extraEnv = {}) => spawnSync(process.execPath, [scriptPath, ...args], { - env: { ...process.env, ...extraEnv }, + env: { ...process.env, DATABASE_URL: "", ...extraEnv }, cwd: fixtureDir, encoding: "utf8", }); @@ -194,7 +194,25 @@ describe("importHistoricalMarathonMatches CLI planning behavior", () => { expect(unmatched.reason).toBe("selected-round-not-found-in-legacy-source"); }); - test("existing challenge snapshots produce reuse/backfill-only deltas and rerun no-op classification", () => { + test("non-marathon rounds are rejected before planning decisions are emitted", () => { + const result = runImporter( + [ + "--data-dir", + fixtureDir, + "--dry-run", + "--round-id", + "7000", + ], + fixtureDir + ); + + expect(result.status).toBe(0); + const [record] = parseRecords(result.stdout); + expect(record.decision).toBe("unresolved"); + expect(record.reason).toBe("selected-round-round-type-is-not-marathon-match"); + }); + + test("existing state snapshots do not drive reuse classification in dry-run", () => { const result = runImporter( [ "--data-dir", @@ -211,40 +229,8 @@ describe("importHistoricalMarathonMatches CLI planning behavior", () => { expect(result.status).toBe(0); const [record] = parseRecords(result.stdout); - expect(record.decision).toBe("reuse/backfill-only"); - expect(record.reason).toBe("existing-v6-challenge-found"); - expect(record.matchedChallengeId).toBe("e3f97773-2f76-4657-b22d-9cb5a95d310a"); - expect(record.rerunClassification).toBe("no-op"); - expect(record.entityDeltas.phases).toEqual({ - target: 3, - existing: 3, - toCreate: 0, - unchanged: 3, - }); - expect(record.entityDeltas.resources).toEqual({ - target: 2, - existing: 2, - toCreate: 0, - unchanged: 2, - }); - expect(record.entityDeltas.submissions).toEqual({ - target: 3, - existing: 3, - toCreate: 0, - unchanged: 3, - }); - expect(record.entityDeltas.finalScores).toEqual({ - target: 2, - existing: 2, - toCreate: 0, - unchanged: 2, - skippedUnattachableFinalists: 1, - }); - expect(record.entityDeltas.provisionalScores).toEqual({ - target: 3, - existing: 3, - toCreate: 0, - unchanged: 3, - }); + expect(record.decision).toBe("create"); + expect(record.reason).toBe("no-matching-v6-challenge-found"); + expect(record.matchedChallengeId).toBe(null); }); }); From 765e7d7f26dc4a1c6a9815bd866421be6131f5a3 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 1 Apr 2026 11:42:01 +1100 Subject: [PATCH 08/27] Align create-path planning with phase derivation and rerun backfill convergence Dry-run now derives and reports explicit MM/Data Science challenge shape plus Registration/Submission/Review phase plans before returning create, and unresolveds when that plan cannot be derived so apply-time reconciliation stays consistent. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../planning.js | 54 ++++++++++ ...ortHistoricalMarathonMatches.apply.test.js | 99 +++++++++++++++++++ ...portHistoricalMarathonMatches.plan.test.js | 47 +++++++++ 3 files changed, 200 insertions(+) diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js b/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js index 6658b8e..11d56db 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js @@ -6,6 +6,10 @@ const { resolveFilePath, streamJsonArray, } = require("./legacyDataReader"); +const { + STANDARD_PHASE_NAMES, + derivePhaseWindows, +} = require("./apply"); const createEmptyCounters = () => ({ round: null, @@ -146,8 +150,38 @@ const buildUnresolvedRecord = ({ roundId, reason, counters, traceability, matche finalistsWithoutAttachableSubmission: 0, }), entityDeltas: buildZeroEntityDeltas(), + createPathChallengeShape: null, + createPathPhasePlan: null, +}); + +const formatIsoDate = (value) => { + if (!(value instanceof Date) || Number.isNaN(value.getTime())) { + return null; + } + return value.toISOString(); +}; + +const buildCreatePathChallengeShape = () => ({ + type: "Marathon Match", + track: "Data Science", + status: "COMPLETED", + phaseNames: [...STANDARD_PHASE_NAMES], }); +const buildCreatePathPhasePlan = (roundId, counters) => { + const windows = derivePhaseWindows(roundId, counters); + return STANDARD_PHASE_NAMES.reduce((acc, phaseName) => { + const key = phaseName.toLowerCase(); + const window = windows[key]; + acc[phaseName] = { + isOpen: false, + startDate: formatIsoDate(window && window.startDate), + endDate: formatIsoDate(window && window.endDate), + }; + return acc; + }, {}); +}; + const isMarathonRoundType = (round) => String(round && round.round_type_id ? round.round_type_id : "").trim() === "13"; const evaluateRoundPlan = (roundId, counters, existingStateEntry) => { @@ -168,6 +202,8 @@ const evaluateRoundPlan = (roundId, counters, existingStateEntry) => { counters: createEmptyCounters(), }), entityDeltas: buildZeroEntityDeltas(), + createPathChallengeShape: null, + createPathPhasePlan: null, }; } @@ -244,6 +280,22 @@ const evaluateRoundPlan = (roundId, counters, existingStateEntry) => { }; const hasMatchedChallenge = matchStatus === "safe" && Boolean(existingStateEntry.challengeId); + let createPathChallengeShape = null; + let createPathPhasePlan = null; + if (!hasMatchedChallenge) { + try { + createPathChallengeShape = buildCreatePathChallengeShape(); + createPathPhasePlan = buildCreatePathPhasePlan(roundId, counters); + } catch { + return buildUnresolvedRecord({ + roundId, + reason: "create-phase-plan-derivation-failed", + counters, + traceability, + }); + } + } + const decision = hasMatchedChallenge ? "reuse/backfill-only" : "create"; const reason = hasMatchedChallenge ? "existing-v6-challenge-found" @@ -273,6 +325,8 @@ const evaluateRoundPlan = (roundId, counters, existingStateEntry) => { finalistsWithoutAttachableSubmission, }), entityDeltas, + createPathChallengeShape, + createPathPhasePlan, }; }; diff --git a/data-migration/test/importHistoricalMarathonMatches.apply.test.js b/data-migration/test/importHistoricalMarathonMatches.apply.test.js index 4394bb3..8c11155 100644 --- a/data-migration/test/importHistoricalMarathonMatches.apply.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.apply.test.js @@ -246,6 +246,105 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { expect(tx.challengePhase.createMany).not.toHaveBeenCalled(); }); + test("apply-mode reruns converge create decisions by backfilling missing standard phases", async () => { + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([ + { id: "existing-challenge-1", typeId: "type-mm", trackId: "track-ds" }, + ]), + create: jest.fn(), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([ + { id: "cp-1", name: "Registration", isOpen: false }, + { id: "cp-2", name: "Submission", isOpen: false }, + ]), + createMany: jest.fn(), + }, + }; + + const phaseRows = [ + { id: "phase-registration", name: "Registration" }, + { id: "phase-submission", name: "Submission" }, + { id: "phase-review", name: "Review" }, + ]; + + const prisma = { + challengeType: { + findMany: jest.fn().mockResolvedValue([{ id: "type-mm" }]), + }, + challengeTrack: { + findMany: jest.fn().mockResolvedValue([{ id: "track-ds" }]), + }, + phase: { + findMany: jest + .fn() + .mockResolvedValueOnce(phaseRows) + .mockResolvedValueOnce(phaseRows), + }, + challengeTimelineTemplate: { + findMany: jest.fn().mockResolvedValue([ + { + timelineTemplateId: "timeline-mm", + isDefault: true, + timelineTemplate: { + phases: [ + { phaseId: "phase-registration" }, + { phaseId: "phase-submission" }, + { phaseId: "phase-review" }, + ], + }, + }, + ]), + }, + $transaction: async (callback) => callback(tx), + }; + + const result = await runApplyMode({ + prisma, + options: { roundIds: ["9892"] }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "create", + reason: "no-matching-v6-challenge-found", + }, + ], + roundDataById: new Map([ + [ + "9892", + { + round: { round_id: "9892", round_type_id: "13" }, + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2"]), + nonExampleSubmissions: 3, + }, + ], + ]), + }, + actor: "importer", + }); + + expect(result.records).toEqual([ + { + recordType: "apply-record", + legacyRoundId: "9892", + status: "existing", + challengeId: "existing-challenge-1", + }, + ]); + expect(tx.challenge.create).not.toHaveBeenCalled(); + expect(tx.challengePhase.createMany).toHaveBeenCalledTimes(1); + expect(tx.challengePhase.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ name: "Review", challengeId: "existing-challenge-1" })], + }); + }); + test("reuse path rejects non-MM/DS challenge shape", async () => { const tx = { challenge: { diff --git a/data-migration/test/importHistoricalMarathonMatches.plan.test.js b/data-migration/test/importHistoricalMarathonMatches.plan.test.js index baa920d..4755be1 100644 --- a/data-migration/test/importHistoricalMarathonMatches.plan.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.plan.test.js @@ -188,12 +188,59 @@ describe("importHistoricalMarathonMatches CLI planning behavior", () => { legacyComponentIds: ["5503", "5504"], legacyProblemIds: ["9001", "9002"], }); + expect(matched.createPathChallengeShape).toEqual({ + type: "Marathon Match", + track: "Data Science", + status: "COMPLETED", + phaseNames: ["Registration", "Submission", "Review"], + }); + expect(matched.createPathPhasePlan).toEqual({ + Registration: { + isOpen: false, + startDate: "2020-01-01T00:00:00.000Z", + endDate: "2020-01-01T00:02:00.000Z", + }, + Submission: { + isOpen: false, + startDate: "1970-01-01T00:00:00.100Z", + endDate: "1970-01-01T00:00:00.103Z", + }, + Review: { + isOpen: false, + startDate: "1970-01-01T00:00:00.103Z", + endDate: "1970-01-01T00:00:00.103Z", + }, + }); const unmatched = records.find((entry) => entry.legacyRoundId === "9999"); expect(unmatched.decision).toBe("unmatched"); expect(unmatched.reason).toBe("selected-round-not-found-in-legacy-source"); }); + test("create-path rounds become unresolved when standard phase plan cannot be derived", () => { + writeJson(fixtureDir, "round_registration_1.json", "round_registration", [ + { round_id: "9892", coder_id: "1", eligible: "0", timestamp: "2020-01-01 00:00:00.0" }, + ]); + + const result = runImporter( + [ + "--data-dir", + fixtureDir, + "--dry-run", + "--round-id", + "9892", + ], + fixtureDir + ); + + expect(result.status).toBe(0); + const [record] = parseRecords(result.stdout); + expect(record.decision).toBe("unresolved"); + expect(record.reason).toBe("create-phase-plan-derivation-failed"); + expect(record.createPathChallengeShape).toBe(null); + expect(record.createPathPhasePlan).toBe(null); + }); + test("non-marathon rounds are rejected before planning decisions are emitted", () => { const result = runImporter( [ From 6122ec0c6f8d0a9bf53b057fed6af37167c1339b Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 1 Apr 2026 14:22:17 +1100 Subject: [PATCH 09/27] Fail closed MM planning when discovery/template prerequisites are unavailable --- .../importHistoricalMarathonMatches.js | 49 ++++++- .../importHistoricalMarathonMatches/apply.js | 17 ++- .../planning.js | 63 ++++++++- ...portHistoricalMarathonMatches.plan.test.js | 67 +++++----- ...athonMatches.planningPrerequisites.test.js | 121 ++++++++++++++++++ 5 files changed, 270 insertions(+), 47 deletions(-) create mode 100644 data-migration/test/importHistoricalMarathonMatches.planningPrerequisites.test.js diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches.js b/data-migration/src/scripts/importHistoricalMarathonMatches.js index 98bf885..8822ab8 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches.js @@ -10,6 +10,7 @@ const { runApplyMode, resolveMarathonTypeId, resolveDataScienceTrackId, + resolveCanonicalTimelineTemplateId, } = require("./importHistoricalMarathonMatches/apply"); const { emitPlanReport, emitApplyReport } = require("./importHistoricalMarathonMatches/reporting"); const { @@ -29,6 +30,18 @@ dotenv.config({ quiet: true }); const DEFAULT_ACTOR = process.env.UPDATED_BY || process.env.CREATED_BY || "historical-mm-importer"; +const AUTHORITATIVE_DISCOVERY_UNAVAILABLE_REASON = + "authoritative-existing-v6-discovery-unavailable"; +const CANONICAL_TIMELINE_UNRESOLVED_REASON = "canonical-mm-ds-timeline-template-unresolved"; +const CANONICAL_TIMELINE_AMBIGUOUS_REASON = "canonical-mm-ds-timeline-template-ambiguous"; + +const deriveCanonicalTimelineReason = (error) => { + const message = String((error && error.message) || ""); + if (message.includes("valid candidates")) { + return CANONICAL_TIMELINE_AMBIGUOUS_REASON; + } + return CANONICAL_TIMELINE_UNRESOLVED_REASON; +}; const run = async () => { const options = parseArgs(process.argv.slice(2)); @@ -50,6 +63,18 @@ const run = async () => { try { let existingStateByRoundId = null; + const planningPrerequisites = { + authoritativeDiscovery: { + available: false, + reason: AUTHORITATIVE_DISCOVERY_UNAVAILABLE_REASON, + }, + canonicalTimelineTemplate: { + resolved: false, + reason: CANONICAL_TIMELINE_UNRESOLVED_REASON, + timelineTemplateId: null, + }, + }; + if (prisma) { try { const marathonTypeId = await resolveMarathonTypeId(prisma); @@ -61,12 +86,32 @@ const run = async () => { dataScienceTrackId, snapshotByRoundId, }); + planningPrerequisites.authoritativeDiscovery = { available: true }; + try { + const timelineTemplateId = await resolveCanonicalTimelineTemplateId( + prisma, + marathonTypeId, + dataScienceTrackId + ); + planningPrerequisites.canonicalTimelineTemplate = { + resolved: true, + timelineTemplateId, + }; + } catch (error) { + planningPrerequisites.canonicalTimelineTemplate = { + resolved: false, + reason: deriveCanonicalTimelineReason(error), + }; + process.stderr.write( + `Warning: unable to resolve canonical Marathon Match/Data Science timeline template (${error.message}); create-path planning will be unresolved.\n` + ); + } } catch (error) { if (options.apply) { throw error; } process.stderr.write( - `Warning: unable to discover existing v6 state directly (${error.message}); continuing dry-run without reuse matching.\n` + `Warning: unable to discover existing v6 state directly (${error.message}); create-path planning will be unresolved.\n` ); } } @@ -79,7 +124,7 @@ const run = async () => { }); } - const plan = await buildDryRunPlan(options, existingStateByRoundId); + const plan = await buildDryRunPlan(options, existingStateByRoundId, planningPrerequisites); if (!options.apply) { emitPlanReport(plan); return; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js index f110731..038914c 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js @@ -399,6 +399,10 @@ const runApplyMode = async ({ prisma, options, plan, actor }) => { const decision = planRecordByRoundId.get(roundId) && planRecordByRoundId.get(roundId).decision; return decision === "create" || decision === "reuse/backfill-only"; }); + const createRoundIds = actionableRoundIds.filter((roundId) => { + const decision = planRecordByRoundId.get(roundId) && planRecordByRoundId.get(roundId).decision; + return decision === "create"; + }); let marathonTypeId = null; let dataScienceTrackId = null; @@ -408,11 +412,13 @@ const runApplyMode = async ({ prisma, options, plan, actor }) => { marathonTypeId = await resolveMarathonTypeId(prisma); dataScienceTrackId = await resolveDataScienceTrackId(prisma); phaseIdsByName = await resolveStandardPhaseIds(prisma); - timelineTemplateId = await resolveCanonicalTimelineTemplateId( - prisma, - marathonTypeId, - dataScienceTrackId - ); + if (createRoundIds.length > 0) { + timelineTemplateId = await resolveCanonicalTimelineTemplateId( + prisma, + marathonTypeId, + dataScienceTrackId + ); + } } const applyRecords = []; @@ -498,5 +504,6 @@ module.exports = { applyCreateRound, resolveMarathonTypeId, resolveDataScienceTrackId, + resolveCanonicalTimelineTemplateId, runApplyMode, }; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js b/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js index 11d56db..e920626 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js @@ -154,6 +154,34 @@ const buildUnresolvedRecord = ({ roundId, reason, counters, traceability, matche createPathPhasePlan: null, }); +const normalizePlanningPrerequisites = (prerequisites = {}) => ({ + authoritativeDiscovery: { + available: + prerequisites.authoritativeDiscovery && + prerequisites.authoritativeDiscovery.available === false + ? false + : true, + reason: + (prerequisites.authoritativeDiscovery && prerequisites.authoritativeDiscovery.reason) || + "authoritative-existing-v6-discovery-unavailable", + }, + canonicalTimelineTemplate: { + resolved: + prerequisites.canonicalTimelineTemplate && + prerequisites.canonicalTimelineTemplate.resolved === false + ? false + : true, + timelineTemplateId: + (prerequisites.canonicalTimelineTemplate && + prerequisites.canonicalTimelineTemplate.timelineTemplateId) || + null, + reason: + (prerequisites.canonicalTimelineTemplate && + prerequisites.canonicalTimelineTemplate.reason) || + "canonical-mm-ds-timeline-template-unresolved", + }, +}); + const formatIsoDate = (value) => { if (!(value instanceof Date) || Number.isNaN(value.getTime())) { return null; @@ -161,11 +189,12 @@ const formatIsoDate = (value) => { return value.toISOString(); }; -const buildCreatePathChallengeShape = () => ({ +const buildCreatePathChallengeShape = (timelineTemplateId) => ({ type: "Marathon Match", track: "Data Science", status: "COMPLETED", phaseNames: [...STANDARD_PHASE_NAMES], + timelineTemplateId, }); const buildCreatePathPhasePlan = (roundId, counters) => { @@ -184,7 +213,7 @@ const buildCreatePathPhasePlan = (roundId, counters) => { const isMarathonRoundType = (round) => String(round && round.round_type_id ? round.round_type_id : "").trim() === "13"; -const evaluateRoundPlan = (roundId, counters, existingStateEntry) => { +const evaluateRoundPlan = (roundId, counters, existingStateEntry, prerequisites) => { if (!counters.round) { return { recordType: "round-plan", @@ -283,8 +312,26 @@ const evaluateRoundPlan = (roundId, counters, existingStateEntry) => { let createPathChallengeShape = null; let createPathPhasePlan = null; if (!hasMatchedChallenge) { + if (!prerequisites.authoritativeDiscovery.available) { + return buildUnresolvedRecord({ + roundId, + reason: prerequisites.authoritativeDiscovery.reason, + counters, + traceability, + }); + } + if (!prerequisites.canonicalTimelineTemplate.resolved) { + return buildUnresolvedRecord({ + roundId, + reason: prerequisites.canonicalTimelineTemplate.reason, + counters, + traceability, + }); + } try { - createPathChallengeShape = buildCreatePathChallengeShape(); + createPathChallengeShape = buildCreatePathChallengeShape( + prerequisites.canonicalTimelineTemplate.timelineTemplateId + ); createPathPhasePlan = buildCreatePathPhasePlan(roundId, counters); } catch { return buildUnresolvedRecord({ @@ -558,13 +605,19 @@ const readLegacyPlanningInputs = async (options, roundDataById) => { ); }; -const buildDryRunPlan = async (options, existingStateByRoundId) => { +const buildDryRunPlan = async (options, existingStateByRoundId, planningPrerequisites = {}) => { + const normalizedPrerequisites = normalizePlanningPrerequisites(planningPrerequisites); const selectedRoundIds = [...options.roundIds]; const roundDataById = buildRoundDataById(selectedRoundIds); await readLegacyPlanningInputs(options, roundDataById); const records = selectedRoundIds.map((roundId) => - evaluateRoundPlan(roundId, roundDataById.get(roundId), existingStateByRoundId.get(roundId)) + evaluateRoundPlan( + roundId, + roundDataById.get(roundId), + existingStateByRoundId.get(roundId), + normalizedPrerequisites + ) ); const summary = summarizePlan(records, selectedRoundIds); return { records, summary, roundDataById }; diff --git a/data-migration/test/importHistoricalMarathonMatches.plan.test.js b/data-migration/test/importHistoricalMarathonMatches.plan.test.js index 4755be1..eddec28 100644 --- a/data-migration/test/importHistoricalMarathonMatches.plan.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.plan.test.js @@ -174,54 +174,30 @@ describe("importHistoricalMarathonMatches CLI planning behavior", () => { expect(records.map((entry) => entry.legacyRoundId)).toEqual(["9892", "9999"]); const matched = records.find((entry) => entry.legacyRoundId === "9892"); - expect(matched.decision).toBe("create"); + expect(matched.decision).toBe("unresolved"); + expect(matched.reason).toBe("authoritative-existing-v6-discovery-unavailable"); expect(matched.summaryCounts).toEqual({ eligibleRegistrants: 2, nonExampleSubmissions: 3, exampleSubmissionsFiltered: 1, - plannedFinalScores: 2, - plannedProvisionalScores: 3, - finalistsWithoutAttachableSubmission: 1, + plannedFinalScores: 0, + plannedProvisionalScores: 0, + finalistsWithoutAttachableSubmission: 0, }); expect(matched.traceability).toEqual({ legacyRoundId: "9892", legacyComponentIds: ["5503", "5504"], legacyProblemIds: ["9001", "9002"], }); - expect(matched.createPathChallengeShape).toEqual({ - type: "Marathon Match", - track: "Data Science", - status: "COMPLETED", - phaseNames: ["Registration", "Submission", "Review"], - }); - expect(matched.createPathPhasePlan).toEqual({ - Registration: { - isOpen: false, - startDate: "2020-01-01T00:00:00.000Z", - endDate: "2020-01-01T00:02:00.000Z", - }, - Submission: { - isOpen: false, - startDate: "1970-01-01T00:00:00.100Z", - endDate: "1970-01-01T00:00:00.103Z", - }, - Review: { - isOpen: false, - startDate: "1970-01-01T00:00:00.103Z", - endDate: "1970-01-01T00:00:00.103Z", - }, - }); + expect(matched.createPathChallengeShape).toBe(null); + expect(matched.createPathPhasePlan).toBe(null); const unmatched = records.find((entry) => entry.legacyRoundId === "9999"); expect(unmatched.decision).toBe("unmatched"); expect(unmatched.reason).toBe("selected-round-not-found-in-legacy-source"); }); - test("create-path rounds become unresolved when standard phase plan cannot be derived", () => { - writeJson(fixtureDir, "round_registration_1.json", "round_registration", [ - { round_id: "9892", coder_id: "1", eligible: "0", timestamp: "2020-01-01 00:00:00.0" }, - ]); - + test("dry-run fails closed with unresolved when authoritative discovery is unavailable", () => { const result = runImporter( [ "--data-dir", @@ -236,11 +212,32 @@ describe("importHistoricalMarathonMatches CLI planning behavior", () => { expect(result.status).toBe(0); const [record] = parseRecords(result.stdout); expect(record.decision).toBe("unresolved"); - expect(record.reason).toBe("create-phase-plan-derivation-failed"); + expect(record.reason).toBe("authoritative-existing-v6-discovery-unavailable"); expect(record.createPathChallengeShape).toBe(null); expect(record.createPathPhasePlan).toBe(null); }); + test("dry-run with broken DATABASE_URL still emits unresolved instead of create", () => { + const result = runImporter( + [ + "--data-dir", + fixtureDir, + "--dry-run", + "--round-id", + "9892", + ], + fixtureDir, + { + DATABASE_URL: "not-a-real-database-url", + } + ); + + expect(result.status).toBe(0); + const [record] = parseRecords(result.stdout); + expect(record.decision).toBe("unresolved"); + expect(record.reason).toBe("authoritative-existing-v6-discovery-unavailable"); + }); + test("non-marathon rounds are rejected before planning decisions are emitted", () => { const result = runImporter( [ @@ -276,8 +273,8 @@ describe("importHistoricalMarathonMatches CLI planning behavior", () => { expect(result.status).toBe(0); const [record] = parseRecords(result.stdout); - expect(record.decision).toBe("create"); - expect(record.reason).toBe("no-matching-v6-challenge-found"); + expect(record.decision).toBe("unresolved"); + expect(record.reason).toBe("authoritative-existing-v6-discovery-unavailable"); expect(record.matchedChallengeId).toBe(null); }); }); diff --git a/data-migration/test/importHistoricalMarathonMatches.planningPrerequisites.test.js b/data-migration/test/importHistoricalMarathonMatches.planningPrerequisites.test.js new file mode 100644 index 0000000..41b3e6c --- /dev/null +++ b/data-migration/test/importHistoricalMarathonMatches.planningPrerequisites.test.js @@ -0,0 +1,121 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + buildDryRunPlan, +} = require("../src/scripts/importHistoricalMarathonMatches/planning"); + +const writeJson = (baseDir, fileName, rootKey, rows) => { + fs.writeFileSync( + path.join(baseDir, fileName), + `${JSON.stringify({ [rootKey]: rows }, null, 2)}\n`, + "utf8" + ); +}; + +const buildFixtureDataDirectory = () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-prereq-plan-fixture-")); + + writeJson(baseDir, "round_1.json", "round", [ + { round_id: "9892", round_type_id: "13", name: "MM 9892", short_name: "MM 9892" }, + ]); + writeJson(baseDir, "round_component_1.json", "round_component", [ + { round_id: "9892", component_id: "5503" }, + ]); + writeJson(baseDir, "component_1.json", "component", [ + { component_id: "5503", problem_id: "9001" }, + ]); + writeJson(baseDir, "problem_1.json", "problem", [{ problem_id: "9001" }]); + writeJson(baseDir, "long_component_state_1.json", "long_component_state", [ + { long_component_state_id: "lcs-1", round_id: "9892", coder_id: "1", component_id: "5503" }, + ]); + writeJson(baseDir, "long_submission_1.json", "long_submission", [ + { + long_component_state_id: "lcs-1", + submission_number: "1", + example: "0", + submit_time: "100", + submission_points: "10.0", + open_time: "90", + }, + ]); + writeJson(baseDir, "long_comp_result_1.json", "long_comp_result", [ + { round_id: "9892", coder_id: "1", system_point_total: "98.1", point_total: null, placed: "1" }, + ]); + writeJson(baseDir, "round_registration_1.json", "round_registration", [ + { round_id: "9892", coder_id: "1", eligible: "1", timestamp: "2020-01-01 00:00:00.0" }, + ]); + + return baseDir; +}; + +const buildOptions = (fixtureDir) => ({ + dataDir: fixtureDir, + roundFile: "round_1.json", + roundComponentFile: "round_component_1.json", + componentFile: "component_1.json", + problemFile: "problem_1.json", + longComponentStateFile: "long_component_state_1.json", + roundRegistrationPattern: "^round_registration_\\d+\\.json$", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + roundIds: ["9892"], +}); + +describe("importHistoricalMarathonMatches planning prerequisites", () => { + let fixtureDir; + + beforeEach(() => { + fixtureDir = buildFixtureDataDirectory(); + }); + + afterEach(() => { + fs.rmSync(fixtureDir, { recursive: true, force: true }); + }); + + test("returns unresolved when canonical MM/DS timeline template is unavailable", async () => { + const plan = await buildDryRunPlan( + buildOptions(fixtureDir), + new Map(), + { + authoritativeDiscovery: { available: true }, + canonicalTimelineTemplate: { + resolved: false, + reason: "canonical-mm-ds-timeline-template-unresolved", + }, + } + ); + + expect(plan.records).toHaveLength(1); + expect(plan.records[0].decision).toBe("unresolved"); + expect(plan.records[0].reason).toBe("canonical-mm-ds-timeline-template-unresolved"); + expect(plan.records[0].createPathChallengeShape).toBe(null); + expect(plan.records[0].createPathPhasePlan).toBe(null); + }); + + test("returns create only after canonical MM/DS timeline template is resolved", async () => { + const plan = await buildDryRunPlan( + buildOptions(fixtureDir), + new Map(), + { + authoritativeDiscovery: { available: true }, + canonicalTimelineTemplate: { + resolved: true, + timelineTemplateId: "timeline-mm", + }, + } + ); + + expect(plan.records).toHaveLength(1); + expect(plan.records[0].decision).toBe("create"); + expect(plan.records[0].reason).toBe("no-matching-v6-challenge-found"); + expect(plan.records[0].createPathChallengeShape).toEqual({ + type: "Marathon Match", + track: "Data Science", + status: "COMPLETED", + phaseNames: ["Registration", "Submission", "Review"], + timelineTemplateId: "timeline-mm", + }); + }); +}); From 1f6ba2d4f99808e0f47dcc265f448bf7edbbd497 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 1 Apr 2026 15:03:11 +1100 Subject: [PATCH 10/27] Validate planning-challenge scrutiny rerun Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- ...planning-prerequisite-convergence-fix.json | 24 +++++ .../scrutiny/synthesis.json | 40 +++++++++ .../scrutiny/synthesis.round1.json | 88 +++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 .factory/validation/planning-challenge/scrutiny/reviews/mm-importer-planning-prerequisite-convergence-fix.json create mode 100644 .factory/validation/planning-challenge/scrutiny/synthesis.json create mode 100644 .factory/validation/planning-challenge/scrutiny/synthesis.round1.json diff --git a/.factory/validation/planning-challenge/scrutiny/reviews/mm-importer-planning-prerequisite-convergence-fix.json b/.factory/validation/planning-challenge/scrutiny/reviews/mm-importer-planning-prerequisite-convergence-fix.json new file mode 100644 index 0000000..7d7b2fa --- /dev/null +++ b/.factory/validation/planning-challenge/scrutiny/reviews/mm-importer-planning-prerequisite-convergence-fix.json @@ -0,0 +1,24 @@ +{ + "featureId": "mm-importer-planning-prerequisite-convergence-fix", + "reviewedAt": "2026-04-01T04:00:27Z", + "commitId": "6122ec0c6f8d0a9bf53b057fed6af37167c1339b", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "pass", + "codeReview": { + "summary": "The fix closes the two remaining dry-run/apply convergence gaps from the prior scrutiny round. Dry-run now fails closed to `unresolved` when authoritative existing-v6 discovery is unavailable, and create-path planning only returns `decision=create` after the same canonical Marathon Match/Data Science timeline-template resolution that apply mode uses.", + "issues": [] + }, + "sharedStateObservations": [ + { + "area": "skills", + "observation": "The `migration-worker` skill's mandatory red/green steps do not mention the interrupted-session case where the assigned feature commit is already on `HEAD` and the remaining work is validation plus handoff. This worker followed a justified resume path and had to record it as a deviation, so the skill should document that exception.", + "evidence": "`.factory/skills/migration-worker/SKILL.md:32-38` requires tests-first red/green on every run, while `/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/handoffs/2026-04-01T03-56-27-373Z__mm-importer-planning-prerequisite-convergence-fix__683f3f74-0fa4-4f13-9c89-475b3d5b879e.json:89-99` records `followedProcedure=false` because the session resumed an already-landed commit (`6122ec0`) mid-validation and explicitly suggests adding resume guidance." + } + ], + "addressesFailureFrom": [ + "mm-importer-authoritative-round-selection-and-reuse-fix", + "mm-importer-challenge-phase-plan-and-rerun-convergence-fix" + ], + "summary": "Reviewed the fix handoff, transcript skeleton, prior failed reviews, shared-state docs, and commit `6122ec0c6f8d0a9bf53b057fed6af37167c1339b`. Result: pass. The commit addresses both prior blockers by making dry-run fail closed when authoritative discovery is unavailable and by requiring canonical MM/Data Science timeline-template resolution before advertising create-path work; the handoff's env-backed checks also show dry-run/apply convergence for round `13897`." +} diff --git a/.factory/validation/planning-challenge/scrutiny/synthesis.json b/.factory/validation/planning-challenge/scrutiny/synthesis.json new file mode 100644 index 0000000..83ece65 --- /dev/null +++ b/.factory/validation/planning-challenge/scrutiny/synthesis.json @@ -0,0 +1,40 @@ +{ + "milestone": "planning-challenge", + "round": 2, + "status": "pass", + "validatorsRun": { + "test": { + "passed": true, + "command": "source \"$HOME/.config/nvm/nvm.sh\" && (cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration\" && nvm use 18.19.0 >/dev/null && pnpm test --maxWorkers=16 --testPathIgnorePatterns test/challenge-migration.test.js)", + "exitCode": 0 + }, + "typecheck": { + "passed": true, + "command": "echo \"No dedicated typecheck command for this JavaScript importer surface\"", + "exitCode": 0 + }, + "lint": { + "passed": true, + "command": "source \"$HOME/.config/nvm/nvm.sh\" && (cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration\" && nvm use 18.19.0 >/dev/null && pnpm lint)", + "exitCode": 0 + } + }, + "reviewsSummary": { + "total": 1, + "passed": 1, + "failed": 0, + "failedFeatures": [] + }, + "blockingIssues": [], + "appliedUpdates": [], + "suggestedGuidanceUpdates": [ + { + "target": ".factory/skills/migration-worker/SKILL.md", + "suggestion": "Document the interrupted-session resume case where a worker resumes after the assigned feature commit is already on `HEAD`, so the remaining required work is validation and handoff rather than restarting the red/green loop from scratch.", + "evidence": "The rerun review for `mm-importer-planning-prerequisite-convergence-fix` found that the worker had to deviate from the current skill because `.factory/skills/migration-worker/SKILL.md:32-38` requires tests-first red/green on every run, while the worker handoff for session `683f3f74-0fa4-4f13-9c89-475b3d5b879e` documents a justified mid-validation resume of already-landed commit `6122ec0c6f8d0a9bf53b057fed6af37167c1339b`.", + "isSystemic": true + } + ], + "rejectedObservations": [], + "previousRound": ".factory/validation/planning-challenge/scrutiny/synthesis.round1.json" +} diff --git a/.factory/validation/planning-challenge/scrutiny/synthesis.round1.json b/.factory/validation/planning-challenge/scrutiny/synthesis.round1.json new file mode 100644 index 0000000..a28821a --- /dev/null +++ b/.factory/validation/planning-challenge/scrutiny/synthesis.round1.json @@ -0,0 +1,88 @@ +{ + "milestone": "planning-challenge", + "round": 1, + "status": "fail", + "validatorsRun": { + "test": { + "passed": true, + "command": "source \"$HOME/.config/nvm/nvm.sh\" && (cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration\" && nvm use 18.19.0 >/dev/null && pnpm test --maxWorkers=16 --testPathIgnorePatterns test/challenge-migration.test.js)", + "exitCode": 0 + }, + "typecheck": { + "passed": true, + "command": "echo \"No dedicated typecheck command for this JavaScript importer surface\"", + "exitCode": 0 + }, + "lint": { + "passed": true, + "command": "source \"$HOME/.config/nvm/nvm.sh\" && (cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration\" && nvm use 18.19.0 >/dev/null && pnpm lint)", + "exitCode": 0 + } + }, + "reviewsSummary": { + "total": 5, + "passed": 0, + "failed": 5, + "failedFeatures": [ + "mm-importer-plan-cli", + "mm-importer-create-historical-challenge-phases", + "mm-importer-reuse-challenge-safety", + "mm-importer-authoritative-round-selection-and-reuse-fix", + "mm-importer-challenge-phase-plan-and-rerun-convergence-fix" + ] + }, + "blockingIssues": [ + { + "featureId": "mm-importer-plan-cli", + "severity": "blocking", + "description": "Dry-run planning still lacked authoritative existing-v6 reuse matching and therefore could not safely report matched challenge ids, reuse/backfill deltas, or rerun no-op classification from authoritative state." + }, + { + "featureId": "mm-importer-plan-cli", + "severity": "blocking", + "description": "Planning initially failed to gate on `round.round_type_id == \"13\"`, allowing non-marathon rounds to be classified as Marathon Match import work." + }, + { + "featureId": "mm-importer-plan-cli", + "severity": "blocking", + "description": "Create-path dry-run records initially omitted the required Marathon Match / Data Science challenge-shape and explicit Registration/Submission/Review phase-plan facts." + }, + { + "featureId": "mm-importer-create-historical-challenge-phases", + "severity": "blocking", + "description": "Apply reruns initially short-circuited on any existing `legacyId` match instead of backfilling missing standard phases, so partially created challenges could remain non-converged." + }, + { + "featureId": "mm-importer-create-historical-challenge-phases", + "severity": "blocking", + "description": "Apply reuse initially matched the first challenge by `legacyId` without enforcing unique Marathon Match / Data Science safety checks." + }, + { + "featureId": "mm-importer-authoritative-round-selection-and-reuse-fix", + "severity": "blocking", + "description": "Dry-run still falls back to `decision=create` when direct DB-backed reuse discovery is unavailable instead of surfacing an unresolved or prerequisite failure." + }, + { + "featureId": "mm-importer-challenge-phase-plan-and-rerun-convergence-fix", + "severity": "blocking", + "description": "Create-path dry-run still does not validate canonical Marathon Match / Data Science timeline-template resolution before returning `decision=create`, so planning can promise creates that apply later rejects." + } + ], + "appliedUpdates": [], + "suggestedGuidanceUpdates": [], + "rejectedObservations": [ + { + "observation": "The mission-owned `.factory/services.yaml` test command was incorrect for Jest.", + "reason": "already-documented" + }, + { + "observation": "The shared-state docs pointed workers at non-marathon round `9892` as the primary create-path fixture.", + "reason": "already-documented" + }, + { + "observation": "The shared state does not yet document `--existing-state-file` generation/schema/authority.", + "reason": "ambiguous" + } + ], + "previousRound": null +} From 5dffc4209bca5b675199c8b9539dec538c36843b Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 1 Apr 2026 18:18:43 +1100 Subject: [PATCH 11/27] Reconcile submitter resources from eligible registrations --- .../importHistoricalMarathonMatches.js | 35 +++- .../importHistoricalMarathonMatches/apply.js | 130 +++++++++++- .../argParser.js | 7 + .../participants.js | 189 +++++++++++++++++ .../resourceApi.js | 193 ++++++++++++++++++ ...ortHistoricalMarathonMatches.apply.test.js | 144 ++++++++++++- ...oricalMarathonMatches.participants.test.js | 87 ++++++++ ...toricalMarathonMatches.resourceApi.test.js | 28 +++ 8 files changed, 810 insertions(+), 3 deletions(-) create mode 100644 data-migration/src/scripts/importHistoricalMarathonMatches/participants.js create mode 100644 data-migration/src/scripts/importHistoricalMarathonMatches/resourceApi.js create mode 100644 data-migration/test/importHistoricalMarathonMatches.participants.test.js create mode 100644 data-migration/test/importHistoricalMarathonMatches.resourceApi.test.js diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches.js b/data-migration/src/scripts/importHistoricalMarathonMatches.js index 8822ab8..1464ad8 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches.js @@ -8,6 +8,7 @@ const { parseArgs, usage } = require("./importHistoricalMarathonMatches/argParse const { buildDryRunPlan } = require("./importHistoricalMarathonMatches/planning"); const { runApplyMode, + DEFAULT_SUBMITTER_ROLE_ID, resolveMarathonTypeId, resolveDataScienceTrackId, resolveCanonicalTimelineTemplateId, @@ -17,6 +18,10 @@ const { loadExistingState, buildExistingStateByRoundId, } = require("./importHistoricalMarathonMatches/existingState"); +const { + createAuth0TokenProvider, + createResourceApiClient, +} = require("./importHistoricalMarathonMatches/resourceApi"); const appRoot = path.resolve(__dirname, "..", "..", ".."); const requireFromRoot = createRequire(path.join(appRoot, "package.json")); @@ -43,6 +48,19 @@ const deriveCanonicalTimelineReason = (error) => { return CANONICAL_TIMELINE_UNRESOLVED_REASON; }; +const createDefaultResourceClient = () => { + const getAccessToken = createAuth0TokenProvider({ + auth0Url: process.env.AUTH0_URL, + auth0Audience: process.env.AUTH0_AUDIENCE, + auth0ClientId: process.env.AUTH0_CLIENT_ID, + auth0ClientSecret: process.env.AUTH0_CLIENT_SECRET, + }); + return createResourceApiClient({ + baseUrl: process.env.RESOURCES_API_URL, + getAccessToken, + }); +}; + const run = async () => { const options = parseArgs(process.argv.slice(2)); @@ -130,9 +148,24 @@ const run = async () => { return; } + if (!String(process.env.RESOURCES_API_URL || "").trim()) { + throw new Error("RESOURCES_API_URL must be set for apply mode participant reconciliation."); + } + + const submitterRoleId = String( + process.env.SUBMITTER_ROLE_ID || DEFAULT_SUBMITTER_ROLE_ID + ).trim(); + if (!submitterRoleId) { + throw new Error("SUBMITTER_ROLE_ID must be set for apply mode participant reconciliation."); + } + const applyResult = await runApplyMode({ prisma, - options, + options: { + ...options, + submitterRoleId, + resourceClient: createDefaultResourceClient(), + }, plan, actor: DEFAULT_ACTOR, }); diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js index 038914c..fce0560 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js @@ -1,6 +1,13 @@ "use strict"; +const { + DEFAULT_USER_PATTERN, + loadNormalizedIdentityByCoderId, + buildEligibleMemberIdentities, +} = require("./participants"); + const STANDARD_PHASE_NAMES = ["Registration", "Submission", "Review"]; +const DEFAULT_SUBMITTER_ROLE_ID = "732339e7-8e30-49d7-9198-cccf9451e221"; const parseRoundLegacyId = (roundId) => { const parsed = Number.parseInt(String(roundId || "").trim(), 10); @@ -389,7 +396,13 @@ const resolveCanonicalTimelineTemplateId = async (prisma, marathonTypeId, dataSc ); }; -const runApplyMode = async ({ prisma, options, plan, actor }) => { +const runApplyMode = async ({ + prisma, + options, + plan, + actor, + normalizedIdentityByCoderId: providedNormalizedIdentityByCoderId, +}) => { const planRecordByRoundId = new Map((plan.records || []).map((record) => [record.legacyRoundId, record])); const actionableRoundIds = options.roundIds.filter((roundId) => { const counters = plan.roundDataById.get(roundId); @@ -403,6 +416,40 @@ const runApplyMode = async ({ prisma, options, plan, actor }) => { const decision = planRecordByRoundId.get(roundId) && planRecordByRoundId.get(roundId).decision; return decision === "create"; }); + const submitterRoleId = String(options.submitterRoleId || DEFAULT_SUBMITTER_ROLE_ID).trim(); + + const resourceClient = options.resourceClient; + if (actionableRoundIds.length > 0 && !resourceClient) { + throw new Error("Resource API client is required for apply mode participant reconciliation."); + } + + let normalizedIdentityByCoderId = + options.normalizedIdentityByCoderId instanceof Map + ? options.normalizedIdentityByCoderId + : providedNormalizedIdentityByCoderId instanceof Map + ? providedNormalizedIdentityByCoderId + : null; + if (!normalizedIdentityByCoderId) { + const eligibleCoderIds = new Set(); + actionableRoundIds.forEach((roundId) => { + const counters = plan.roundDataById.get(roundId); + if (!counters || !(counters.eligibleRegistrants instanceof Set)) { + return; + } + counters.eligibleRegistrants.forEach((coderId) => { + const normalizedCoderId = String(coderId || "").trim(); + if (normalizedCoderId) { + eligibleCoderIds.add(normalizedCoderId); + } + }); + }); + + normalizedIdentityByCoderId = await loadNormalizedIdentityByCoderId({ + dataDir: options.dataDir, + userPattern: options.userPattern || DEFAULT_USER_PATTERN, + coderIds: eligibleCoderIds, + }); + } let marathonTypeId = null; let dataScienceTrackId = null; @@ -459,11 +506,19 @@ const runApplyMode = async ({ prisma, options, plan, actor }) => { timelineTemplateId, phaseIdsByName, }); + const resourceReconciliation = await reconcileSubmitterResourcesForRound({ + challengeId: result.challengeId, + counters, + normalizedIdentityByCoderId, + resourceClient, + submitterRoleId, + }); applyRecords.push({ recordType: "apply-record", legacyRoundId: roundId, status: result.status, challengeId: result.challengeId, + resourceReconciliation, }); } catch (error) { applyRecords.push({ @@ -497,13 +552,86 @@ const runApplyMode = async ({ prisma, options, plan, actor }) => { return { records: applyRecords, summary }; }; +const parseMemberId = (value) => { + const parsed = Number.parseInt(String(value || "").trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +}; + +const reconcileSubmitterResourcesForRound = async ({ + challengeId, + counters, + normalizedIdentityByCoderId, + resourceClient, + submitterRoleId, +}) => { + const eligibleMemberIdentities = buildEligibleMemberIdentities({ + eligibleCoderIds: counters && counters.eligibleRegistrants ? counters.eligibleRegistrants : new Set(), + normalizedIdentityByCoderId, + }); + const targetEligibleRegistrants = eligibleMemberIdentities.length; + if (targetEligibleRegistrants === 0) { + return { + targetEligibleRegistrants: 0, + existingSubmitterResources: 0, + createdSubmitterResources: 0, + unchangedSubmitterResources: 0, + }; + } + + const existingResources = await resourceClient.listSubmitterResources(challengeId, submitterRoleId); + const eligibleMemberIds = new Set(eligibleMemberIdentities.map((identity) => identity.memberId)); + const existingEligibleMemberIds = new Set(); + + (existingResources || []).forEach((resource) => { + if (!resource || typeof resource !== "object") { + return; + } + const resourceRoleId = String(resource.roleId || "").trim(); + if (resourceRoleId && resourceRoleId !== submitterRoleId) { + return; + } + + const memberId = parseMemberId(resource.memberId); + if (!memberId || !eligibleMemberIds.has(memberId)) { + return; + } + existingEligibleMemberIds.add(memberId); + }); + + let createdSubmitterResources = 0; + for (const identity of eligibleMemberIdentities) { + if (existingEligibleMemberIds.has(identity.memberId)) { + continue; + } + await resourceClient.createSubmitterResource({ + challengeId, + memberId: String(identity.memberId), + roleId: submitterRoleId, + }); + existingEligibleMemberIds.add(identity.memberId); + createdSubmitterResources += 1; + } + + return { + targetEligibleRegistrants, + existingSubmitterResources: targetEligibleRegistrants - createdSubmitterResources, + createdSubmitterResources, + unchangedSubmitterResources: targetEligibleRegistrants - createdSubmitterResources, + }; +}; + module.exports = { STANDARD_PHASE_NAMES, + DEFAULT_SUBMITTER_ROLE_ID, derivePhaseWindows, buildChallengePhaseRows, applyCreateRound, resolveMarathonTypeId, resolveDataScienceTrackId, resolveCanonicalTimelineTemplateId, + reconcileSubmitterResourcesForRound, runApplyMode, }; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js b/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js index ed23eca..a3f9745 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js @@ -8,6 +8,7 @@ const DEFAULT_OPTIONS = { problemFile: "problem_1.json", longComponentStateFile: "long_component_state_1.json", roundRegistrationPattern: "^round_registration_\\d+\\.json$", + userPattern: "^user_\\d+\\.json$", longSubmissionPattern: "^long_submission_\\d+\\.json$", longCompResultPattern: "^long_comp_result_\\d+\\.json$", existingStateFile: null, @@ -106,6 +107,11 @@ const parseArgs = (argv) => { index += 1; continue; } + if (arg === "--user-pattern") { + options.userPattern = requireNextValue(argv, index, "--user-pattern"); + index += 1; + continue; + } if (arg === "--long-submission-pattern") { options.longSubmissionPattern = requireNextValue(argv, index, "--long-submission-pattern"); index += 1; @@ -173,6 +179,7 @@ Input options: --problem-file Legacy problem file (default: problem_1.json) --long-component-state-file Legacy long_component_state file (default: long_component_state_1.json) --round-registration-pattern Regex for round_registration files (default: ^round_registration_\\d+\\.json$) + --user-pattern Regex for user files (default: ^user_\\d+\\.json$) --long-submission-pattern Regex for long_submission files (default: ^long_submission_\\d+\\.json$) --long-comp-result-pattern Regex for long_comp_result files (default: ^long_comp_result_\\d+\\.json$) diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/participants.js b/data-migration/src/scripts/importHistoricalMarathonMatches/participants.js new file mode 100644 index 0000000..0cc6828 --- /dev/null +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/participants.js @@ -0,0 +1,189 @@ +"use strict"; + +const { + listFilesByPattern, + streamJsonArray, +} = require("./legacyDataReader"); + +const DEFAULT_USER_PATTERN = "^user_\\d+\\.json$"; + +const normalizePositiveInteger = (value) => { + const parsed = Number.parseInt(String(value || "").trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +}; + +const normalizeHandle = (value) => { + const normalized = String(value || "").trim(); + return normalized ? normalized : null; +}; + +const sortCoderIds = (coderIds) => + Array.from(coderIds).sort((left, right) => { + const leftNum = normalizePositiveInteger(left); + const rightNum = normalizePositiveInteger(right); + if (Number.isFinite(leftNum) && Number.isFinite(rightNum)) { + return leftNum - rightNum; + } + return String(left).localeCompare(String(right)); + }); + +const normalizeIdentity = ({ coderId, memberId, memberHandle }) => ({ + coderId: String(coderId), + memberId: normalizePositiveInteger(memberId), + memberHandle: normalizeHandle(memberHandle), +}); + +const fallbackIdentityFromCoderId = (coderId) => + normalizeIdentity({ + coderId, + memberId: coderId, + memberHandle: null, + }); + +const mergeIdentity = (existing, incoming) => { + if (!existing) { + return incoming; + } + if (!existing.memberId && incoming.memberId) { + return { + ...existing, + memberId: incoming.memberId, + memberHandle: existing.memberHandle || incoming.memberHandle, + }; + } + if (!existing.memberHandle && incoming.memberHandle) { + return { ...existing, memberHandle: incoming.memberHandle }; + } + return existing; +}; + +const resolveUserId = (row) => { + if (!row || typeof row !== "object") { + return null; + } + return ( + normalizePositiveInteger(row.user_id) || + normalizePositiveInteger(row.coder_id) || + normalizePositiveInteger(row.member_id) || + normalizePositiveInteger(row.id) + ); +}; + +const resolveMemberHandle = (row) => { + if (!row || typeof row !== "object") { + return null; + } + return ( + normalizeHandle(row.handle) || + normalizeHandle(row.handle_lower) || + normalizeHandle(row.member_handle) || + null + ); +}; + +const loadNormalizedIdentityByCoderId = async ({ + dataDir, + userPattern = DEFAULT_USER_PATTERN, + coderIds = new Set(), +}) => { + const normalizedCoderIds = new Set( + Array.from(coderIds) + .map((coderId) => String(coderId || "").trim()) + .filter(Boolean) + ); + const identityByCoderId = new Map(); + + if (normalizedCoderIds.size > 0) { + try { + const userFiles = listFilesByPattern(dataDir, userPattern, "user"); + await Promise.all( + userFiles.map((filePath) => + streamJsonArray(filePath, "user", (row) => { + const userId = resolveUserId(row); + if (!userId) { + return; + } + const coderId = String(userId); + if (!normalizedCoderIds.has(coderId)) { + return; + } + const identity = normalizeIdentity({ + coderId, + memberId: userId, + memberHandle: resolveMemberHandle(row), + }); + identityByCoderId.set( + coderId, + mergeIdentity(identityByCoderId.get(coderId), identity) + ); + }) + ) + ); + } catch (error) { + if (!String(error.message || "").includes("No files matched user pattern")) { + throw error; + } + } + } + + normalizedCoderIds.forEach((coderId) => { + if (!identityByCoderId.has(coderId)) { + identityByCoderId.set(coderId, fallbackIdentityFromCoderId(coderId)); + return; + } + const existing = identityByCoderId.get(coderId); + if (!existing.memberId) { + identityByCoderId.set(coderId, fallbackIdentityFromCoderId(coderId)); + } + }); + + return identityByCoderId; +}; + +const buildEligibleMemberIdentities = ({ + eligibleCoderIds = new Set(), + normalizedIdentityByCoderId = new Map(), +}) => { + const memberIdentityByMemberId = new Map(); + + sortCoderIds(eligibleCoderIds).forEach((coderId) => { + const normalizedCoderId = String(coderId || "").trim(); + if (!normalizedCoderId) { + return; + } + const identity = + normalizedIdentityByCoderId.get(normalizedCoderId) || + fallbackIdentityFromCoderId(normalizedCoderId); + if (!identity || !identity.memberId) { + return; + } + + const existing = memberIdentityByMemberId.get(identity.memberId); + if (!existing) { + memberIdentityByMemberId.set(identity.memberId, { + memberId: identity.memberId, + memberHandle: identity.memberHandle, + coderIds: [normalizedCoderId], + }); + return; + } + + existing.coderIds.push(normalizedCoderId); + if (!existing.memberHandle && identity.memberHandle) { + existing.memberHandle = identity.memberHandle; + } + }); + + return Array.from(memberIdentityByMemberId.values()).sort( + (left, right) => left.memberId - right.memberId + ); +}; + +module.exports = { + DEFAULT_USER_PATTERN, + loadNormalizedIdentityByCoderId, + buildEligibleMemberIdentities, +}; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/resourceApi.js b/data-migration/src/scripts/importHistoricalMarathonMatches/resourceApi.js new file mode 100644 index 0000000..3c52bb1 --- /dev/null +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/resourceApi.js @@ -0,0 +1,193 @@ +"use strict"; + +const trimTrailingSlash = (value) => String(value || "").trim().replace(/\/+$/, ""); + +const createAuth0TokenProvider = ({ + auth0Url, + auth0Audience, + auth0ClientId, + auth0ClientSecret, + fetchImpl = fetch, +}) => { + const normalizedAuth0Url = trimTrailingSlash(auth0Url); + const audience = String(auth0Audience || "").trim(); + const clientId = String(auth0ClientId || "").trim(); + const clientSecret = String(auth0ClientSecret || "").trim(); + + if (!normalizedAuth0Url) { + throw new Error("AUTH0_URL must be set for Resource API authentication."); + } + if (!audience) { + throw new Error("AUTH0_AUDIENCE must be set for Resource API authentication."); + } + if (!clientId) { + throw new Error("AUTH0_CLIENT_ID must be set for Resource API authentication."); + } + if (!clientSecret) { + throw new Error("AUTH0_CLIENT_SECRET must be set for Resource API authentication."); + } + + const tokenUrl = normalizedAuth0Url.endsWith("/oauth/token") + ? normalizedAuth0Url + : `${normalizedAuth0Url}/oauth/token`; + let cachedToken = null; + let expiresAtMs = 0; + + return async () => { + const now = Date.now(); + if (cachedToken && now < expiresAtMs) { + return cachedToken; + } + + const response = await fetchImpl(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "client_credentials", + client_id: clientId, + client_secret: clientSecret, + audience, + }), + }); + if (!response.ok) { + throw new Error( + `Failed to obtain Auth0 token (${response.status} ${response.statusText}).` + ); + } + + const payload = await response.json(); + const token = payload && payload.access_token ? String(payload.access_token) : ""; + if (!token) { + throw new Error("Auth0 token response did not include access_token."); + } + const expiresInSeconds = Number.parseInt(payload.expires_in, 10); + const safeExpiresInSeconds = Number.isFinite(expiresInSeconds) && expiresInSeconds > 0 + ? expiresInSeconds + : 3600; + cachedToken = token; + expiresAtMs = now + (safeExpiresInSeconds - 60) * 1000; + return cachedToken; + }; +}; + +const parseJsonBody = async (response) => { + const contentType = String(response.headers.get("content-type") || "").toLowerCase(); + if (!contentType.includes("application/json")) { + return null; + } + return response.json(); +}; + +const createResourceApiClient = ({ + baseUrl, + submitterRoleId, + getAccessToken, + fetchImpl = fetch, +}) => { + const normalizedBaseUrl = trimTrailingSlash(baseUrl); + if (!normalizedBaseUrl) { + throw new Error("RESOURCES_API_URL must be set."); + } + if (!getAccessToken || typeof getAccessToken !== "function") { + throw new Error("Resource API access token provider is required."); + } + + const listSubmitterResources = async (challengeId, roleId = submitterRoleId) => { + const normalizedChallengeId = String(challengeId || "").trim(); + if (!normalizedChallengeId) { + return []; + } + + const normalizedRoleId = String(roleId || submitterRoleId || "").trim(); + const perPage = 200; + const results = []; + let page = 1; + + while (true) { + const url = new URL(normalizedBaseUrl); + url.searchParams.set("challengeId", normalizedChallengeId); + url.searchParams.set("perPage", String(perPage)); + url.searchParams.set("page", String(page)); + if (normalizedRoleId) { + url.searchParams.set("roleId", normalizedRoleId); + } + + const token = await getAccessToken(); + const response = await fetchImpl(url.toString(), { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!response.ok) { + throw new Error( + `Failed to list resources for challenge ${normalizedChallengeId} (${response.status} ${response.statusText}).` + ); + } + + const payload = await parseJsonBody(response); + const rows = Array.isArray(payload) ? payload : []; + if (rows.length === 0) { + break; + } + results.push(...rows); + + const totalPages = Number.parseInt(response.headers.get("x-total-pages"), 10); + if (Number.isFinite(totalPages) && page >= totalPages) { + break; + } + if (rows.length < perPage) { + break; + } + page += 1; + } + + return results; + }; + + const createSubmitterResource = async ({ challengeId, memberId, roleId = submitterRoleId }) => { + const token = await getAccessToken(); + const response = await fetchImpl(normalizedBaseUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + challengeId, + memberId, + roleId, + }), + }); + + if (response.status === 409) { + return null; + } + const responseBodyText = await response.text(); + if (!response.ok) { + throw new Error( + `Failed to create submitter resource for challenge ${challengeId} member ${memberId} (${response.status} ${response.statusText})${responseBodyText ? `: ${responseBodyText}` : ""}.` + ); + } + if (!responseBodyText) { + return null; + } + try { + return JSON.parse(responseBodyText); + } catch { + return null; + } + }; + + return { + listSubmitterResources, + createSubmitterResource, + }; +}; + +module.exports = { + createAuth0TokenProvider, + createResourceApiClient, +}; diff --git a/data-migration/test/importHistoricalMarathonMatches.apply.test.js b/data-migration/test/importHistoricalMarathonMatches.apply.test.js index 8c11155..4e1125f 100644 --- a/data-migration/test/importHistoricalMarathonMatches.apply.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.apply.test.js @@ -302,7 +302,13 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { const result = await runApplyMode({ prisma, - options: { roundIds: ["9892"] }, + options: { + roundIds: ["9892"], + resourceClient: { + listSubmitterResources: jest.fn().mockResolvedValue([]), + createSubmitterResource: jest.fn().mockResolvedValue({}), + }, + }, plan: { records: [ { @@ -328,6 +334,10 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { ]), }, actor: "importer", + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ]), }); expect(result.records).toEqual([ @@ -336,6 +346,12 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { legacyRoundId: "9892", status: "existing", challengeId: "existing-challenge-1", + resourceReconciliation: { + targetEligibleRegistrants: 2, + existingSubmitterResources: 0, + createdSubmitterResources: 2, + unchangedSubmitterResources: 0, + }, }, ]); expect(tx.challenge.create).not.toHaveBeenCalled(); @@ -586,4 +602,130 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { expect(tx.challenge.create).not.toHaveBeenCalled(); expect(tx.challengePhase.createMany).not.toHaveBeenCalled(); }); + + test("apply mode reconciles submitter resources from eligible registrations", async () => { + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({ id: "challenge-1" }), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([]), + createMany: jest.fn(), + }, + }; + + const phaseRows = [ + { id: "phase-registration", name: "Registration" }, + { id: "phase-submission", name: "Submission" }, + { id: "phase-review", name: "Review" }, + ]; + + const prisma = { + challengeType: { + findMany: jest.fn().mockResolvedValue([{ id: "type-mm" }]), + }, + challengeTrack: { + findMany: jest.fn().mockResolvedValue([{ id: "track-ds" }]), + }, + phase: { + findMany: jest + .fn() + .mockResolvedValueOnce(phaseRows) + .mockResolvedValueOnce(phaseRows), + }, + challengeTimelineTemplate: { + findMany: jest.fn().mockResolvedValue([ + { + timelineTemplateId: "timeline-mm", + isDefault: true, + timelineTemplate: { + phases: [ + { phaseId: "phase-registration" }, + { phaseId: "phase-submission" }, + { phaseId: "phase-review" }, + ], + }, + }, + ]), + }, + $transaction: async (callback) => callback(tx), + }; + + const resourceClient = { + listSubmitterResources: jest.fn().mockResolvedValue([ + { id: "resource-existing", challengeId: "challenge-1", roleId: "submitter-role", memberId: 1 }, + ]), + createSubmitterResource: jest.fn().mockResolvedValue({}), + }; + + const result = await runApplyMode({ + prisma, + options: { + roundIds: ["9892"], + resourceClient, + submitterRoleId: "submitter-role", + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "create", + reason: "no-matching-v6-challenge-found", + }, + ], + roundDataById: new Map([ + [ + "9892", + { + round: { round_id: "9892", round_type_id: "13" }, + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2", "2", "3"]), + nonExampleSubmissions: 3, + }, + ], + ]), + }, + actor: "importer", + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ["3", { coderId: "3", memberId: 3, memberHandle: "charlie" }], + ]), + }); + + expect(resourceClient.listSubmitterResources).toHaveBeenCalledWith( + "challenge-1", + "submitter-role" + ); + expect(resourceClient.createSubmitterResource).toHaveBeenCalledTimes(2); + expect(resourceClient.createSubmitterResource).toHaveBeenCalledWith({ + challengeId: "challenge-1", + memberId: "2", + roleId: "submitter-role", + }); + expect(resourceClient.createSubmitterResource).toHaveBeenCalledWith({ + challengeId: "challenge-1", + memberId: "3", + roleId: "submitter-role", + }); + expect(result.records).toEqual([ + { + recordType: "apply-record", + legacyRoundId: "9892", + status: "created", + challengeId: "challenge-1", + resourceReconciliation: { + targetEligibleRegistrants: 3, + existingSubmitterResources: 1, + createdSubmitterResources: 2, + unchangedSubmitterResources: 1, + }, + }, + ]); + }); }); diff --git a/data-migration/test/importHistoricalMarathonMatches.participants.test.js b/data-migration/test/importHistoricalMarathonMatches.participants.test.js new file mode 100644 index 0000000..7e682b5 --- /dev/null +++ b/data-migration/test/importHistoricalMarathonMatches.participants.test.js @@ -0,0 +1,87 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + loadNormalizedIdentityByCoderId, + buildEligibleMemberIdentities, +} = require("../src/scripts/importHistoricalMarathonMatches/participants"); + +const writeJson = (baseDir, fileName, rootKey, rows) => { + fs.writeFileSync( + path.join(baseDir, fileName), + `${JSON.stringify({ [rootKey]: rows }, null, 2)}\n`, + "utf8" + ); +}; + +describe("importHistoricalMarathonMatches participant identity normalization", () => { + let fixtureDir; + + beforeEach(() => { + fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-participants-fixture-")); + + writeJson(fixtureDir, "user_1.json", "user", [ + { user_id: "1", handle: "alpha" }, + { user_id: "2", handle: "bravo" }, + { user_id: "77", handle: "delta" }, + ]); + }); + + afterEach(() => { + fs.rmSync(fixtureDir, { recursive: true, force: true }); + }); + + test("loads known user handles and falls back to coder id member mapping", async () => { + const identities = await loadNormalizedIdentityByCoderId({ + dataDir: fixtureDir, + coderIds: new Set(["1", "2", "3"]), + }); + + expect(identities.get("1")).toEqual({ + coderId: "1", + memberId: 1, + memberHandle: "alpha", + }); + expect(identities.get("2")).toEqual({ + coderId: "2", + memberId: 2, + memberHandle: "bravo", + }); + expect(identities.get("3")).toEqual({ + coderId: "3", + memberId: 3, + memberHandle: null, + }); + }); + + test("eligible identity derivation deduplicates by normalized memberId", () => { + const identities = buildEligibleMemberIdentities({ + eligibleCoderIds: new Set(["1", "2", "88", "89"]), + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ["88", { coderId: "88", memberId: 77, memberHandle: "delta" }], + ["89", { coderId: "89", memberId: 77, memberHandle: null }], + ]), + }); + + expect(identities).toEqual([ + { + memberId: 1, + memberHandle: "alpha", + coderIds: ["1"], + }, + { + memberId: 2, + memberHandle: "bravo", + coderIds: ["2"], + }, + { + memberId: 77, + memberHandle: "delta", + coderIds: ["88", "89"], + }, + ]); + }); +}); diff --git a/data-migration/test/importHistoricalMarathonMatches.resourceApi.test.js b/data-migration/test/importHistoricalMarathonMatches.resourceApi.test.js new file mode 100644 index 0000000..2c55cc7 --- /dev/null +++ b/data-migration/test/importHistoricalMarathonMatches.resourceApi.test.js @@ -0,0 +1,28 @@ +const { + createAuth0TokenProvider, +} = require("../src/scripts/importHistoricalMarathonMatches/resourceApi"); + +describe("importHistoricalMarathonMatches resource api auth provider", () => { + test("uses AUTH0_URL directly when it already targets /oauth/token", async () => { + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ access_token: "token-1", expires_in: 3600 }), + }); + + const getAccessToken = createAuth0TokenProvider({ + auth0Url: "https://topcoder-dev.auth0.com/oauth/token", + auth0Audience: "https://m2m.topcoder-dev.com/", + auth0ClientId: "client-id", + auth0ClientSecret: "client-secret", + fetchImpl, + }); + + await expect(getAccessToken()).resolves.toBe("token-1"); + expect(fetchImpl).toHaveBeenCalledWith( + "https://topcoder-dev.auth0.com/oauth/token", + expect.objectContaining({ method: "POST" }) + ); + }); +}); From 56809ab187d903bf2245cd3ed16cde25028164c1 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 1 Apr 2026 21:33:53 +1100 Subject: [PATCH 12/27] Add completed-status fallback for submitter resource backfill Retry submitter resource creation with a temporary challenge status transition when the Resource API blocks writes on COMPLETED rounds, then always restore the original status before returning. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../importHistoricalMarathonMatches/apply.js | 161 ++++++++++++++++-- .../resourceApi.js | 5 +- ...ortHistoricalMarathonMatches.apply.test.js | 103 +++++++++++ 3 files changed, 258 insertions(+), 11 deletions(-) diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js index fce0560..1938cc9 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js @@ -8,6 +8,7 @@ const { const STANDARD_PHASE_NAMES = ["Registration", "Submission", "Review"]; const DEFAULT_SUBMITTER_ROLE_ID = "732339e7-8e30-49d7-9198-cccf9451e221"; +const TEMPORARY_RESOURCE_WRITE_STATUS = "ACTIVE"; const parseRoundLegacyId = (roundId) => { const parsed = Number.parseInt(String(roundId || "").trim(), 10); @@ -396,6 +397,57 @@ const resolveCanonicalTimelineTemplateId = async (prisma, marathonTypeId, dataSc ); }; +const normalizeChallengeStatus = (value) => String(value || "").trim().toUpperCase(); + +const createPrismaChallengeStatusController = ({ prisma, actor }) => { + if ( + !prisma || + !prisma.challenge || + typeof prisma.challenge.findUnique !== "function" || + typeof prisma.challenge.update !== "function" + ) { + return null; + } + + return { + getChallengeStatus: async (challengeId) => { + const challenge = await prisma.challenge.findUnique({ + where: { id: challengeId }, + select: { status: true }, + }); + if (!challenge || !challenge.status) { + throw new Error(`Unable to read challenge status for ${challengeId}.`); + } + return normalizeChallengeStatus(challenge.status); + }, + updateChallengeStatus: async (challengeId, status) => + prisma.challenge.update({ + where: { id: challengeId }, + data: { + status, + updatedBy: actor, + }, + select: { id: true, status: true }, + }), + }; +}; + +const isCompletedChallengeResourceConstraintError = (error) => { + if (!error) { + return false; + } + const message = String(error.message || "").toLowerCase(); + const responseBody = String(error.responseBody || "").toLowerCase(); + const searchable = `${message} ${responseBody}`; + const hasCompletedSignal = searchable.includes("completed"); + const hasChallengeSignal = searchable.includes("challenge"); + const hasConstraintStatus = + error.httpStatus === undefined || + error.httpStatus === null || + [400, 403, 422].includes(Number.parseInt(error.httpStatus, 10)); + return hasCompletedSignal && hasChallengeSignal && hasConstraintStatus; +}; + const runApplyMode = async ({ prisma, options, @@ -422,6 +474,9 @@ const runApplyMode = async ({ if (actionableRoundIds.length > 0 && !resourceClient) { throw new Error("Resource API client is required for apply mode participant reconciliation."); } + const challengeStatusController = + options.challengeStatusController || + createPrismaChallengeStatusController({ prisma, actor }); let normalizedIdentityByCoderId = options.normalizedIdentityByCoderId instanceof Map @@ -512,6 +567,7 @@ const runApplyMode = async ({ normalizedIdentityByCoderId, resourceClient, submitterRoleId, + challengeStatusController, }); applyRecords.push({ recordType: "apply-record", @@ -566,6 +622,7 @@ const reconcileSubmitterResourcesForRound = async ({ normalizedIdentityByCoderId, resourceClient, submitterRoleId, + challengeStatusController, }) => { const eligibleMemberIdentities = buildEligibleMemberIdentities({ eligibleCoderIds: counters && counters.eligibleRegistrants ? counters.eligibleRegistrants : new Set(), @@ -602,25 +659,109 @@ const reconcileSubmitterResourcesForRound = async ({ }); let createdSubmitterResources = 0; - for (const identity of eligibleMemberIdentities) { - if (existingEligibleMemberIds.has(identity.memberId)) { - continue; + let usedTemporaryStatusTransition = false; + let originalChallengeStatus = null; + + const transitionChallengeToTemporaryWritableStatus = async () => { + if (!challengeStatusController) { + return false; + } + if (usedTemporaryStatusTransition) { + return true; + } + if ( + typeof challengeStatusController.getChallengeStatus !== "function" || + typeof challengeStatusController.updateChallengeStatus !== "function" + ) { + return false; + } + + const currentStatus = normalizeChallengeStatus( + await challengeStatusController.getChallengeStatus(challengeId) + ); + if (currentStatus !== "COMPLETED") { + return false; } - await resourceClient.createSubmitterResource({ + + await challengeStatusController.updateChallengeStatus( challengeId, - memberId: String(identity.memberId), - roleId: submitterRoleId, - }); - existingEligibleMemberIds.add(identity.memberId); - createdSubmitterResources += 1; + TEMPORARY_RESOURCE_WRITE_STATUS + ); + usedTemporaryStatusTransition = true; + originalChallengeStatus = currentStatus; + return true; + }; + + let operationError = null; + let restorationError = null; + try { + for (const identity of eligibleMemberIdentities) { + if (existingEligibleMemberIds.has(identity.memberId)) { + continue; + } + + const createPayload = { + challengeId, + memberId: String(identity.memberId), + roleId: submitterRoleId, + }; + + try { + await resourceClient.createSubmitterResource(createPayload); + } catch (error) { + const shouldAttemptStatusTransition = + isCompletedChallengeResourceConstraintError(error) && + (await transitionChallengeToTemporaryWritableStatus()); + if (!shouldAttemptStatusTransition) { + throw error; + } + await resourceClient.createSubmitterResource(createPayload); + } + + existingEligibleMemberIds.add(identity.memberId); + createdSubmitterResources += 1; + } + } catch (error) { + operationError = error; + } finally { + if (usedTemporaryStatusTransition && originalChallengeStatus) { + try { + await challengeStatusController.updateChallengeStatus( + challengeId, + originalChallengeStatus + ); + } catch (restoreError) { + restorationError = restoreError; + } + } } - return { + if (restorationError && operationError) { + throw new Error( + `${operationError.message} Failed to restore challenge ${challengeId} status to ${originalChallengeStatus}: ${restorationError.message}` + ); + } + if (restorationError) { + throw new Error( + `Failed to restore challenge ${challengeId} status to ${originalChallengeStatus}: ${restorationError.message}` + ); + } + if (operationError) { + throw operationError; + } + + const result = { targetEligibleRegistrants, existingSubmitterResources: targetEligibleRegistrants - createdSubmitterResources, createdSubmitterResources, unchangedSubmitterResources: targetEligibleRegistrants - createdSubmitterResources, }; + if (usedTemporaryStatusTransition) { + result.usedTemporaryStatusTransition = true; + result.originalChallengeStatus = originalChallengeStatus; + result.temporaryChallengeStatus = TEMPORARY_RESOURCE_WRITE_STATUS; + } + return result; }; module.exports = { diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/resourceApi.js b/data-migration/src/scripts/importHistoricalMarathonMatches/resourceApi.js index 3c52bb1..331aa5f 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/resourceApi.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/resourceApi.js @@ -167,9 +167,12 @@ const createResourceApiClient = ({ } const responseBodyText = await response.text(); if (!response.ok) { - throw new Error( + const error = new Error( `Failed to create submitter resource for challenge ${challengeId} member ${memberId} (${response.status} ${response.statusText})${responseBodyText ? `: ${responseBodyText}` : ""}.` ); + error.httpStatus = response.status; + error.responseBody = responseBodyText; + throw error; } if (!responseBodyText) { return null; diff --git a/data-migration/test/importHistoricalMarathonMatches.apply.test.js b/data-migration/test/importHistoricalMarathonMatches.apply.test.js index 4e1125f..77bfe86 100644 --- a/data-migration/test/importHistoricalMarathonMatches.apply.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.apply.test.js @@ -2,6 +2,7 @@ const { derivePhaseWindows, buildChallengePhaseRows, applyCreateRound, + reconcileSubmitterResourcesForRound, runApplyMode, } = require("../src/scripts/importHistoricalMarathonMatches/apply"); @@ -728,4 +729,106 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { }, ]); }); + + test("resource reconciliation temporarily transitions COMPLETED challenges and restores status on retry success", async () => { + const completedRestrictionError = new Error( + "Failed to create submitter resource for challenge challenge-1 member 2 (400 Bad Request): challenge is completed." + ); + completedRestrictionError.httpStatus = 400; + + const resourceClient = { + listSubmitterResources: jest.fn().mockResolvedValue([]), + createSubmitterResource: jest + .fn() + .mockRejectedValueOnce(completedRestrictionError) + .mockResolvedValueOnce({}), + }; + + const challengeStatusController = { + getChallengeStatus: jest.fn().mockResolvedValue("COMPLETED"), + updateChallengeStatus: jest.fn().mockResolvedValue({}), + }; + + const result = await reconcileSubmitterResourcesForRound({ + challengeId: "challenge-1", + counters: { + eligibleRegistrants: new Set(["2"]), + }, + normalizedIdentityByCoderId: new Map([ + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ]), + resourceClient, + submitterRoleId: "submitter-role", + challengeStatusController, + }); + + expect(challengeStatusController.getChallengeStatus).toHaveBeenCalledWith("challenge-1"); + expect(challengeStatusController.updateChallengeStatus).toHaveBeenNthCalledWith( + 1, + "challenge-1", + "ACTIVE" + ); + expect(challengeStatusController.updateChallengeStatus).toHaveBeenNthCalledWith( + 2, + "challenge-1", + "COMPLETED" + ); + expect(resourceClient.createSubmitterResource).toHaveBeenCalledTimes(2); + expect(result).toEqual({ + targetEligibleRegistrants: 1, + existingSubmitterResources: 0, + createdSubmitterResources: 1, + unchangedSubmitterResources: 0, + usedTemporaryStatusTransition: true, + originalChallengeStatus: "COMPLETED", + temporaryChallengeStatus: "ACTIVE", + }); + }); + + test("resource reconciliation restores original COMPLETED status when retry still fails", async () => { + const completedRestrictionError = new Error( + "Failed to create submitter resource for challenge challenge-1 member 2 (400 Bad Request): challenge is completed." + ); + completedRestrictionError.httpStatus = 400; + const secondFailure = new Error("Still rejected after temporary transition."); + + const resourceClient = { + listSubmitterResources: jest.fn().mockResolvedValue([]), + createSubmitterResource: jest + .fn() + .mockRejectedValueOnce(completedRestrictionError) + .mockRejectedValueOnce(secondFailure), + }; + + const challengeStatusController = { + getChallengeStatus: jest.fn().mockResolvedValue("COMPLETED"), + updateChallengeStatus: jest.fn().mockResolvedValue({}), + }; + + await expect( + reconcileSubmitterResourcesForRound({ + challengeId: "challenge-1", + counters: { + eligibleRegistrants: new Set(["2"]), + }, + normalizedIdentityByCoderId: new Map([ + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ]), + resourceClient, + submitterRoleId: "submitter-role", + challengeStatusController, + }) + ).rejects.toThrow("Still rejected after temporary transition."); + + expect(challengeStatusController.updateChallengeStatus).toHaveBeenNthCalledWith( + 1, + "challenge-1", + "ACTIVE" + ); + expect(challengeStatusController.updateChallengeStatus).toHaveBeenNthCalledWith( + 2, + "challenge-1", + "COMPLETED" + ); + }); }); From ba1cc6d3712ae3e70d4e4f35cfeaf488c80dd0f4 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 2 Apr 2026 10:11:11 +1100 Subject: [PATCH 13/27] Align importer mission artifacts with missing-member skip reporting Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .factory/library/architecture.md | 44 +++++++++++++--- .factory/library/environment.md | 7 +++ .factory/library/legacy-data.md | 61 +++++++++++++++++++++-- .factory/library/user-testing.md | 34 +++++++++++-- .factory/skills/migration-worker/SKILL.md | 27 +++++----- 5 files changed, 146 insertions(+), 27 deletions(-) diff --git a/.factory/library/architecture.md b/.factory/library/architecture.md index b5b532a..dcc1e07 100644 --- a/.factory/library/architecture.md +++ b/.factory/library/architecture.md @@ -36,6 +36,8 @@ The importer accepts an explicit round filter and builds a per-round plan. Each Planning is required to surface traceability, counts, and entity-level deltas before writes occur. +`--existing-state-file` is supplemental only. It may enrich counts for reporting, but it is not authoritative reuse evidence and must never override direct challenge-state discovery. + ### Existing-challenge match rule Safe reuse is authoritative, not fuzzy: @@ -47,6 +49,8 @@ Safe reuse is authoritative, not fuzzy: This keeps backfill-only behavior deterministic and avoids silent challenge-level rewrites. +If authoritative challenge-state discovery is unavailable, planning must fail closed as `unresolved` instead of silently falling back to create-path planning. + ### 2. Challenge reconciliation For each selected round: @@ -77,9 +81,11 @@ When creating a historical challenge: If required timestamps are missing or contradictory enough that a coherent closed timeline cannot be produced, the round should remain `unresolved` instead of being half-created. +Planning must perform this same canonical MM/Data Science timeline-mapping resolution before returning `decision=create`; dry-run must not promise creates that apply would later reject. + ### 4. Participant materialization -Submitter resources come from legacy registrations, not just from members with submissions. The importer must create or reuse exactly one submitter-role resource per eligible registrant. +Submitter resources come from legacy registrations, not just from members with submissions. The importer must create or reuse exactly one submitter-role resource per eligible registrant that resolves in the target environment. **Eligible registrant rule:** every distinct `round_registration.coder_id` for the selected round where `eligible == '1'`. @@ -87,9 +93,32 @@ Submitter resources come from legacy registrations, not just from members with s **Stable resource dedup key:** `(challengeId, memberId, roleId=submitter)`. +### Missing-member skip policy + +If the target dev environment does not contain a legacy member, classify that member as `missing-member` for the current run and: + +- skip resource creation for that member +- skip that member's non-example submissions +- skip that member's final and provisional review materialization +- continue importing other members for the round +- write a deterministic skipped-file artifact for later manual processing + +The skipped artifact should be stable enough for rerun comparison and manual recovery, including at least the legacy round id, member id, skip reason, and affected surfaces. + +### Approved completed-challenge resource workflow + +If the Resource API refuses submitter creation on a completed historical challenge, the user has approved a temporary status-transition workflow solely for submitter-resource backfill: + +- capture the original challenge status first +- transition only as much as needed to satisfy the Resource API write constraint +- create the missing submitter resources through the Resource API +- restore the challenge to its original completed state before the importer finishes + +This workflow is a narrow exception for historical resource backfill only; it does not authorize general challenge-level rewrites. + ### 5. Submission materialization -Only non-example legacy submissions are imported. The importer must preserve the full non-example history per member. +Only non-example legacy submissions are imported. The importer must preserve the full non-example history for members that resolve in the target environment, and explicitly skip/report missing-member rows instead of creating partial participant footprints. **Stable submission identity invariant:** imported `Submission.legacySubmissionId` must be a deterministic composite derived from legacy submission identity so round-wide and rerun validation can compare exact sets. The contract assumes `legacySubmissionId` is the stable external identity for imported submissions. @@ -98,7 +127,7 @@ Only non-example legacy submissions are imported. The importer must preserve the Two score streams are imported: - **provisional history** — one provisional review summation per imported non-example submission, using `long_submission.submission_points` -- **final result** — one final review summation per member, attached to the member's latest imported non-example submission +- **final result** — one final review summation per imported member, attached to that member's latest imported non-example submission Final-score derivation uses legacy final-result fields with the agreed precedence: @@ -106,7 +135,7 @@ Final-score derivation uses legacy final-result fields with the agreed precedenc 2. `long_comp_result.point_total` 3. the ranking score from legacy state data used for final ordering -If a legacy finalist has no imported non-example submission to attach to, the importer must skip that final score explicitly rather than create an orphan final review summation. +If a legacy finalist has no imported non-example submission to attach to, the importer must skip that final score explicitly rather than create an orphan final review summation. Missing-member skips should be reported distinctly from other skip reasons. **Stable review-summation dedup keys:** @@ -134,6 +163,8 @@ Cross-service writes are not a single distributed transaction. The importer ther The observable result of rerunning a partially imported round should be reconciliation to the same steady state, not duplication or destructive rewrite. +If a temporary status-transition workflow is used during participant backfill, reruns must still converge to the same final completed state. + ## Data Ownership Invariants ### Challenge DB @@ -162,8 +193,9 @@ Owns: The validation contract relies on these high-level invariants being preserved: -- round `9892` is the primary missing-historical create-path fixture -- round `10089` is the score-rich final-ranking fixture +- round `10815` is the primary missing-historical create-path fixture +- a score-rich Marathon Match fixture is selected during score-feature work for final-ranking validation - round `14272` is the second selected round for multi-round blast-radius checks - imported submission identity is externally testable via `legacySubmissionId` - reused-round verification depends on comparing both identity sets and externally visible field snapshots +- for member-owned surfaces, validation now reconciles `imported subset + skipped missing-member subset = legacy total` diff --git a/.factory/library/environment.md b/.factory/library/environment.md index f2247ba..46f12c7 100644 --- a/.factory/library/environment.md +++ b/.factory/library/environment.md @@ -26,6 +26,13 @@ Optional / useful values: - `DATA_DIRECTORY=/mnt/Informix` - importer-scoped attribution values such as `CREATED_BY` / `UPDATED_BY` +## Canonical API Endpoints For Validation + +- Challenge API base URL: `https://api.topcoder-dev.com/v6/challenges` +- Resource API base URL: read from `RESOURCES_API_URL` in `.env.importer.local` + +Workers and validators should use these canonical endpoints rather than probing localhost guesses when validating against the populated dev environment. + ## Runtime Boundaries - `/mnt/Informix` is a read-only legacy data source. diff --git a/.factory/library/legacy-data.md b/.factory/library/legacy-data.md index 7477f9a..67312ae 100644 --- a/.factory/library/legacy-data.md +++ b/.factory/library/legacy-data.md @@ -39,6 +39,13 @@ Use this legacy relationship when deriving participant/submission/final-score da - example submissions are excluded from imported submissions and imported score history - imported `Submission.legacySubmissionId` must be deterministic and stable across reruns +### Named participant fixture + +- round `10815`, member `22664170` (`Marinov_Martin`): + - `27` non-example submissions + - `55` example runs + - latest non-example submit timestamp: `1180539064719` + ## Score Rules ### Provisional @@ -57,7 +64,53 @@ Use this legacy relationship when deriving participant/submission/final-score da ## Fixture Rounds -- `9892`: `1108` eligible registrations, `3217` non-example submissions, `2381` example submissions, `354` submitters with non-example history -- `10089`: clean final-score round with `115` non-null `system_point_total` finalists -- `14272`: second multi-round blast-radius fixture with `3326` non-example submissions -- `10722`: useful edge-case round for finalists without attachable non-example submissions and duplicate placements +- `10815`: `836` eligible registrations, `1445` non-example submissions, `2424` example submissions, `267` submitters with non-example history, and `16` unattachable finalists; use as the primary missing-historical create-path fixture +- a score-rich Marathon Match round should be selected during score-feature work; do not assume `10089` remains a valid Marathon Match fixture in the current validation environment without reconfirmation +- `14272`: second selected-round filter fixture; current validation guidance treats it as an unresolved/non-Marathon-Match round rather than an importable Marathon Match target +- an edge-case Marathon Match round with unattachable finalists should be selected during score-feature work; do not assume `10722` remains valid in the current validation environment without reconfirmation + +## Existing-State Snapshot File (`--existing-state-file`) + +- Purpose: optional offline count hints for reporting only +- Authoritative source of truth: direct challenge-state discovery through the challenge DB / challenge-api schema +- Non-authoritative rule: this file must never override create vs reuse/backfill classification + +### How validators can create one + +There is no committed generator script. When a validator explicitly needs to exercise the supplemental snapshot path, create a small hand-authored JSON file from prior read-only API or DB observations, for example: + +```bash +cat > /tmp/existing-state.json <<'JSON' +{ + "rounds": { + "10815": { + "challengeId": "5fa76bd9-da55-422d-8d4c-4f0155dc62c5", + "existing": { + "phases": 3, + "resources": 57, + "submissions": 0, + "finalScores": 0, + "provisionalScores": 0 + } + } + } +} +JSON +``` + +### Accepted schema + +- top-level object +- either: + - `{"rounds": [{"legacyRoundId": "...", "challengeId": "...", "existing": {...}}]}` + - `{"rounds": {"10815": {"challengeId": "...", "existing": {...}}}}` + - or a plain object keyed by legacy round id +- each entry may contain: + - `challengeId` + - `existing.phases` + - `existing.resources` + - `existing.submissions` + - `existing.finalScores` + - `existing.provisionalScores` + +Invalid or mismatched counts should affect only supplemental reporting, never authoritative reuse matching. diff --git a/.factory/library/user-testing.md b/.factory/library/user-testing.md index 43c0bcf..0f1535e 100644 --- a/.factory/library/user-testing.md +++ b/.factory/library/user-testing.md @@ -25,16 +25,25 @@ There is no browser or TUI surface for this mission. 2. Capture per-round decision records and deltas. 3. Run apply for the same selected round set. 4. Verify challenge/resource/submission/review state through API responses. -5. Compare imported data to legacy data using read-only Python scripts. -6. Re-run apply or dry-run to prove idempotency. +5. Inspect the skipped-file artifact for any missing-member records reported by the run. +6. Compare imported data plus skipped-member reporting to legacy data using read-only Python scripts. +7. Re-run apply or dry-run to prove idempotency. + +Canonical live validation endpoints: + +- Challenge API: `https://api.topcoder-dev.com/v6/challenges` +- Resource API: from `RESOURCES_API_URL` in `.env.importer.local` + +If participant backfill uses the approved temporary status-transition workflow, validators should verify the post-apply state only: the challenge must end in its original completed state after Resource API writes finish. +If the approved missing-member policy is exercised, validators should reconcile `imported + skipped = legacy total` on member-owned surfaces and verify the skipped-file artifact records the skipped members and reason codes. ### Fixture rounds -- `9892` — missing-historical create-path round -- `10089` — score-rich final-score ranking round +- `10815` — primary create-path round during planning-challenge; in the shared dev environment it is now a post-create/backfill fixture +- one score-rich Marathon Match round selected during score-feature work for final-ranking validation - `14272` — second round for multi-round filter checks - one existing-v6 round chosen from dry-run output in the validation environment -- one round with unattachable finalists (for explicit skip/report validation), e.g. `10722` +- one Marathon Match round with unattachable finalists selected during score-feature work for explicit skip/report validation ## Validation Concurrency @@ -49,3 +58,18 @@ There is no browser or TUI surface for this mission. - Validation uses the existing dev environment referenced by `.env.importer.local`. - `.env.importer.local` is populated, so live end-to-end apply-mode validation can proceed on the selected dev environment. - Pre-existing repo-wide `standard-lint` noise in `challenge-api-v6` should not be mistaken for importer regressions; validators should focus on mission-owned surfaces. +- The shared dev environment does not necessarily contain every historical legacy member id, so member-owned validation must account for approved `missing-member` skips rather than assuming full one-to-one import coverage. + +## Flow Validator Guidance: importer CLI + API verification + +- Treat `legacyId=13897` / challenge `a15cbb04-a0d3-4647-85bd-23d8d11e9f3f` as an already-imported shared-environment fixture. Use it for reuse/rerun and post-import property checks only; do not attempt destructive cleanup or concurrent apply-mode validation against it. +- Round `10815` was imported during planning-challenge user-testing round 2 as challenge `5fa76bd9-da55-422d-8d4c-4f0155dc62c5`. In the shared dev environment it is now a post-create fixture rather than a pristine missing-historical round, so future validators should not expect pre-apply create-path evidence there unless they use a clean/reset environment. +- Immediate rerun dry-run on `10815` now reports `reuse/backfill-only` with `phases.toCreate=0`, but still classifies the round as `partial-backfill` because resources/submissions/finalScores/provisionalScores remain pending later-milestone work. Use it to verify challenge/phase reuse only, not full-surface no-op reruns. +- In the current shared dev environment, `13897` is a partial-backfill fixture: the challenge and standard phases already exist, while linked resources/submissions/review-summation surfaces still read as empty. That means no-op rerun assertions for a fully imported round cannot be proven here without a separately completed fixture. +- `GET https://api.topcoder-dev.com/v6/challenges` and `GET https://api.topcoder-dev.com/v6/challenges/` work without auth in this environment. `GET https://api.topcoder-dev.com/v6/resources?challengeId=` and `GET https://api.topcoder-dev.com/v6/submissions?challengeId=` are also readable without auth. +- `GET https://api.topcoder-dev.com/v6/reviewSummations?challengeId=` requires an M2M bearer token. Source `.env.importer.local`, run `node get_token.js`, and use the final stdout line as the token value. +- When participant backfill encounters legacy members absent from the dev environment, validators should expect a skipped-file artifact and should confirm that the skipped member ids plus the imported member-owned records reconcile back to the legacy totals for the round. +- Round `14272` currently dry-runs as `decision=unresolved` with reason `selected-round-round-type-is-not-marathon-match`; it remains useful for exact-filter and unresolved-path validation but should not be treated as an importable Marathon Match fixture. +- Previously considered score candidates such as `10089` and `10722` should not be assumed valid Marathon Match fixtures in the current validation environment unless a later score-feature investigation reconfirms them. +- Dry-run planning against `/mnt/Informix` can take several minutes; use generous timeouts (roughly 360-480s) for evidence-capture runs to avoid false timeout failures. +- Do not run apply-mode validators concurrently on the same round or shared dev database. Read-only dry-run/API checks may run concurrently only when they avoid rounds being mutated by another validator. diff --git a/.factory/skills/migration-worker/SKILL.md b/.factory/skills/migration-worker/SKILL.md index ad14f74..b0b0205 100644 --- a/.factory/skills/migration-worker/SKILL.md +++ b/.factory/skills/migration-worker/SKILL.md @@ -29,32 +29,35 @@ None. 3. Switch to the correct Node version in the same shell command before running anything: - repo root: `nvm use` - `data-migration/`: `nvm use 18.19.0` -4. Write tests first (red) for the behavior you are adding. Prefer `data-migration/test/**` for unit/integration tests that exercise: +4. If you are resuming an interrupted feature and the relevant implementation/tests are already on `HEAD`, verify that existing work against the contract first instead of restarting the red/green loop from scratch. Record in the handoff that this was a resume-validation case and cite the commit or files you validated. +5. Otherwise, write tests first (red) for the behavior you are adding. Prefer `data-migration/test/**` for unit/integration tests that exercise: - round planning and filtering - deterministic `legacySubmissionId` - create vs reuse/backfill-only reconciliation - score derivation and attachment - idempotent reruns -5. Run the new tests and confirm they fail before implementing. Record the exact failing command and observation in the handoff. -6. Implement the minimal code needed to satisfy the feature. Keep write paths aligned with architecture boundaries: +6. Run the new tests and confirm they fail before implementing. Record the exact failing command and observation in the handoff. +7. Implement the minimal code needed to satisfy the feature. Keep write paths aligned with architecture boundaries: - challenge / phase writes in the challenge DB - resource writes through the Resource API - submission / review-summation writes in the review DB -7. Preserve mission invariants while implementing: +8. Preserve mission invariants while implementing: - existing v6 marathon challenges are challenge-level source of truth - already-present standard phase rows on reused challenges are preserved - example submissions and example review summations are never imported - imported submissions expose stable `legacySubmissionId` - reruns must not create duplicates or rewrite preserved records -8. After implementation, run targeted validators from `.factory/services.yaml`: +9. After implementation or resume-validation, run targeted validators from `.factory/services.yaml`: - `commands.test` - `commands.lint` - if you touched repo-root code outside `data-migration/`, also run `commands.root_smoke_test` and any targeted repo-root checks needed for the changed files -9. Manually verify the feature at the CLI/API surface when possible: - - use dry-run for planning features - - use apply-mode only when the env file and target round selection are ready - - verify the exact API-visible data that corresponds to the feature's `fulfills` assertions -10. End with a precise handoff. Be explicit about what was implemented, which assertions became testable, what commands ran, what manual checks were performed, and any tech debt or unresolved ambiguity. +10. Manually verify the feature at the CLI/API surface when possible: + +- use dry-run for planning features +- use apply-mode only when the env file and target round selection are ready +- verify the exact API-visible data that corresponds to the feature's `fulfills` assertions + +11. End with a precise handoff. Be explicit about what was implemented, which assertions became testable, what commands ran, what manual checks were performed, whether this was a resume-validation case, and any tech debt or unresolved ambiguity. ## Example Handoff @@ -66,7 +69,7 @@ None. "verification": { "commandsRun": [ { - "command": "source \\\"$HOME/.config/nvm/nvm.sh\\\" && cd /home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration && nvm use 18.19.0 >/dev/null && pnpm test -- --maxWorkers=16 --runInBand plan-reporting.test.js", + "command": "source \\\"$HOME/.config/nvm/nvm.sh\\\" && cd /home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration && nvm use 18.19.0 >/dev/null && pnpm test --maxWorkers=16 --runInBand plan-reporting.test.js", "exitCode": 0, "observation": "New planning tests passed after implementation; they failed before the code change because the CLI report omitted matched challenge ids and delta fields." }, @@ -78,7 +81,7 @@ None. ], "interactiveChecks": [ { - "action": "Ran importer dry-run for round 9892 with missing env writes disabled and inspected the labeled per-round record.", + "action": "Ran importer dry-run for round 10815 with missing env writes disabled and inspected the labeled per-round record.", "observed": "CLI reported decision=create with separate resource/submission/final/provisional deltas, traceability identifiers, and no stdin prompt." } ] From 47f4ade125d34f738dc27a1fd2b959732e972d7b Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 2 Apr 2026 11:32:39 +1100 Subject: [PATCH 14/27] Fix member-resolution bigint lookup for live missing-member planning Cast target-member lookup placeholders to bigint to avoid postgres type-mismatch failures, and add regression coverage so dry-run can classify missing-member partitions from live MEMBER_DB data. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../targetMemberResolution.js | 67 +++++++++++++++++++ ...thonMatches.targetMemberResolution.test.js | 26 +++++++ 2 files changed, 93 insertions(+) create mode 100644 data-migration/src/scripts/importHistoricalMarathonMatches/targetMemberResolution.js create mode 100644 data-migration/test/importHistoricalMarathonMatches.targetMemberResolution.test.js diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/targetMemberResolution.js b/data-migration/src/scripts/importHistoricalMarathonMatches/targetMemberResolution.js new file mode 100644 index 0000000..a0db91e --- /dev/null +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/targetMemberResolution.js @@ -0,0 +1,67 @@ +"use strict"; + +const DEFAULT_MEMBER_SCHEMA = "members"; +const TARGET_MEMBER_RESOLUTION_UNAVAILABLE_REASON = "target-member-resolution-unavailable"; + +const normalizeMemberSchema = (value) => { + const normalized = String(value || DEFAULT_MEMBER_SCHEMA).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(normalized)) { + throw new Error(`Invalid MEMBER_DB_SCHEMA "${normalized}"`); + } + return normalized; +}; + +const chunkArray = (items, size) => { + const chunks = []; + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)); + } + return chunks; +}; + +const normalizeMemberId = (value) => { + const parsed = Number.parseInt(String(value || "").trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return String(parsed); +}; + +const createMemberPresenceResolver = ({ prisma, memberSchema = DEFAULT_MEMBER_SCHEMA }) => { + if (!prisma || typeof prisma.$queryRawUnsafe !== "function") { + throw new Error("A Prisma client with $queryRawUnsafe is required for member resolution."); + } + + const normalizedSchema = normalizeMemberSchema(memberSchema); + + return async ({ memberIds = [] }) => { + const normalizedMemberIds = Array.from( + new Set(memberIds.map((memberId) => normalizeMemberId(memberId)).filter(Boolean)) + ); + if (normalizedMemberIds.length === 0) { + return new Set(); + } + + const resolvedMemberIds = new Set(); + const batches = chunkArray(normalizedMemberIds, 1000); + for (const batch of batches) { + const placeholders = batch.map((_, index) => `$${index + 1}::bigint`).join(", "); + const query = `SELECT "userId" FROM "${normalizedSchema}"."member" WHERE "userId" IN (${placeholders})`; + const rows = await prisma.$queryRawUnsafe(query, ...batch); + (rows || []).forEach((row) => { + const memberId = normalizeMemberId(row && row.userId); + if (memberId) { + resolvedMemberIds.add(memberId); + } + }); + } + + return resolvedMemberIds; + }; +}; + +module.exports = { + DEFAULT_MEMBER_SCHEMA, + TARGET_MEMBER_RESOLUTION_UNAVAILABLE_REASON, + createMemberPresenceResolver, +}; diff --git a/data-migration/test/importHistoricalMarathonMatches.targetMemberResolution.test.js b/data-migration/test/importHistoricalMarathonMatches.targetMemberResolution.test.js new file mode 100644 index 0000000..03442e4 --- /dev/null +++ b/data-migration/test/importHistoricalMarathonMatches.targetMemberResolution.test.js @@ -0,0 +1,26 @@ +const { + createMemberPresenceResolver, +} = require("../src/scripts/importHistoricalMarathonMatches/targetMemberResolution"); + +describe("importHistoricalMarathonMatches target member resolution", () => { + test("casts lookup placeholders to bigint and returns normalized member ids", async () => { + const prisma = { + $queryRawUnsafe: jest.fn().mockResolvedValue([{ userId: 1 }, { userId: "2" }]), + }; + const resolveMemberPresence = createMemberPresenceResolver({ + prisma, + memberSchema: "members", + }); + + const resolved = await resolveMemberPresence({ + memberIds: ["1", "2", "2", "invalid"], + }); + + expect(prisma.$queryRawUnsafe).toHaveBeenCalledWith( + 'SELECT "userId" FROM "members"."member" WHERE "userId" IN ($1::bigint, $2::bigint)', + "1", + "2" + ); + expect(resolved).toEqual(new Set(["1", "2"])); + }); +}); From 0bb291a73381d56c0c0f4ebca3beb462cd38bf17 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 2 Apr 2026 11:33:39 +1100 Subject: [PATCH 15/27] Finalize missing-member planning partitions and skipped-artifact reporting Commit the resumed importer planning/reporting implementation and mission artifacts, including fail-closed prerequisite handling, stable missing-member/explicit-skip partitions, and deterministic skipped-file output coverage. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .factory/library/environment.md | 2 + .factory/library/user-testing.md | 1 + ...itative-round-selection-and-reuse-fix.json | 22 + ...-phase-plan-and-rerun-convergence-fix.json | 22 + ...er-create-historical-challenge-phases.json | 28 + .../reviews/mm-importer-plan-cli.json | 50 ++ .../mm-importer-reuse-challenge-safety.json | 28 + .../flows/challenge-reuse-apply.json | 108 +++ .../flows/create-and-rerun-10815.json | 143 ++++ .../flows/create-path-plan-check.json | 33 + .../user-testing/flows/dry-run-deltas.json | 81 +++ .../user-testing/flows/plan-cli-core.json | 98 +++ .../user-testing/synthesis.json | 16 + .../user-testing/synthesis.round1.json | 58 ++ .../user-testing/synthesis.round2.json | 31 + .../importHistoricalMarathonMatches.js | 64 +- .../importHistoricalMarathonMatches/apply.js | 37 + .../argParser.js | 7 + .../planning.js | 670 +++++++++++++++++- .../skippedArtifact.js | 156 ++++ ...ortHistoricalMarathonMatches.apply.test.js | 39 +- ...athonMatches.missingMemberPlanning.test.js | 305 ++++++++ ...athonMatches.planningPrerequisites.test.js | 4 + 23 files changed, 1954 insertions(+), 49 deletions(-) create mode 100644 .factory/validation/planning-challenge/scrutiny/reviews/mm-importer-authoritative-round-selection-and-reuse-fix.json create mode 100644 .factory/validation/planning-challenge/scrutiny/reviews/mm-importer-challenge-phase-plan-and-rerun-convergence-fix.json create mode 100644 .factory/validation/planning-challenge/scrutiny/reviews/mm-importer-create-historical-challenge-phases.json create mode 100644 .factory/validation/planning-challenge/scrutiny/reviews/mm-importer-plan-cli.json create mode 100644 .factory/validation/planning-challenge/scrutiny/reviews/mm-importer-reuse-challenge-safety.json create mode 100644 .factory/validation/planning-challenge/user-testing/flows/challenge-reuse-apply.json create mode 100644 .factory/validation/planning-challenge/user-testing/flows/create-and-rerun-10815.json create mode 100644 .factory/validation/planning-challenge/user-testing/flows/create-path-plan-check.json create mode 100644 .factory/validation/planning-challenge/user-testing/flows/dry-run-deltas.json create mode 100644 .factory/validation/planning-challenge/user-testing/flows/plan-cli-core.json create mode 100644 .factory/validation/planning-challenge/user-testing/synthesis.json create mode 100644 .factory/validation/planning-challenge/user-testing/synthesis.round1.json create mode 100644 .factory/validation/planning-challenge/user-testing/synthesis.round2.json create mode 100644 data-migration/src/scripts/importHistoricalMarathonMatches/skippedArtifact.js create mode 100644 data-migration/test/importHistoricalMarathonMatches.missingMemberPlanning.test.js diff --git a/.factory/library/environment.md b/.factory/library/environment.md index 46f12c7..4aa5b13 100644 --- a/.factory/library/environment.md +++ b/.factory/library/environment.md @@ -14,6 +14,8 @@ The importer must load `challenge-api-v6/.env.importer.local` for local/dev exec Required values: - `DATABASE_URL` — challenge DB used by `challenge-api-v6` +- `MEMBER_DB_URL` — member lookup DB connection string for target-member resolution during missing-member planning/validation; defaults to `DATABASE_URL` only when that DB can also resolve member data +- `MEMBER_DB_SCHEMA` — schema used for member lookup tables (default behavior is code-defined; validators should set it explicitly when member data is not reachable through the challenge schema) - `REVIEW_DB_URL` — review DB used for submissions and review summations - `RESOURCES_API_URL` — base URL for Resource API writes and reads - `AUTH0_URL` diff --git a/.factory/library/user-testing.md b/.factory/library/user-testing.md index 0f1535e..bde5983 100644 --- a/.factory/library/user-testing.md +++ b/.factory/library/user-testing.md @@ -59,6 +59,7 @@ If the approved missing-member policy is exercised, validators should reconcile - `.env.importer.local` is populated, so live end-to-end apply-mode validation can proceed on the selected dev environment. - Pre-existing repo-wide `standard-lint` noise in `challenge-api-v6` should not be mistaken for importer regressions; validators should focus on mission-owned surfaces. - The shared dev environment does not necessarily contain every historical legacy member id, so member-owned validation must account for approved `missing-member` skips rather than assuming full one-to-one import coverage. +- If dry-run/apply returns `target-member-resolution-unavailable`, the validation environment still lacks reachable member lookup configuration. Provide `MEMBER_DB_URL` (or a `DATABASE_URL` that can resolve members) plus a valid `MEMBER_DB_SCHEMA` before expecting populated missing-member partitions or skipped-file records from live runs. ## Flow Validator Guidance: importer CLI + API verification diff --git a/.factory/validation/planning-challenge/scrutiny/reviews/mm-importer-authoritative-round-selection-and-reuse-fix.json b/.factory/validation/planning-challenge/scrutiny/reviews/mm-importer-authoritative-round-selection-and-reuse-fix.json new file mode 100644 index 0000000..5aeb59c --- /dev/null +++ b/.factory/validation/planning-challenge/scrutiny/reviews/mm-importer-authoritative-round-selection-and-reuse-fix.json @@ -0,0 +1,22 @@ +{ + "featureId": "mm-importer-authoritative-round-selection-and-reuse-fix", + "reviewedAt": "2026-04-01T02:59:36Z", + "commitId": "83c8ac843def95d2b578e3aefd9ca943dd6d6923", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "fail", + "codeReview": { + "summary": "The new matcher correctly rejects non-MM rounds and unsafe/ambiguous legacyId reuse targets when authoritative discovery is available, but the feature still fails scrutiny because dry-run silently drops the authoritative matcher whenever direct DB discovery is unavailable and then plans every selected round as create.", + "issues": [ + { + "file": "data-migration/src/scripts/importHistoricalMarathonMatches.js", + "line": 42, + "severity": "blocking", + "description": "Dry-run only attempts authoritative existing-v6 discovery when `DATABASE_URL` is already present, and discovery errors are downgraded to a warning that continues \"without reuse matching\". The fallback path then calls `buildExistingStateByRoundId({ prisma: null, ... })`, whose early return leaves every round at `matchStatus: \"none\"` / `reason: \"no-matching-v6-challenge-found\"` (`data-migration/src/scripts/importHistoricalMarathonMatches/existingState.js:128-130`). That means a real rerun/backfill target is reported as `decision: \"create\"` instead of surfacing a true planning-prerequisite failure or unresolved result, so dry-run and apply no longer share the same authoritative matcher this feature was supposed to unify. The new test suite codifies the regression by forcing `DATABASE_URL: \"\"` and expecting `decision: \"create\"` for the snapshot-backed round (`data-migration/test/importHistoricalMarathonMatches.plan.test.js:101-104,262-280`). This is a correctness/safety issue because operators can receive a misleading create plan for an already-imported round whenever direct discovery is unavailable." + } + ] + }, + "sharedStateObservations": [], + "addressesFailureFrom": null, + "summary": "Reviewed feature `mm-importer-authoritative-round-selection-and-reuse-fix` at commit `83c8ac843def95d2b578e3aefd9ca943dd6d6923`. The direct matcher itself is a solid improvement, but the feature fails scrutiny because dry-run still silently falls back to `create` planning when authoritative DB-backed reuse discovery is unavailable, which undermines the intended planning/apply convergence." +} diff --git a/.factory/validation/planning-challenge/scrutiny/reviews/mm-importer-challenge-phase-plan-and-rerun-convergence-fix.json b/.factory/validation/planning-challenge/scrutiny/reviews/mm-importer-challenge-phase-plan-and-rerun-convergence-fix.json new file mode 100644 index 0000000..76760a4 --- /dev/null +++ b/.factory/validation/planning-challenge/scrutiny/reviews/mm-importer-challenge-phase-plan-and-rerun-convergence-fix.json @@ -0,0 +1,22 @@ +{ + "featureId": "mm-importer-challenge-phase-plan-and-rerun-convergence-fix", + "reviewedAt": "2026-04-01T13:59:51+11:00", + "commitId": "765e7d7f26dc4a1c6a9815bd866421be6131f5a3", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "fail", + "codeReview": { + "summary": "The commit fixes the original create-path phase-plan reporting gap and the partial-rerun phase backfill behavior, but dry-run/create alignment is still incomplete. Planning now hard-codes a Marathon Match/Data Science create shape plus phase windows, yet it still does not validate the canonical timeline-template prerequisite that apply mode enforces before any create can succeed.", + "issues": [ + { + "file": "data-migration/src/scripts/importHistoricalMarathonMatches/planning.js", + "line": 164, + "severity": "blocking", + "description": "For create-path rounds, planning now emits `createPathChallengeShape` and `createPathPhasePlan`, but it never resolves the canonical Marathon Match/Data Science timeline mapping before returning `decision: \"create\"`. Apply mode still calls `resolveCanonicalTimelineTemplateId()` (`data-migration/src/scripts/importHistoricalMarathonMatches/apply.js:334-388,411-415`) and aborts when zero or multiple valid mappings exist. Because `planning.js:164-177` and `planning.js:285-289` hard-code the create-path shape/phase plan without that check, dry-run can still promise a create plan that apply cannot materialize in the target environment. That breaks the feature's requirement that dry-run promises stay aligned with apply-time reconciliation for create-path rounds and conflicts with `.factory/library/architecture.md:73`, which requires create-path planning to stop with `unresolved` when the canonical timeline mapping cannot be resolved." + } + ] + }, + "sharedStateObservations": [], + "addressesFailureFrom": null, + "summary": "Reviewed the feature handoff, transcript skeleton, shared-state docs, and commit `765e7d7f26dc4a1c6a9815bd866421be6131f5a3`. Result: fail. The rerun-backfill fix itself looks correct, but dry-run still omits one create-path prerequisite check (canonical MM/Data Science timeline mapping), so planning can advertise `decision=create` in environments where apply will reject the round." +} diff --git a/.factory/validation/planning-challenge/scrutiny/reviews/mm-importer-create-historical-challenge-phases.json b/.factory/validation/planning-challenge/scrutiny/reviews/mm-importer-create-historical-challenge-phases.json new file mode 100644 index 0000000..730bead --- /dev/null +++ b/.factory/validation/planning-challenge/scrutiny/reviews/mm-importer-create-historical-challenge-phases.json @@ -0,0 +1,28 @@ +{ + "featureId": "mm-importer-create-historical-challenge-phases", + "reviewedAt": "2026-04-01T13:59:04+11:00", + "commitId": "13106e58b307b6587a79c1252fe203d01aeada61", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "fail", + "codeReview": { + "summary": "The new create path covers the clean happy path for creating a completed historical Marathon Match challenge with derived Registration/Submission/Review phases, but the apply-mode reuse logic is not safe enough to satisfy the mission invariants. Existing `legacyId` matches are treated as automatic success, so reruns do not reconcile missing standard phases and unsafe/ambiguous challenge matches are silently reused.", + "issues": [ + { + "file": "data-migration/src/scripts/importHistoricalMarathonMatches/apply.js", + "line": 159, + "severity": "blocking", + "description": "As soon as `challenge.findMany({ where: { legacyId } })` returns any row, `applyCreateRound()` returns `status: \"existing\"` without reading that challenge's phase state or backfilling missing standard phases. That means rerunning apply against an existing challenge that is missing one or more of `Registration` / `Submission` / `Review` will report success but leave the challenge in a state that still violates the feature's expected behavior and VAL-CHALLENGE-003/004/008. The accompanying test at `data-migration/test/importHistoricalMarathonMatches.apply.test.js:136` locks in this behavior by asserting that reruns never call `challengePhase.createMany()`." + }, + { + "file": "data-migration/src/scripts/importHistoricalMarathonMatches/apply.js", + "line": 154, + "severity": "blocking", + "description": "The reuse path matches only on `challenge.legacyId`, selects just `id`, and then returns the first row found. It never verifies that there is exactly one match, that the matched challenge is Marathon Match / Data Science, or that the existing phase roster is a safe reuse target. In a database with duplicate `legacyId` rows or a non-MM/DS challenge sharing the same `legacyId`, apply mode will bind to whichever row Prisma returns and falsely report `existing` instead of rejecting the round as ambiguous/unsafe. That breaks the backfill-only safety rule documented in `AGENTS.md` and `.factory/library/architecture.md`." + } + ] + }, + "sharedStateObservations": [], + "addressesFailureFrom": null, + "summary": "Reviewed the handoff, transcript skeleton, shared-state docs, and commit `13106e58b307b6587a79c1252fe203d01aeada61`. Result: fail, with two blocking apply-mode correctness/safety issues—reruns do not converge missing standard phases, and raw `legacyId` reuse can silently bind the importer to an unsafe or ambiguous existing challenge." +} diff --git a/.factory/validation/planning-challenge/scrutiny/reviews/mm-importer-plan-cli.json b/.factory/validation/planning-challenge/scrutiny/reviews/mm-importer-plan-cli.json new file mode 100644 index 0000000..cd01877 --- /dev/null +++ b/.factory/validation/planning-challenge/scrutiny/reviews/mm-importer-plan-cli.json @@ -0,0 +1,50 @@ +{ + "featureId": "mm-importer-plan-cli", + "reviewedAt": "2026-04-01T03:00:49.605010+00:00", + "commitId": "6a38297ad56c1d7b845c8b684639e3a0e53cafb3", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "fail", + "codeReview": { + "summary": "Fail. The feature establishes a dry-run CLI and deterministic record format, but it misses three contract-critical planning behaviors: authoritative reuse detection, marathon-only round selection, and complete create-path reporting.", + "issues": [ + { + "file": "data-migration/src/scripts/importHistoricalMarathonMatches.js", + "line": 32, + "severity": "blocking", + "description": "Existing-v6 reconciliation is snapshot-only. The CLI never queries an authoritative v6 surface; it only loads `--existing-state-file`, and `planning.js` switches to `reuse/backfill-only` solely from the presence of `existingStateEntry.challengeId`. Without that sidecar file, real existing-v6 rounds are always planned as `create`, so the feature cannot satisfy matched challenge-id reporting, reuse/backfill deltas, or rerun no-op classification for normal dry-runs. Because the snapshot is arbitrary JSON, a stale or hand-edited `challengeId` can also force an unsafe reuse classification." + }, + { + "file": "data-migration/src/scripts/importHistoricalMarathonMatches/planning.js", + "line": 95, + "severity": "blocking", + "description": "The planner never checks `round.round_type_id == '13'`. Any selected round with components plus registrations, submissions, or finals is treated as Marathon-Match work. In the real export, round `9892` is `round_type_id: \"15\"` (`/mnt/Informix/round_1.json:22899-22908`), yet the worker's handoff/manual verification used `--round-id 9892` and observed `decision=create`. That means the feature can plan Marathon Match creation for a non-marathon legacy round, violating the mission's authoritative MM identification rule." + }, + { + "file": "data-migration/src/scripts/importHistoricalMarathonMatches/planning.js", + "line": 141, + "severity": "blocking", + "description": "Create-path output is not a complete creation plan. The record only exposes numeric counts and `entityDeltas.phases.target = 3`; it never reports that the planned challenge is `Marathon Match` / `Data Science`, nor that the three phases are specifically `Registration`, `Submission`, and `Review`. That falls short of VAL-PLAN-009's required dry-run evidence for missing historical rounds and leaves validators without the parseable challenge/phase shape the feature claims to provide." + } + ] + }, + "sharedStateObservations": [ + { + "area": "services", + "observation": "The mission-owned test command in `.factory/services.yaml` was incorrect for Jest and caused the worker to deviate from the skill procedure.", + "evidence": "Commit `6a38297` added `.factory/services.yaml:3` with `pnpm test -- --maxWorkers=16`; the handoff's `skillFeedback.followedProcedure=false` explains that this command produced a pre-existing argument-handling failure, so the worker had to fall back to a scoped test invocation instead." + }, + { + "area": "knowledge", + "observation": "The feature introduced `--existing-state-file`, but the mission shared state never documented how to generate that snapshot, what schema is expected, or which live surface is authoritative for it.", + "evidence": "`AGENTS.md:20` requires sidecar snapshots to be documented in `.factory/library/`; commit `6a38297` only documents the flag in `argParser.js:166`, parses an ad hoc shape in `existingState.js:18-75`, and the handoff validated reuse by creating `/tmp/mm-existing-state.json` manually." + }, + { + "area": "knowledge", + "observation": "The initial shared-state docs pointed workers at the wrong create-path fixture round, which contributed to validating against a non-marathon round.", + "evidence": "Commit `6a38297` set `.factory/library/architecture.md:165` to `round 9892` as the primary missing-historical fixture, but `/mnt/Informix/round_1.json:22899-22908` shows `9892` has `round_type_id=\"15\"`; the worker handoff's manual dry-run checks also used `--round-id 9892`." + } + ], + "addressesFailureFrom": null, + "summary": "Reviewed the handoff, transcript skeleton, commit diff, mission docs, and shared-state files for `mm-importer-plan-cli`. Result: fail, with three blocking issues: reuse reporting depends on an undocumented snapshot instead of authoritative v6 discovery, non-marathon rounds are not filtered out before planning, and create-path records do not expose the complete Marathon Match/Data Science phase plan required by the validation contract." +} diff --git a/.factory/validation/planning-challenge/scrutiny/reviews/mm-importer-reuse-challenge-safety.json b/.factory/validation/planning-challenge/scrutiny/reviews/mm-importer-reuse-challenge-safety.json new file mode 100644 index 0000000..4cf9e91 --- /dev/null +++ b/.factory/validation/planning-challenge/scrutiny/reviews/mm-importer-reuse-challenge-safety.json @@ -0,0 +1,28 @@ +{ + "featureId": "mm-importer-reuse-challenge-safety", + "reviewedAt": "2026-04-01T02:59:36Z", + "commitId": "29d9b42", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "fail", + "codeReview": { + "summary": "The commit correctly hardens `applyCreateRound()` so reused legacyId matches reject duplicate challenges, reject non-MM/Data Science targets, reject duplicate standard phases, and only backfill missing standard phases. But the feature still falls short of its \"authoritative existing-v6 reuse matching\" requirement because dry-run planning continues to trust snapshot-supplied `challengeId` values instead of discovering and validating the same MM/Data Science reuse target that apply mode uses.", + "issues": [ + { + "file": "data-migration/src/scripts/importHistoricalMarathonMatches/planning.js", + "line": 210, + "severity": "blocking", + "description": "`evaluateRoundPlan()` treats any `existingStateEntry.challengeId` as a safe reuse target and emits `decision=\"reuse/backfill-only\"` on that basis alone. In the same commit, `importHistoricalMarathonMatches.js:34-35` sources that state only from `loadExistingState(...)`, and `existingState.js:27-35` preserves only `challengeId` plus aggregate counts, with no validation of the architecture's reuse preconditions (exact `challenge.legacyId == round.id`, Marathon Match/Data Science shape, and no duplicate standard phases). Apply mode then re-matches independently by `legacyId` in `apply.js:177-181`, so dry-run can advertise a reused round that apply will instead create anew or reject. That is a direct correctness/safety gap against the feature description's authoritative reuse-matching requirement." + } + ] + }, + "sharedStateObservations": [ + { + "area": "knowledge", + "observation": "The mission's shared-state story around `--existing-state-file` was too weak for this feature: the snapshot format carried `challengeId` and counts, but not the evidence needed to prove authoritative reuse safety. That made it easy for planning code to treat snapshot data as authoritative even though the architecture requires exact legacyId matching plus MM/Data Science and duplicate-phase checks.", + "evidence": "The architecture document defines reuse as exact legacyId matching with MM/Data Science and standard-phase safety checks, while historical `data-migration/src/scripts/importHistoricalMarathonMatches/existingState.js` at commit `29d9b42` stored only `challengeId` and aggregate counts from the snapshot." + } + ], + "addressesFailureFrom": null, + "summary": "I reviewed the mission docs, feature entry, handoff, transcript skeleton, and commit `29d9b42`. Result: fail — apply-path phase preservation was improved, but dry-run/planning still did not use authoritative reuse matching, so dry-run output could disagree with apply behavior for supposedly reusable rounds." +} diff --git a/.factory/validation/planning-challenge/user-testing/flows/challenge-reuse-apply.json b/.factory/validation/planning-challenge/user-testing/flows/challenge-reuse-apply.json new file mode 100644 index 0000000..07ebb48 --- /dev/null +++ b/.factory/validation/planning-challenge/user-testing/flows/challenge-reuse-apply.json @@ -0,0 +1,108 @@ +{ + "groupId": "challenge-reuse-apply", + "surface": "importer CLI + API verification", + "toolsUsed": [ + "node", + "python", + "curl" + ], + "assertions": [ + { + "id": "VAL-CHALLENGE-001", + "status": "blocked", + "summary": "Could not prove missing-round create-path behavior because round 13897 was already imported before this session: dry-run reported reuse/backfill-only, challenge lookup already returned one row, and apply reported existing.", + "evidence": [ + "planning-challenge/challenge-reuse-apply/01-dry-run-13897.stdout", + "planning-challenge/challenge-reuse-apply/02-pre-search-13897.json", + "planning-challenge/challenge-reuse-apply/06-apply-1-13897.stdout" + ] + }, + { + "id": "VAL-CHALLENGE-002", + "status": "blocked", + "summary": "The current challenge uses the Marathon Match/Data Science ids and canonical MM timeline template, but the create-path binding itself could not be proven because the shared fixture was already imported and no create run was possible.", + "evidence": [ + "planning-challenge/challenge-reuse-apply/01-dry-run-13897.stdout", + "planning-challenge/challenge-reuse-apply/03-pre-detail-13897.json", + "planning-challenge/challenge-reuse-apply/05-pre-timeline-template.json", + "planning-challenge/challenge-reuse-apply/14-challenge-types.json", + "planning-challenge/challenge-reuse-apply/15-challenge-tracks.json", + "planning-challenge/challenge-reuse-apply/16-challenge-phases.json" + ] + }, + { + "id": "VAL-CHALLENGE-003", + "status": "pass", + "summary": "The imported challenge detail exposes exactly one Registration, one Submission, and one Review phase, and the phase-name counts remained 1/1/1 across pre, post-apply, and rerun snapshots.", + "evidence": [ + "planning-challenge/challenge-reuse-apply/03-pre-detail-13897.json", + "planning-challenge/challenge-reuse-apply/09-post-apply-1-normalized-13897.json", + "planning-challenge/challenge-reuse-apply/17-snapshot-comparison.json" + ] + }, + { + "id": "VAL-CHALLENGE-004", + "status": "pass", + "summary": "All three visible phases were closed with non-null actualEndDate values and the challenge had no current open phase names before or after apply.", + "evidence": [ + "planning-challenge/challenge-reuse-apply/03-pre-detail-13897.json", + "planning-challenge/challenge-reuse-apply/09-post-apply-1-normalized-13897.json" + ] + }, + { + "id": "VAL-CHALLENGE-005", + "status": "pass", + "summary": "Registration and Submission phase windows contain the legacy eligible-registration and non-example submission timestamp ranges, and the Review phase starts at the Submission phase end with a coherent closed window.", + "evidence": [ + "planning-challenge/challenge-reuse-apply/03-pre-detail-13897.json", + "planning-challenge/challenge-reuse-apply/18-legacy-activity-comparison-13897.json" + ] + }, + { + "id": "VAL-CHALLENGE-006", + "status": "pass", + "summary": "Dry-run classified round 13897 as reuse/backfill-only, and the normalized pre/post snapshots were identical: the challenge id, name, typeId, trackId, status, timelineTemplateId, startDate, and endDate did not change across the first apply.", + "evidence": [ + "planning-challenge/challenge-reuse-apply/01-dry-run-13897.stdout", + "planning-challenge/challenge-reuse-apply/04-pre-normalized-13897.json", + "planning-challenge/challenge-reuse-apply/09-post-apply-1-normalized-13897.json", + "planning-challenge/challenge-reuse-apply/17-snapshot-comparison.json", + "planning-challenge/challenge-reuse-apply/19-curl-challenge-search-13897.json" + ] + }, + { + "id": "VAL-CHALLENGE-007", + "status": "pass", + "summary": "The same Registration, Submission, and Review phase rows remained attached to the challenge with unchanged ids, open/closed state, and visible timestamps; no duplicate standard phase names appeared after apply.", + "evidence": [ + "planning-challenge/challenge-reuse-apply/04-pre-normalized-13897.json", + "planning-challenge/challenge-reuse-apply/09-post-apply-1-normalized-13897.json", + "planning-challenge/challenge-reuse-apply/17-snapshot-comparison.json" + ] + }, + { + "id": "VAL-CHALLENGE-008", + "status": "pass", + "summary": "The second apply also reported existing, challenge lookup still returned exactly one row, and the post-apply and rerun snapshots had identical phase ids and phase-name counts.", + "evidence": [ + "planning-challenge/challenge-reuse-apply/10-apply-2-13897.stdout", + "planning-challenge/challenge-reuse-apply/09-post-apply-1-normalized-13897.json", + "planning-challenge/challenge-reuse-apply/13-post-apply-2-normalized-13897.json", + "planning-challenge/challenge-reuse-apply/17-snapshot-comparison.json" + ] + } + ], + "frictions": [ + "GET /v6/timeline-templates/ returned 401 without a bearer token even though the challenge, type, track, and phase metadata endpoints used here were readable without auth; resolved by sourcing .env.importer.local and using node get_token.js for a read-only token." + ], + "blockers": [ + { + "assertionId": "VAL-CHALLENGE-001", + "reason": "Round 13897 was already imported in the shared dev environment before this validation run, so the contract's missing-round create path could not be reproduced within the allowed isolation boundary." + }, + { + "assertionId": "VAL-CHALLENGE-002", + "reason": "Because round 13897 already existed and apply only returned existing, this run could observe current timeline binding but could not prove the create-path binding event required by the assertion." + } + ] +} diff --git a/.factory/validation/planning-challenge/user-testing/flows/create-and-rerun-10815.json b/.factory/validation/planning-challenge/user-testing/flows/create-and-rerun-10815.json new file mode 100644 index 0000000..208cdcf --- /dev/null +++ b/.factory/validation/planning-challenge/user-testing/flows/create-and-rerun-10815.json @@ -0,0 +1,143 @@ +{ + "groupId": "create-and-rerun-10815", + "surface": "importer CLI + API verification", + "testedAt": "2026-04-01T06:29:52.397070Z", + "isolation": { + "legacyRoundId": 10815, + "repoRoot": "/home/jmgasper/Documents/Git/v6/challenge-api-v6", + "missionDir": "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1", + "evidenceDir": "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/planning-challenge/create-and-rerun-10815", + "envFile": "/home/jmgasper/Documents/Git/v6/challenge-api-v6/.env.importer.local", + "cliNodeVersion": "18.19.0", + "allowedSurfaces": [ + "node", + "curl", + "python" + ] + }, + "toolsUsed": [ + "node", + "curl", + "python" + ], + "assertions": [ + { + "id": "VAL-PLAN-009", + "title": "Missing historical rounds show a complete creation plan", + "status": "pass", + "steps": [ + { + "action": "Run importer dry-run for legacy round 10815 before any apply", + "expected": "One create decision with completed Marathon Match/Data Science challenge shape and Registration/Submission/Review phase plan", + "observed": "decision=create, reason=no-matching-v6-challenge-found, createPathChallengeShape={'type': 'Marathon Match', 'track': 'Data Science', 'status': 'COMPLETED', 'phaseNames': ['Registration', 'Submission', 'Review'], 'timelineTemplateId': '6969125a-a12f-4b89-8de6-e66b0056f36b'}, createPathPhasePlan keys=['Registration', 'Submission', 'Review']" + } + ], + "evidence": { + "files": [ + "planning-challenge/create-and-rerun-10815/dry-run-10815-before-apply.stdout.txt", + "planning-challenge/create-and-rerun-10815/dry-run-10815-before-apply.stderr.txt", + "planning-challenge/create-and-rerun-10815/dry-run-10815-before-apply.exitcode.txt" + ], + "network": "GET /v6/challenges?legacyId=10815 before apply -> 200, x-total: 0" + }, + "issues": null + }, + { + "id": "VAL-CHALLENGE-001", + "title": "Missing historical round creates one completed marathon challenge", + "status": "pass", + "steps": [ + { + "action": "Confirm no challenge exists for legacyId 10815 before apply", + "expected": "Challenge API returns zero matches", + "observed": "0 matches before apply" + }, + { + "action": "Run importer apply for legacy round 10815", + "expected": "Apply creates one challenge", + "observed": "status=created, challengeId=5fa76bd9-da55-422d-8d4c-4f0155dc62c5" + }, + { + "action": "Read Challenge API after apply", + "expected": "Exactly one completed Marathon Match/Data Science challenge with legacyId 10815", + "observed": "1 match; legacyId=10815, type=Marathon Match, track=Data Science, status=COMPLETED" + } + ], + "evidence": { + "files": [ + "planning-challenge/create-and-rerun-10815/challenges-legacyId-10815-before.json", + "planning-challenge/create-and-rerun-10815/challenges-legacyId-10815-before.headers.txt", + "planning-challenge/create-and-rerun-10815/apply-10815.stdout.txt", + "planning-challenge/create-and-rerun-10815/apply-10815.stderr.txt", + "planning-challenge/create-and-rerun-10815/apply-10815.exitcode.txt", + "planning-challenge/create-and-rerun-10815/challenges-legacyId-10815-after.json", + "planning-challenge/create-and-rerun-10815/challenges-legacyId-10815-after.headers.txt", + "planning-challenge/create-and-rerun-10815/challenge-5fa76bd9-da55-422d-8d4c-4f0155dc62c5.json", + "planning-challenge/create-and-rerun-10815/challenge-5fa76bd9-da55-422d-8d4c-4f0155dc62c5.headers.txt" + ], + "network": "GET /v6/challenges?legacyId=10815 after apply -> 200, x-total: 1; GET /v6/challenges/5fa76bd9-da55-422d-8d4c-4f0155dc62c5 -> 200" + }, + "issues": null + }, + { + "id": "VAL-CHALLENGE-002", + "title": "Created historical MM binds to a canonical marathon-match timeline mapping", + "status": "pass", + "steps": [ + { + "action": "Inspect created challenge detail", + "expected": "timelineTemplateId=6969125a-a12f-4b89-8de6-e66b0056f36b", + "observed": "timelineTemplateId=6969125a-a12f-4b89-8de6-e66b0056f36b" + }, + { + "action": "Lookup timeline template 6969125a-a12f-4b89-8de6-e66b0056f36b", + "expected": "Active Marathon Match template with non-empty MM phase shape", + "observed": "name=Marathon Match, isActive=True, templatePhaseCount=3; created challenge phases=['Registration', 'Submission', 'Review']" + } + ], + "evidence": { + "files": [ + "planning-challenge/create-and-rerun-10815/challenge-5fa76bd9-da55-422d-8d4c-4f0155dc62c5.json", + "planning-challenge/create-and-rerun-10815/challenge-5fa76bd9-da55-422d-8d4c-4f0155dc62c5.headers.txt", + "planning-challenge/create-and-rerun-10815/timeline-template-6969125a-a12f-4b89-8de6-e66b0056f36b.json", + "planning-challenge/create-and-rerun-10815/timeline-template-6969125a-a12f-4b89-8de6-e66b0056f36b.headers.txt" + ], + "network": "GET /v6/timeline-templates/6969125a-a12f-4b89-8de6-e66b0056f36b -> 200" + }, + "issues": null + }, + { + "id": "VAL-PLAN-014", + "title": "Rerun planning classifies already-imported work as no-op", + "status": "blocked", + "steps": [ + { + "action": "Rerun importer dry-run for legacy round 10815 after the apply run", + "expected": "Already-imported work is classified as no-op with no duplicate creates across the full contract surface", + "observed": "decision=reuse/backfill-only, matchedChallengeId=5fa76bd9-da55-422d-8d4c-4f0155dc62c5, rerunClassification=partial-backfill, phases.toCreate=0, resources.toCreate=836, submissions.toCreate=1445, finalScores.toCreate=267, provisionalScores.toCreate=1445" + } + ], + "evidence": { + "files": [ + "planning-challenge/create-and-rerun-10815/dry-run-10815-after-apply.stdout.txt", + "planning-challenge/create-and-rerun-10815/dry-run-10815-after-apply.stderr.txt", + "planning-challenge/create-and-rerun-10815/dry-run-10815-after-apply.exitcode.txt", + "planning-challenge/create-and-rerun-10815/challenge-5fa76bd9-da55-422d-8d4c-4f0155dc62c5.json" + ], + "network": "Rerun dry-run only; no additional write call was made." + }, + "issues": "Post-apply rerun proved challenge/phase reuse only: the dry-run switched to reuse/backfill-only for 5fa76bd9-da55-422d-8d4c-4f0155dc62c5 and reported phases existing=3/toCreate=0, but rerunClassification remained partial-backfill because resources/submissions/finalScores/provisionalScores still showed pending creates (836/1445/267/1445). This milestone does not backfill those later-surface entities, so a true no-op rerun for the full contract cannot be proven from this run." + } + ], + "frictions": [], + "blockers": [ + { + "description": "Post-apply rerun proved challenge/phase reuse only: the dry-run switched to reuse/backfill-only for 5fa76bd9-da55-422d-8d4c-4f0155dc62c5 and reported phases existing=3/toCreate=0, but rerunClassification remained partial-backfill because resources/submissions/finalScores/provisionalScores still showed pending creates (836/1445/267/1445). This milestone does not backfill those later-surface entities, so a true no-op rerun for the full contract cannot be proven from this run.", + "affectedAssertions": [ + "VAL-PLAN-014" + ], + "quickFixAttempted": "Completed the create-path apply and reran dry-run immediately to see whether the importer would classify the round as a full no-op." + } + ], + "summary": "Tested 4 assertions: 3 passed, 0 failed, 1 blocked. VAL-PLAN-014 is blocked because rerun proved challenge/phase reuse but still planned later-milestone resources, submissions, and scores." +} diff --git a/.factory/validation/planning-challenge/user-testing/flows/create-path-plan-check.json b/.factory/validation/planning-challenge/user-testing/flows/create-path-plan-check.json new file mode 100644 index 0000000..79f6e0b --- /dev/null +++ b/.factory/validation/planning-challenge/user-testing/flows/create-path-plan-check.json @@ -0,0 +1,33 @@ +{ + "groupId": "create-path-plan-check", + "surface": "importer CLI + API verification", + "toolsUsed": [ + "node", + "python", + "curl" + ], + "assertions": [ + { + "id": "VAL-PLAN-009", + "status": "blocked", + "summary": "Dry-run for round 13897 exited 0 but emitted decision reuse/backfill-only against existing challenge a15cbb04-a0d3-4647-85bd-23d8d11e9f3f with createPathChallengeShape/createPathPhasePlan both null. Challenge API confirms legacyId 13897 already exists as a completed Marathon Match on the Data Science track with Registration, Submission, and Review phases, so the current shared fixture cannot prove the missing-historical create-path plan required by this assertion.", + "evidence": [ + "planning-challenge/create-path-plan-check/dry-run-13897.stdout.txt", + "planning-challenge/create-path-plan-check/dry-run-13897.stderr.txt", + "planning-challenge/create-path-plan-check/dry-run-13897.exitcode.txt", + "planning-challenge/create-path-plan-check/challenges-legacyId-13897.json", + "planning-challenge/create-path-plan-check/challenges-legacyId-13897.http.txt", + "planning-challenge/create-path-plan-check/challenge-a15cbb04-a0d3-4647-85bd-23d8d11e9f3f.json", + "planning-challenge/create-path-plan-check/challenge-a15cbb04-a0d3-4647-85bd-23d8d11e9f3f.http.txt", + "planning-challenge/create-path-plan-check/create-path-assessment.json" + ] + } + ], + "frictions": [], + "blockers": [ + { + "assertionId": "VAL-PLAN-009", + "reason": "The shared dev fixture for legacy round 13897 is already imported, so dry-run legitimately plans reuse/backfill-only instead of a create path; within read-only scope this prevents proving missing-historical create-path behavior." + } + ] +} diff --git a/.factory/validation/planning-challenge/user-testing/flows/dry-run-deltas.json b/.factory/validation/planning-challenge/user-testing/flows/dry-run-deltas.json new file mode 100644 index 0000000..deb0bb5 --- /dev/null +++ b/.factory/validation/planning-challenge/user-testing/flows/dry-run-deltas.json @@ -0,0 +1,81 @@ +{ + "groupId": "dry-run-deltas", + "surface": "importer CLI + API verification", + "toolsUsed": [ + "node", + "python", + "curl" + ], + "assertions": [ + { + "id": "VAL-PLAN-006", + "status": "pass", + "summary": "Dry-run for round 13897 exited 0 and left both the selected round (challenge a15cbb04-a0d3-4647-85bd-23d8d11e9f3f) and unselected control round 10089 unchanged across challenge, resources, submissions, and reviewSummations snapshots.", + "evidence": [ + "planning-challenge/dry-run-deltas/dry-run-13897.stdout.txt", + "planning-challenge/dry-run-deltas/dry-run-13897.exitcode.txt", + "planning-challenge/dry-run-deltas/pre-round-13897-snapshot.json", + "planning-challenge/dry-run-deltas/post-round-13897-snapshot.json", + "planning-challenge/dry-run-deltas/pre-round-10089-snapshot.json", + "planning-challenge/dry-run-deltas/post-round-10089-snapshot.json", + "planning-challenge/dry-run-deltas/dry-run-non-mutation-diff.json", + "planning-challenge/dry-run-deltas/curl-selected-challenge-lookup.json", + "planning-challenge/dry-run-deltas/curl-control-challenge-lookup.json" + ] + }, + { + "id": "VAL-PLAN-008", + "status": "pass", + "summary": "PLAN_RECORD classified round 13897 as reuse/backfill-only, named matched challenge a15cbb04-a0d3-4647-85bd-23d8d11e9f3f, and exposed separate deltas for phases, resources, submissions, finalScores, and provisionalScores.", + "evidence": [ + "planning-challenge/dry-run-deltas/dry-run-13897.stdout.txt", + "planning-challenge/dry-run-deltas/dry-run-13897.parsed.json" + ] + }, + { + "id": "VAL-PLAN-010", + "status": "pass", + "summary": "Resource planning was separately quantified from submissions: entityDeltas.resources.target=810 while entityDeltas.submissions.target=1980. Legacy data showed 810 eligible registrants, 228 eligible non-example submitters, and 582 eligible registered non-submitters, so the resource target is registration-driven rather than submission-driven.", + "evidence": [ + "planning-challenge/dry-run-deltas/dry-run-13897.parsed.json", + "planning-challenge/dry-run-deltas/legacy-round-13897-summary.json" + ] + }, + { + "id": "VAL-PLAN-011", + "status": "pass", + "summary": "Submission planning excluded example runs: PLAN_RECORD reported 1980 nonExampleSubmissions and 3955 exampleSubmissionsFiltered, matching legacy long_component_state submission_number and example_submission_number totals for round 13897.", + "evidence": [ + "planning-challenge/dry-run-deltas/dry-run-13897.parsed.json", + "planning-challenge/dry-run-deltas/legacy-round-13897-summary.json" + ] + }, + { + "id": "VAL-PLAN-012", + "status": "pass", + "summary": "The plan distinguished final and provisional score streams with stable fields: plannedFinalScores=229, plannedProvisionalScores=1980, and skippedUnattachableFinalists/finalistsWithoutAttachableSubmission=34.", + "evidence": [ + "planning-challenge/dry-run-deltas/dry-run-13897.parsed.json", + "planning-challenge/dry-run-deltas/legacy-round-13897-summary.json" + ] + }, + { + "id": "VAL-PLAN-014", + "status": "blocked", + "summary": "The shared fixture is only partially imported, not a fully completed rerun target: the challenge and phases already exist, but pre/post API snapshots still show 0 resources, 0 submissions, and 0 reviewSummations. The observed dry-run therefore reports rerunClassification=partial-backfill and plans creates, so a true post-successful-import no-op rerun cannot be proven in this read-only assignment.", + "evidence": [ + "planning-challenge/dry-run-deltas/dry-run-13897.parsed.json", + "planning-challenge/dry-run-deltas/pre-round-13897-snapshot.json", + "planning-challenge/dry-run-deltas/post-round-13897-snapshot.json", + "planning-challenge/dry-run-deltas/dry-run-non-mutation-diff.json" + ] + } + ], + "frictions": [], + "blockers": [ + { + "assertionId": "VAL-PLAN-014", + "reason": "Read-only scope plus a partially imported shared fixture prevented verification of the required post-successful-import no-op rerun behavior." + } + ] +} \ No newline at end of file diff --git a/.factory/validation/planning-challenge/user-testing/flows/plan-cli-core.json b/.factory/validation/planning-challenge/user-testing/flows/plan-cli-core.json new file mode 100644 index 0000000..5f25b3c --- /dev/null +++ b/.factory/validation/planning-challenge/user-testing/flows/plan-cli-core.json @@ -0,0 +1,98 @@ +{ + "groupId": "plan-cli-core", + "surface": "importer CLI + API verification", + "toolsUsed": [ + "node", + "python" + ], + "assertions": [ + { + "id": "VAL-PLAN-001", + "status": "pass", + "summary": "Help completed with exit code 0 and documented dry-run plus round filters without requiring a valid data directory or database URL.", + "evidence": [ + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-001-help.meta.json", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-001-help.stdout.txt", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-001-help.stderr.txt" + ] + }, + { + "id": "VAL-PLAN-002", + "status": "pass", + "summary": "Unknown flags, missing values, and malformed --round-ids all failed fast with named errors and emitted no misleading plan output.", + "evidence": [ + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-002-invalid-unknown-flag.meta.json", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-002-invalid-unknown-flag.stdout.txt", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-002-invalid-unknown-flag.stderr.txt", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-002-invalid-missing-value.meta.json", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-002-invalid-missing-value.stdout.txt", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-002-invalid-missing-value.stderr.txt", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-002-invalid-malformed-filter.meta.json", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-002-invalid-malformed-filter.stdout.txt", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-002-invalid-malformed-filter.stderr.txt" + ] + }, + { + "id": "VAL-PLAN-003", + "status": "pass", + "summary": "A true planning prerequisite failure (missing legacy data) exited non-zero explicitly, while a dry-run with DATABASE_URL unset still produced a read-only plan for round 14272.", + "evidence": [ + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-003-planning-prereq-missing-data.meta.json", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-003-planning-prereq-missing-data.stdout.txt", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-003-planning-prereq-missing-data.stderr.txt", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-003-dry-run-without-db.meta.json", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-003-dry-run-without-db.stdout.txt", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-003-dry-run-without-db.stderr.txt" + ] + }, + { + "id": "VAL-PLAN-004", + "status": "pass", + "summary": "Repeatable --round-id, comma-separated --round-ids, duplicate IDs, whitespace trimming, and unmatched round handling all resolved to the exact sorted set [13897, 14272, 99999999] without widening scope.", + "evidence": [ + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-004-007-013-filtering-and-parseability.meta.json", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-004-007-013-filtering-and-parseability.stdout.txt", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-004-007-013-filtering-and-parseability.stderr.txt", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-004-007-013-analysis.json" + ] + }, + { + "id": "VAL-PLAN-005", + "status": "pass", + "summary": "Two dry-run executions of the same unresolved round with stdin closed produced identical stdout/stderr and completed without any interactive prompt.", + "evidence": [ + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-005-determinism-run1.meta.json", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-005-determinism-run1.stdout.txt", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-005-determinism-run1.stderr.txt", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-005-determinism-run2.meta.json", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-005-determinism-run2.stdout.txt", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-005-determinism-run2.stderr.txt", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-005-determinism-analysis.json" + ] + }, + { + "id": "VAL-PLAN-007", + "status": "pass", + "summary": "The dry-run emitted exactly one parseable PLAN_RECORD per selected round with stable labeled fields for round id, matched challenge id, decision, reason, and summary counts that reconcile to PLAN_SUMMARY.", + "evidence": [ + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-004-007-013-filtering-and-parseability.meta.json", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-004-007-013-filtering-and-parseability.stdout.txt", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-004-007-013-analysis.json" + ] + }, + { + "id": "VAL-PLAN-013", + "status": "pass", + "summary": "Each per-round record exposed labeled traceability fields (legacyRoundId, legacyComponentIds, legacyProblemIds) that map decisions back to legacy source identifiers before apply.", + "evidence": [ + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-004-007-013-filtering-and-parseability.meta.json", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-004-007-013-filtering-and-parseability.stdout.txt", + "evidence/planning-challenge/plan-cli-core/VAL-PLAN-004-007-013-analysis.json" + ] + } + ], + "frictions": [ + "Dry-run streaming over /mnt/Informix exceeded an initial 120s probe timeout, so evidence capture used 360-480s command timeouts to complete read-only planning runs reliably." + ], + "blockers": [] +} \ No newline at end of file diff --git a/.factory/validation/planning-challenge/user-testing/synthesis.json b/.factory/validation/planning-challenge/user-testing/synthesis.json new file mode 100644 index 0000000..0fa23bf --- /dev/null +++ b/.factory/validation/planning-challenge/user-testing/synthesis.json @@ -0,0 +1,16 @@ +{ + "milestone": "planning-challenge", + "round": 3, + "status": "pass", + "assertionsSummary": { + "total": 0, + "passed": 0, + "failed": 0, + "blocked": 0 + }, + "passedAssertions": [], + "failedAssertions": [], + "blockedAssertions": [], + "appliedUpdates": [], + "previousRound": ".factory/validation/planning-challenge/user-testing/synthesis.round2.json" +} diff --git a/.factory/validation/planning-challenge/user-testing/synthesis.round1.json b/.factory/validation/planning-challenge/user-testing/synthesis.round1.json new file mode 100644 index 0000000..5d93408 --- /dev/null +++ b/.factory/validation/planning-challenge/user-testing/synthesis.round1.json @@ -0,0 +1,58 @@ +{ + "milestone": "planning-challenge", + "round": 1, + "status": "fail", + "assertionsSummary": { + "total": 22, + "passed": 18, + "failed": 0, + "blocked": 4 + }, + "passedAssertions": [ + "VAL-CHALLENGE-003", + "VAL-CHALLENGE-004", + "VAL-CHALLENGE-005", + "VAL-CHALLENGE-006", + "VAL-CHALLENGE-007", + "VAL-CHALLENGE-008", + "VAL-PLAN-001", + "VAL-PLAN-002", + "VAL-PLAN-003", + "VAL-PLAN-004", + "VAL-PLAN-005", + "VAL-PLAN-006", + "VAL-PLAN-007", + "VAL-PLAN-008", + "VAL-PLAN-010", + "VAL-PLAN-011", + "VAL-PLAN-012", + "VAL-PLAN-013" + ], + "failedAssertions": [], + "blockedAssertions": [ + { + "id": "VAL-CHALLENGE-001", + "blockedBy": "Round 13897 was already imported in the shared dev environment before this validation run, so the contract's missing-round create path could not be reproduced within the allowed isolation boundary." + }, + { + "id": "VAL-CHALLENGE-002", + "blockedBy": "Because round 13897 already existed and apply only returned existing, this run could observe current timeline binding but could not prove the create-path binding event required by the assertion." + }, + { + "id": "VAL-PLAN-009", + "blockedBy": "The shared dev fixture for legacy round 13897 is already imported, so dry-run legitimately plans reuse/backfill-only instead of a create path; within read-only scope this prevents proving missing-historical create-path behavior." + }, + { + "id": "VAL-PLAN-014", + "blockedBy": "Read-only scope plus a partially imported shared fixture prevented verification of the required post-successful-import no-op rerun behavior." + } + ], + "appliedUpdates": [ + { + "target": "user-testing.md", + "description": "Added importer CLI flow guidance covering the shared 13897 partial-backfill fixture, auth expectations for reviewSummations, non-marathon read-only fixtures, safe apply concurrency, and longer dry-run timeout expectations.", + "source": "setup" + } + ], + "previousRound": null +} diff --git a/.factory/validation/planning-challenge/user-testing/synthesis.round2.json b/.factory/validation/planning-challenge/user-testing/synthesis.round2.json new file mode 100644 index 0000000..d652b3b --- /dev/null +++ b/.factory/validation/planning-challenge/user-testing/synthesis.round2.json @@ -0,0 +1,31 @@ +{ + "milestone": "planning-challenge", + "round": 2, + "status": "fail", + "assertionsSummary": { + "total": 4, + "passed": 3, + "failed": 0, + "blocked": 1 + }, + "passedAssertions": [ + "VAL-PLAN-009", + "VAL-CHALLENGE-001", + "VAL-CHALLENGE-002" + ], + "failedAssertions": [], + "blockedAssertions": [ + { + "id": "VAL-PLAN-014", + "blockedBy": "Round 10815 now reruns as reuse/backfill-only for the created challenge with phases fully converged, but resources/submissions/finalScores/provisionalScores still plan creates (836/1445/267/1445), so the full-contract no-op rerun remains a later-milestone blocker rather than a planning-challenge regression." + } + ], + "appliedUpdates": [ + { + "target": "user-testing.md", + "description": "Recorded that round 10815 is now a shared post-create fixture (challenge 5fa76bd9-da55-422d-8d4c-4f0155dc62c5) and that its immediate rerun proves challenge/phase reuse but remains partial-backfill for later participant/score surfaces.", + "source": "flow-report" + } + ], + "previousRound": ".factory/validation/planning-challenge/user-testing/synthesis.round1.json" +} diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches.js b/data-migration/src/scripts/importHistoricalMarathonMatches.js index 1464ad8..23c5a06 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches.js @@ -22,6 +22,11 @@ const { createAuth0TokenProvider, createResourceApiClient, } = require("./importHistoricalMarathonMatches/resourceApi"); +const { + TARGET_MEMBER_RESOLUTION_UNAVAILABLE_REASON, + DEFAULT_MEMBER_SCHEMA, + createMemberPresenceResolver, +} = require("./importHistoricalMarathonMatches/targetMemberResolution"); const appRoot = path.resolve(__dirname, "..", "..", ".."); const requireFromRoot = createRequire(path.join(appRoot, "package.json")); @@ -72,12 +77,30 @@ const run = async () => { const snapshotByRoundId = loadExistingState(options.dataDir, options.existingStateFile); const shouldAttemptDatabaseDiscovery = options.apply || Boolean(String(process.env.DATABASE_URL || "").trim()); + const memberDbUrl = String(process.env.MEMBER_DB_URL || process.env.DATABASE_URL || "").trim(); + const memberDbSchema = String(process.env.MEMBER_DB_SCHEMA || DEFAULT_MEMBER_SCHEMA).trim(); let prisma = null; + let memberLookupPrisma = null; + let resolveMemberPresence = null; if (shouldAttemptDatabaseDiscovery) { const { PrismaClient } = requireFromRoot("@prisma/client"); prisma = new PrismaClient(); } + if (memberDbUrl) { + const { PrismaClient } = requireFromRoot("@prisma/client"); + const databaseUrl = String(process.env.DATABASE_URL || "").trim(); + memberLookupPrisma = + prisma && databaseUrl && memberDbUrl === databaseUrl + ? prisma + : new PrismaClient({ + datasources: { + db: { + url: memberDbUrl, + }, + }, + }); + } try { let existingStateByRoundId = null; @@ -91,6 +114,10 @@ const run = async () => { reason: CANONICAL_TIMELINE_UNRESOLVED_REASON, timelineTemplateId: null, }, + memberResolution: { + available: false, + reason: TARGET_MEMBER_RESOLUTION_UNAVAILABLE_REASON, + }, }; if (prisma) { @@ -134,6 +161,29 @@ const run = async () => { } } + if (memberLookupPrisma) { + try { + if (memberLookupPrisma !== prisma) { + await memberLookupPrisma.$connect(); + } + resolveMemberPresence = createMemberPresenceResolver({ + prisma: memberLookupPrisma, + memberSchema: memberDbSchema, + }); + planningPrerequisites.memberResolution = { + available: true, + }; + } catch (error) { + planningPrerequisites.memberResolution = { + available: false, + reason: TARGET_MEMBER_RESOLUTION_UNAVAILABLE_REASON, + }; + process.stderr.write( + `Warning: unable to resolve target-environment members (${error.message}); planning will be unresolved for missing-member classification.\n` + ); + } + } + if (!existingStateByRoundId) { existingStateByRoundId = await buildExistingStateByRoundId({ prisma: null, @@ -142,7 +192,15 @@ const run = async () => { }); } - const plan = await buildDryRunPlan(options, existingStateByRoundId, planningPrerequisites); + const plan = await buildDryRunPlan( + { + ...options, + cwd: process.cwd(), + resolveMemberPresence, + }, + existingStateByRoundId, + planningPrerequisites + ); if (!options.apply) { emitPlanReport(plan); return; @@ -163,6 +221,7 @@ const run = async () => { prisma, options: { ...options, + cwd: process.cwd(), submitterRoleId, resourceClient: createDefaultResourceClient(), }, @@ -171,6 +230,9 @@ const run = async () => { }); emitApplyReport(applyResult); } finally { + if (memberLookupPrisma && memberLookupPrisma !== prisma) { + await memberLookupPrisma.$disconnect(); + } if (prisma) { await prisma.$disconnect(); } diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js index 1938cc9..7b249cf 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js @@ -5,6 +5,12 @@ const { loadNormalizedIdentityByCoderId, buildEligibleMemberIdentities, } = require("./participants"); +const { + resolveSkippedFilePath, + normalizeSkipRecords, + collectReasonCodes, + writeSkippedArtifact, +} = require("./skippedArtifact"); const STANDARD_PHASE_NAMES = ["Registration", "Submission", "Review"]; const DEFAULT_SUBMITTER_ROLE_ID = "732339e7-8e30-49d7-9198-cccf9451e221"; @@ -448,6 +454,20 @@ const isCompletedChallengeResourceConstraintError = (error) => { return hasCompletedSignal && hasChallengeSignal && hasConstraintStatus; }; +const collectPlannedSkipRecords = (roundIds, planRecordByRoundId) => { + const records = []; + roundIds.forEach((roundId) => { + const planRecord = planRecordByRoundId.get(roundId); + if (!planRecord || !Array.isArray(planRecord.plannedSkipRecords)) { + return; + } + planRecord.plannedSkipRecords.forEach((record) => { + records.push(record); + }); + }); + return normalizeSkipRecords(records); +}; + const runApplyMode = async ({ prisma, options, @@ -456,6 +476,18 @@ const runApplyMode = async ({ normalizedIdentityByCoderId: providedNormalizedIdentityByCoderId, }) => { const planRecordByRoundId = new Map((plan.records || []).map((record) => [record.legacyRoundId, record])); + const skippedFilePath = resolveSkippedFilePath({ + skippedFilePath: options.skippedFilePath, + roundIds: options.roundIds, + cwd: options.cwd || process.cwd(), + }); + const plannedSkipRecords = collectPlannedSkipRecords(options.roundIds, planRecordByRoundId); + const skippedArtifact = writeSkippedArtifact({ + filePath: skippedFilePath, + selectedRoundIds: options.roundIds, + records: plannedSkipRecords, + }); + const actionableRoundIds = options.roundIds.filter((roundId) => { const counters = plan.roundDataById.get(roundId); if (!counters || !counters.round) { @@ -604,6 +636,11 @@ const runApplyMode = async ({ }, { recordType: "apply-summary", created: 0, existing: 0, unmatched: 0, unresolved: 0, errors: 0 } ); + summary.skippedFileArtifact = { + path: skippedFilePath, + reasonCodes: collectReasonCodes(plannedSkipRecords), + recordCount: skippedArtifact.records.length, + }; return { records: applyRecords, summary }; }; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js b/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js index a3f9745..e1c53a9 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js @@ -12,6 +12,7 @@ const DEFAULT_OPTIONS = { longSubmissionPattern: "^long_submission_\\d+\\.json$", longCompResultPattern: "^long_comp_result_\\d+\\.json$", existingStateFile: null, + skippedFilePath: null, dryRun: true, apply: false, roundIds: [], @@ -127,6 +128,11 @@ const parseArgs = (argv) => { index += 1; continue; } + if (arg === "--skipped-file") { + options.skippedFilePath = requireNextValue(argv, index, "--skipped-file"); + index += 1; + continue; + } if (arg === "--round-id") { const value = requireNextValue(argv, index, "--round-id"); options.roundIds.push(parseRoundIdValue(value, "--round-id")); @@ -170,6 +176,7 @@ Planning options: --round-ids Select comma-separated round ids --dry-run Build a non-mutating deterministic reconciliation plan (default) --existing-state-file Optional snapshot for offline entity-count hints (not authoritative reuse matching) + --skipped-file Optional deterministic skipped-file artifact path (default: ./historical-mm-skipped-.json) Input options: --data-dir Legacy data directory (default: DATA_DIRECTORY or /mnt/Informix) diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js b/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js index e920626..947f59d 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js @@ -10,6 +10,19 @@ const { STANDARD_PHASE_NAMES, derivePhaseWindows, } = require("./apply"); +const { + loadNormalizedIdentityByCoderId, + buildEligibleMemberIdentities, +} = require("./participants"); +const { + MISSING_MEMBER_REASON_CODE, + FINALIST_WITHOUT_ATTACHABLE_SUBMISSION_REASON_CODE, + resolveSkippedFilePath, + collectReasonCodes, +} = require("./skippedArtifact"); +const { + TARGET_MEMBER_RESOLUTION_UNAVAILABLE_REASON, +} = require("./targetMemberResolution"); const createEmptyCounters = () => ({ round: null, @@ -19,6 +32,7 @@ const createEmptyCounters = () => ({ nonExampleSubmissions: 0, exampleSubmissions: 0, nonExampleSubmitterCoderIds: new Set(), + nonExampleSubmissionCountsByCoderId: new Map(), finalCandidateCoderIds: new Set(), registrationStartMs: null, registrationEndMs: null, @@ -45,6 +59,22 @@ const parseNonNegativeInteger = (value) => { return parsed; }; +const parsePositiveInteger = (value) => { + const parsed = Number.parseInt(String(value || "").trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +}; + +const normalizeMemberId = (value) => { + const parsed = parsePositiveInteger(value); + if (!parsed) { + return null; + } + return String(parsed); +}; + const parseLegacySqlTimestamp = (value) => { const normalized = String(value || "").trim(); if (!normalized || normalized.toLowerCase() === "null") { @@ -135,7 +165,95 @@ const buildZeroEntityDeltas = () => ({ provisionalScores: buildEntityDelta(0, 0), }); -const buildUnresolvedRecord = ({ roundId, reason, counters, traceability, matchedChallengeId = null }) => ({ +const buildZeroPartitions = () => ({ + resources: { + toCreate: 0, + alreadyPresent: 0, + missingMember: 0, + explicitSkips: { + total: 0, + byReason: {}, + }, + }, + submissions: { + legacyNonExample: 0, + legacyExampleFiltered: 0, + toImport: 0, + alreadyPresent: 0, + missingMember: 0, + explicitSkips: { + total: 0, + byReason: {}, + }, + }, + finalScores: { + legacyFinalCandidates: 0, + toImport: 0, + alreadyPresent: 0, + missingMember: 0, + explicitSkips: { + total: 0, + byReason: {}, + }, + }, + provisionalScores: { + legacyNonExample: 0, + toImport: 0, + alreadyPresent: 0, + missingMember: 0, + explicitSkips: { + total: 0, + byReason: {}, + }, + }, +}); + +const addCount = (map, key, value = 1) => { + const normalizedKey = String(key || "").trim(); + if (!normalizedKey) { + return; + } + const increment = parseNonNegativeInteger(value); + map.set(normalizedKey, (map.get(normalizedKey) || 0) + increment); +}; + +const toSortedArray = (valueSet) => + Array.from(valueSet || []).sort((left, right) => + String(left).localeCompare(String(right), undefined, { numeric: true }) + ); + +const resolveIdentityForCoderId = (coderId, normalizedIdentityByCoderId = new Map()) => { + const normalizedCoderId = String(coderId || "").trim(); + if (!normalizedCoderId) { + return null; + } + const knownIdentity = normalizedIdentityByCoderId.get(normalizedCoderId); + if (knownIdentity && normalizeMemberId(knownIdentity.memberId)) { + return { + coderId: normalizedCoderId, + memberId: normalizeMemberId(knownIdentity.memberId), + memberHandle: knownIdentity.memberHandle || null, + }; + } + const fallbackMemberId = normalizeMemberId(normalizedCoderId); + if (!fallbackMemberId) { + return null; + } + return { + coderId: normalizedCoderId, + memberId: fallbackMemberId, + memberHandle: null, + }; +}; + +const buildUnresolvedRecord = ({ + roundId, + reason, + counters, + traceability, + matchedChallengeId = null, + skippedFilePath = null, +}) => ({ recordType: "round-plan", legacyRoundId: roundId, decision: "unresolved", @@ -150,6 +268,15 @@ const buildUnresolvedRecord = ({ roundId, reason, counters, traceability, matche finalistsWithoutAttachableSubmission: 0, }), entityDeltas: buildZeroEntityDeltas(), + partitions: buildZeroPartitions(), + plannedSkipRecords: [], + skippedFileArtifact: skippedFilePath + ? { + path: skippedFilePath, + reasonCodes: [], + recordCount: 0, + } + : null, createPathChallengeShape: null, createPathPhasePlan: null, }); @@ -180,6 +307,25 @@ const normalizePlanningPrerequisites = (prerequisites = {}) => ({ prerequisites.canonicalTimelineTemplate.reason) || "canonical-mm-ds-timeline-template-unresolved", }, + memberResolution: { + available: + prerequisites.memberResolution && + prerequisites.memberResolution.available === false + ? false + : true, + reason: + (prerequisites.memberResolution && prerequisites.memberResolution.reason) || + TARGET_MEMBER_RESOLUTION_UNAVAILABLE_REASON, + resolvedMemberIds: + prerequisites.memberResolution && + prerequisites.memberResolution.resolvedMemberIds instanceof Set + ? new Set( + Array.from(prerequisites.memberResolution.resolvedMemberIds) + .map((memberId) => normalizeMemberId(memberId)) + .filter(Boolean) + ) + : null, + }, }); const formatIsoDate = (value) => { @@ -211,9 +357,327 @@ const buildCreatePathPhasePlan = (roundId, counters) => { }, {}); }; +const normalizeResolvedMemberIds = (resolvedMemberIds) => { + if (!resolvedMemberIds) { + return new Set(); + } + const values = + resolvedMemberIds instanceof Set + ? Array.from(resolvedMemberIds) + : Array.isArray(resolvedMemberIds) + ? resolvedMemberIds + : []; + return new Set(values.map((memberId) => normalizeMemberId(memberId)).filter(Boolean)); +}; + +const resolveMemberPlanningPrerequisite = async ({ + options, + normalizedPrerequisites, + roundDataById, + normalizedIdentityByCoderId, +}) => { + const result = { + available: normalizedPrerequisites.memberResolution.available, + reason: normalizedPrerequisites.memberResolution.reason, + resolvedMemberIds: normalizeResolvedMemberIds( + normalizedPrerequisites.memberResolution.resolvedMemberIds + ), + }; + + if (!result.available) { + return result; + } + if (result.resolvedMemberIds.size > 0) { + return result; + } + if (typeof options.resolveMemberPresence !== "function") { + return { + available: false, + reason: normalizedPrerequisites.memberResolution.reason, + resolvedMemberIds: new Set(), + }; + } + + const memberIds = new Set(); + for (const counters of roundDataById.values()) { + counters.eligibleRegistrants.forEach((coderId) => { + const identity = resolveIdentityForCoderId(coderId, normalizedIdentityByCoderId); + const normalized = normalizeMemberId(identity && identity.memberId); + if (normalized) { + memberIds.add(normalized); + } + }); + counters.nonExampleSubmitterCoderIds.forEach((coderId) => { + const identity = resolveIdentityForCoderId(coderId, normalizedIdentityByCoderId); + const normalized = normalizeMemberId(identity && identity.memberId); + if (normalized) { + memberIds.add(normalized); + } + }); + counters.finalCandidateCoderIds.forEach((coderId) => { + const identity = resolveIdentityForCoderId(coderId, normalizedIdentityByCoderId); + const normalized = normalizeMemberId(identity && identity.memberId); + if (normalized) { + memberIds.add(normalized); + } + }); + } + + try { + const resolved = await options.resolveMemberPresence({ + memberIds: Array.from(memberIds), + }); + const resolvedSet = normalizeResolvedMemberIds(resolved); + return { + available: true, + reason: normalizedPrerequisites.memberResolution.reason, + resolvedMemberIds: resolvedSet, + }; + } catch (error) { + return { + available: false, + reason: normalizedPrerequisites.memberResolution.reason, + resolvedMemberIds: new Set(), + error, + }; + } +}; + +const buildSurfacePartitionsForRound = ({ + roundId, + counters, + existingStateEntry, + normalizedIdentityByCoderId, + resolvedMemberIds, +}) => { + const existingCounts = existingStateEntry && existingStateEntry.existing ? existingStateEntry.existing : {}; + const partitions = buildZeroPartitions(); + partitions.submissions.legacyNonExample = counters.nonExampleSubmissions; + partitions.submissions.legacyExampleFiltered = counters.exampleSubmissions; + partitions.provisionalScores.legacyNonExample = counters.nonExampleSubmissions; + partitions.finalScores.legacyFinalCandidates = counters.finalCandidateCoderIds.size; + + const memberStatsByMemberId = new Map(); + const ensureMemberStats = ({ memberId, memberHandle = null, coderId = null }) => { + if (!memberId) { + return null; + } + const normalizedMemberId = normalizeMemberId(memberId); + if (!normalizedMemberId) { + return null; + } + if (!memberStatsByMemberId.has(normalizedMemberId)) { + memberStatsByMemberId.set(normalizedMemberId, { + memberId: normalizedMemberId, + memberHandle: memberHandle || null, + coderIds: new Set(), + eligibleResourceCount: 0, + nonExampleSubmissionCount: 0, + finalCandidateCount: 0, + }); + } + const stats = memberStatsByMemberId.get(normalizedMemberId); + if (memberHandle && !stats.memberHandle) { + stats.memberHandle = memberHandle; + } + if (coderId) { + stats.coderIds.add(String(coderId)); + } + return stats; + }; + + const eligibleMemberIdentities = buildEligibleMemberIdentities({ + eligibleCoderIds: counters.eligibleRegistrants, + normalizedIdentityByCoderId, + }); + eligibleMemberIdentities.forEach((identity) => { + const stats = ensureMemberStats({ + memberId: identity.memberId, + memberHandle: identity.memberHandle, + }); + if (!stats) { + return; + } + stats.eligibleResourceCount += 1; + (identity.coderIds || []).forEach((coderId) => stats.coderIds.add(String(coderId))); + }); + + counters.nonExampleSubmissionCountsByCoderId.forEach((count, coderId) => { + const identity = resolveIdentityForCoderId(coderId, normalizedIdentityByCoderId); + if (!identity) { + return; + } + const stats = ensureMemberStats(identity); + if (!stats) { + return; + } + stats.nonExampleSubmissionCount += parseNonNegativeInteger(count); + }); + + counters.finalCandidateCoderIds.forEach((coderId) => { + const identity = resolveIdentityForCoderId(coderId, normalizedIdentityByCoderId); + if (!identity) { + return; + } + const stats = ensureMemberStats(identity); + if (!stats) { + return; + } + stats.finalCandidateCount += 1; + }); + + const missingMemberSkipRecords = []; + const explicitSkipRecords = []; + let materializableResourceCount = 0; + let materializableSubmissionCount = 0; + let materializableFinalScoreCount = 0; + let materializableProvisionalCount = 0; + + memberStatsByMemberId.forEach((stats) => { + const isResolved = resolvedMemberIds.has(stats.memberId); + const missingResourceCount = isResolved ? 0 : stats.eligibleResourceCount; + const missingSubmissionCount = isResolved ? 0 : stats.nonExampleSubmissionCount; + const missingProvisionalCount = isResolved ? 0 : stats.nonExampleSubmissionCount; + const hasAttachableFinal = stats.nonExampleSubmissionCount > 0; + const missingFinalCount = isResolved ? 0 : stats.finalCandidateCount; + const explicitFinalSkipCount = + isResolved && !hasAttachableFinal + ? stats.finalCandidateCount + : 0; + const importableFinalCount = + isResolved && hasAttachableFinal + ? stats.finalCandidateCount + : 0; + + partitions.resources.missingMember += missingResourceCount; + partitions.submissions.missingMember += missingSubmissionCount; + partitions.provisionalScores.missingMember += missingProvisionalCount; + partitions.finalScores.missingMember += missingFinalCount; + + materializableResourceCount += isResolved ? stats.eligibleResourceCount : 0; + materializableSubmissionCount += isResolved ? stats.nonExampleSubmissionCount : 0; + materializableProvisionalCount += isResolved ? stats.nonExampleSubmissionCount : 0; + materializableFinalScoreCount += importableFinalCount; + + if (!isResolved) { + const affectedSurfaces = []; + const counts = {}; + if (missingResourceCount > 0) { + affectedSurfaces.push("resource"); + counts.resource = missingResourceCount; + } + if (missingSubmissionCount > 0) { + affectedSurfaces.push("submission"); + counts.submission = missingSubmissionCount; + } + if (missingFinalCount > 0) { + affectedSurfaces.push("final-score"); + counts.finalScore = missingFinalCount; + } + if (missingProvisionalCount > 0) { + affectedSurfaces.push("provisional-score"); + counts.provisionalScore = missingProvisionalCount; + } + if (affectedSurfaces.length > 0) { + missingMemberSkipRecords.push({ + legacyRoundId: roundId, + memberId: stats.memberId, + memberHandle: stats.memberHandle || undefined, + coderIds: toSortedArray(stats.coderIds), + reasonCode: MISSING_MEMBER_REASON_CODE, + affectedSurfaces, + counts, + }); + } + } + + if (explicitFinalSkipCount > 0) { + partitions.finalScores.explicitSkips.total += explicitFinalSkipCount; + partitions.finalScores.explicitSkips.byReason[ + FINALIST_WITHOUT_ATTACHABLE_SUBMISSION_REASON_CODE + ] = (partitions.finalScores.explicitSkips.byReason[ + FINALIST_WITHOUT_ATTACHABLE_SUBMISSION_REASON_CODE + ] || 0) + explicitFinalSkipCount; + explicitSkipRecords.push({ + legacyRoundId: roundId, + memberId: stats.memberId, + memberHandle: stats.memberHandle || undefined, + coderIds: toSortedArray(stats.coderIds), + reasonCode: FINALIST_WITHOUT_ATTACHABLE_SUBMISSION_REASON_CODE, + affectedSurfaces: ["final-score"], + counts: { finalScore: explicitFinalSkipCount }, + }); + } + }); + + partitions.resources.alreadyPresent = Math.min( + materializableResourceCount, + parseNonNegativeInteger(existingCounts.resources) + ); + partitions.resources.toCreate = Math.max( + 0, + materializableResourceCount - partitions.resources.alreadyPresent + ); + + partitions.submissions.alreadyPresent = Math.min( + materializableSubmissionCount, + parseNonNegativeInteger(existingCounts.submissions) + ); + partitions.submissions.toImport = Math.max( + 0, + materializableSubmissionCount - partitions.submissions.alreadyPresent + ); + + partitions.finalScores.alreadyPresent = Math.min( + materializableFinalScoreCount, + parseNonNegativeInteger(existingCounts.finalScores) + ); + partitions.finalScores.toImport = Math.max( + 0, + materializableFinalScoreCount - partitions.finalScores.alreadyPresent + ); + + partitions.provisionalScores.alreadyPresent = Math.min( + materializableProvisionalCount, + parseNonNegativeInteger(existingCounts.provisionalScores) + ); + partitions.provisionalScores.toImport = Math.max( + 0, + materializableProvisionalCount - partitions.provisionalScores.alreadyPresent + ); + + const plannedSkipRecords = [...missingMemberSkipRecords, ...explicitSkipRecords].sort((left, right) => { + const leftMember = String(left.memberId || ""); + const rightMember = String(right.memberId || ""); + if (leftMember !== rightMember) { + return leftMember.localeCompare(rightMember, undefined, { numeric: true }); + } + return String(left.reasonCode || "").localeCompare(String(right.reasonCode || "")); + }); + + return { + partitions, + plannedSkipRecords, + materializable: { + resources: materializableResourceCount, + submissions: materializableSubmissionCount, + finalScores: materializableFinalScoreCount, + provisionalScores: materializableProvisionalCount, + }, + }; +}; + const isMarathonRoundType = (round) => String(round && round.round_type_id ? round.round_type_id : "").trim() === "13"; -const evaluateRoundPlan = (roundId, counters, existingStateEntry, prerequisites) => { +const evaluateRoundPlan = ({ + roundId, + counters, + existingStateEntry, + prerequisites, + normalizedIdentityByCoderId, + resolvedMemberIds, + skippedFilePath, +}) => { if (!counters.round) { return { recordType: "round-plan", @@ -231,6 +695,15 @@ const evaluateRoundPlan = (roundId, counters, existingStateEntry, prerequisites) counters: createEmptyCounters(), }), entityDeltas: buildZeroEntityDeltas(), + partitions: buildZeroPartitions(), + plannedSkipRecords: [], + skippedFileArtifact: skippedFilePath + ? { + path: skippedFilePath, + reasonCodes: [], + recordCount: 0, + } + : null, createPathChallengeShape: null, createPathPhasePlan: null, }; @@ -248,6 +721,7 @@ const evaluateRoundPlan = (roundId, counters, existingStateEntry, prerequisites) reason: "selected-round-round-type-is-not-marathon-match", counters, traceability, + skippedFilePath, }); } @@ -264,25 +738,10 @@ const evaluateRoundPlan = (roundId, counters, existingStateEntry, prerequisites) reason: "selected-round-lacks-marathon-signal-data", counters, traceability, + skippedFilePath, }); } - const finalAttachableMemberCount = Array.from(counters.finalCandidateCoderIds).filter((coderId) => - counters.nonExampleSubmitterCoderIds.has(coderId) - ).length; - const finalistsWithoutAttachableSubmission = Math.max( - 0, - counters.finalCandidateCoderIds.size - finalAttachableMemberCount - ); - - const targets = { - phases: 3, - resources: counters.eligibleRegistrants.size, - submissions: counters.nonExampleSubmissions, - finalScores: finalAttachableMemberCount, - provisionalScores: counters.nonExampleSubmissions, - }; - const matchStatus = existingStateEntry && existingStateEntry.matchStatus ? existingStateEntry.matchStatus : "none"; @@ -293,21 +752,10 @@ const evaluateRoundPlan = (roundId, counters, existingStateEntry, prerequisites) counters, traceability, matchedChallengeId: existingStateEntry.challengeId || null, + skippedFilePath, }); } - const existingCounts = existingStateEntry && existingStateEntry.existing ? existingStateEntry.existing : {}; - const entityDeltas = { - phases: buildEntityDelta(targets.phases, existingCounts.phases), - resources: buildEntityDelta(targets.resources, existingCounts.resources), - submissions: buildEntityDelta(targets.submissions, existingCounts.submissions), - finalScores: { - ...buildEntityDelta(targets.finalScores, existingCounts.finalScores), - skippedUnattachableFinalists: finalistsWithoutAttachableSubmission, - }, - provisionalScores: buildEntityDelta(targets.provisionalScores, existingCounts.provisionalScores), - }; - const hasMatchedChallenge = matchStatus === "safe" && Boolean(existingStateEntry.challengeId); let createPathChallengeShape = null; let createPathPhasePlan = null; @@ -318,6 +766,7 @@ const evaluateRoundPlan = (roundId, counters, existingStateEntry, prerequisites) reason: prerequisites.authoritativeDiscovery.reason, counters, traceability, + skippedFilePath, }); } if (!prerequisites.canonicalTimelineTemplate.resolved) { @@ -326,6 +775,7 @@ const evaluateRoundPlan = (roundId, counters, existingStateEntry, prerequisites) reason: prerequisites.canonicalTimelineTemplate.reason, counters, traceability, + skippedFilePath, }); } try { @@ -339,9 +789,48 @@ const evaluateRoundPlan = (roundId, counters, existingStateEntry, prerequisites) reason: "create-phase-plan-derivation-failed", counters, traceability, + skippedFilePath, }); } } + if (!prerequisites.memberResolution.available) { + return buildUnresolvedRecord({ + roundId, + reason: prerequisites.memberResolution.reason, + counters, + traceability, + matchedChallengeId: hasMatchedChallenge ? existingStateEntry.challengeId : null, + skippedFilePath, + }); + } + + const partitioned = buildSurfacePartitionsForRound({ + roundId, + counters, + existingStateEntry, + normalizedIdentityByCoderId, + resolvedMemberIds, + }); + const finalistsWithoutAttachableSubmission = parseNonNegativeInteger( + partitioned.partitions.finalScores.explicitSkips.byReason[ + FINALIST_WITHOUT_ATTACHABLE_SUBMISSION_REASON_CODE + ] + ); + + const existingCounts = existingStateEntry && existingStateEntry.existing ? existingStateEntry.existing : {}; + const entityDeltas = { + phases: buildEntityDelta(3, existingCounts.phases), + resources: buildEntityDelta(partitioned.materializable.resources, existingCounts.resources), + submissions: buildEntityDelta(partitioned.materializable.submissions, existingCounts.submissions), + finalScores: { + ...buildEntityDelta(partitioned.materializable.finalScores, existingCounts.finalScores), + skippedUnattachableFinalists: finalistsWithoutAttachableSubmission, + }, + provisionalScores: buildEntityDelta( + partitioned.materializable.provisionalScores, + existingCounts.provisionalScores + ), + }; const decision = hasMatchedChallenge ? "reuse/backfill-only" : "create"; const reason = hasMatchedChallenge @@ -367,17 +856,28 @@ const evaluateRoundPlan = (roundId, counters, existingStateEntry, prerequisites) traceability, summaryCounts: buildRoundSummaryCounts({ counters, - plannedFinalScores: finalAttachableMemberCount, - plannedProvisionalScores: counters.nonExampleSubmissions, + plannedFinalScores: + partitioned.partitions.finalScores.toImport + + partitioned.partitions.finalScores.alreadyPresent, + plannedProvisionalScores: + partitioned.partitions.provisionalScores.toImport + + partitioned.partitions.provisionalScores.alreadyPresent, finalistsWithoutAttachableSubmission, }), entityDeltas, + partitions: partitioned.partitions, + plannedSkipRecords: partitioned.plannedSkipRecords, + skippedFileArtifact: { + path: skippedFilePath, + reasonCodes: collectReasonCodes(partitioned.plannedSkipRecords), + recordCount: partitioned.plannedSkipRecords.length, + }, createPathChallengeShape, createPathPhasePlan, }; }; -const summarizePlan = (records, selectedRoundIds) => { +const summarizePlan = (records, selectedRoundIds, skippedFilePath) => { const countsByDecision = { create: 0, "reuse/backfill-only": 0, @@ -399,7 +899,10 @@ const summarizePlan = (records, selectedRoundIds) => { finalScores: 0, provisionalScores: 0, }, + partitions: buildZeroPartitions(), }; + const reasonCodes = new Set(); + let plannedSkipRecordCount = 0; records.forEach((record) => { if (countsByDecision[record.decision] !== undefined) { @@ -417,6 +920,57 @@ const summarizePlan = (records, selectedRoundIds) => { totals.toCreate.submissions += record.entityDeltas.submissions.toCreate; totals.toCreate.finalScores += record.entityDeltas.finalScores.toCreate; totals.toCreate.provisionalScores += record.entityDeltas.provisionalScores.toCreate; + + if (record.partitions) { + totals.partitions.resources.toCreate += record.partitions.resources.toCreate; + totals.partitions.resources.alreadyPresent += record.partitions.resources.alreadyPresent; + totals.partitions.resources.missingMember += record.partitions.resources.missingMember; + totals.partitions.resources.explicitSkips.total += + record.partitions.resources.explicitSkips.total; + + totals.partitions.submissions.legacyNonExample += + record.partitions.submissions.legacyNonExample; + totals.partitions.submissions.legacyExampleFiltered += + record.partitions.submissions.legacyExampleFiltered; + totals.partitions.submissions.toImport += record.partitions.submissions.toImport; + totals.partitions.submissions.alreadyPresent += + record.partitions.submissions.alreadyPresent; + totals.partitions.submissions.missingMember += record.partitions.submissions.missingMember; + totals.partitions.submissions.explicitSkips.total += + record.partitions.submissions.explicitSkips.total; + + totals.partitions.finalScores.legacyFinalCandidates += + record.partitions.finalScores.legacyFinalCandidates; + totals.partitions.finalScores.toImport += record.partitions.finalScores.toImport; + totals.partitions.finalScores.alreadyPresent += + record.partitions.finalScores.alreadyPresent; + totals.partitions.finalScores.missingMember += record.partitions.finalScores.missingMember; + totals.partitions.finalScores.explicitSkips.total += + record.partitions.finalScores.explicitSkips.total; + Object.entries(record.partitions.finalScores.explicitSkips.byReason || {}).forEach( + ([reasonCode, count]) => { + totals.partitions.finalScores.explicitSkips.byReason[reasonCode] = + (totals.partitions.finalScores.explicitSkips.byReason[reasonCode] || 0) + + parseNonNegativeInteger(count); + } + ); + + totals.partitions.provisionalScores.legacyNonExample += + record.partitions.provisionalScores.legacyNonExample; + totals.partitions.provisionalScores.toImport += + record.partitions.provisionalScores.toImport; + totals.partitions.provisionalScores.alreadyPresent += + record.partitions.provisionalScores.alreadyPresent; + totals.partitions.provisionalScores.missingMember += + record.partitions.provisionalScores.missingMember; + totals.partitions.provisionalScores.explicitSkips.total += + record.partitions.provisionalScores.explicitSkips.total; + } + + (record.plannedSkipRecords || []).forEach((skipRecord) => { + reasonCodes.add(skipRecord.reasonCode); + plannedSkipRecordCount += 1; + }); }); return { @@ -425,6 +979,11 @@ const summarizePlan = (records, selectedRoundIds) => { roundsRequested: selectedRoundIds.length, countsByDecision, totals, + skippedFileArtifact: { + path: skippedFilePath, + reasonCodes: Array.from(reasonCodes).sort(), + recordCount: plannedSkipRecordCount, + }, }; }; @@ -580,6 +1139,7 @@ const readLegacyPlanningInputs = async (options, roundDataById) => { if (stateInfo.coderId) { counters.nonExampleSubmitterCoderIds.add(stateInfo.coderId); + addCount(counters.nonExampleSubmissionCountsByCoderId, stateInfo.coderId, 1); } }) ) @@ -608,18 +1168,50 @@ const readLegacyPlanningInputs = async (options, roundDataById) => { const buildDryRunPlan = async (options, existingStateByRoundId, planningPrerequisites = {}) => { const normalizedPrerequisites = normalizePlanningPrerequisites(planningPrerequisites); const selectedRoundIds = [...options.roundIds]; + const skippedFilePath = resolveSkippedFilePath({ + skippedFilePath: options.skippedFilePath, + roundIds: selectedRoundIds, + cwd: options.cwd || process.cwd(), + }); const roundDataById = buildRoundDataById(selectedRoundIds); await readLegacyPlanningInputs(options, roundDataById); + const allKnownCoderIds = new Set(); + roundDataById.forEach((counters) => { + counters.eligibleRegistrants.forEach((coderId) => allKnownCoderIds.add(String(coderId))); + counters.nonExampleSubmitterCoderIds.forEach((coderId) => allKnownCoderIds.add(String(coderId))); + counters.finalCandidateCoderIds.forEach((coderId) => allKnownCoderIds.add(String(coderId))); + }); + const normalizedIdentityByCoderId = await loadNormalizedIdentityByCoderId({ + dataDir: options.dataDir, + userPattern: options.userPattern, + coderIds: allKnownCoderIds, + }); + + const memberResolution = await resolveMemberPlanningPrerequisite({ + options, + normalizedPrerequisites, + roundDataById, + normalizedIdentityByCoderId, + }); + normalizedPrerequisites.memberResolution = { + available: memberResolution.available, + reason: memberResolution.reason, + resolvedMemberIds: memberResolution.resolvedMemberIds, + }; + const records = selectedRoundIds.map((roundId) => - evaluateRoundPlan( + evaluateRoundPlan({ roundId, - roundDataById.get(roundId), - existingStateByRoundId.get(roundId), - normalizedPrerequisites - ) + counters: roundDataById.get(roundId), + existingStateEntry: existingStateByRoundId.get(roundId), + prerequisites: normalizedPrerequisites, + normalizedIdentityByCoderId, + resolvedMemberIds: memberResolution.resolvedMemberIds, + skippedFilePath, + }) ); - const summary = summarizePlan(records, selectedRoundIds); + const summary = summarizePlan(records, selectedRoundIds, skippedFilePath); return { records, summary, roundDataById }; }; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/skippedArtifact.js b/data-migration/src/scripts/importHistoricalMarathonMatches/skippedArtifact.js new file mode 100644 index 0000000..b382ec3 --- /dev/null +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/skippedArtifact.js @@ -0,0 +1,156 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const SKIPPED_ARTIFACT_SCHEMA_VERSION = 1; +const MISSING_MEMBER_REASON_CODE = "missing-member"; +const FINALIST_WITHOUT_ATTACHABLE_SUBMISSION_REASON_CODE = + "finalist-without-attachable-submission"; + +const normalizeMemberIdForSort = (value) => { + const parsed = Number.parseInt(String(value || "").trim(), 10); + if (Number.isFinite(parsed)) { + return parsed; + } + return null; +}; + +const normalizeAffectedSurfaces = (value) => { + const surfaces = Array.isArray(value) ? value : []; + return Array.from( + new Set( + surfaces + .map((surface) => String(surface || "").trim()) + .filter(Boolean) + ) + ); +}; + +const compareSkipRecords = (left, right) => { + const leftRound = String(left.legacyRoundId || ""); + const rightRound = String(right.legacyRoundId || ""); + const roundDelta = leftRound.localeCompare(rightRound, undefined, { numeric: true }); + if (roundDelta !== 0) { + return roundDelta; + } + + const leftMemberNum = normalizeMemberIdForSort(left.memberId); + const rightMemberNum = normalizeMemberIdForSort(right.memberId); + if (Number.isFinite(leftMemberNum) && Number.isFinite(rightMemberNum) && leftMemberNum !== rightMemberNum) { + return leftMemberNum - rightMemberNum; + } + + const leftMember = String(left.memberId || ""); + const rightMember = String(right.memberId || ""); + const memberDelta = leftMember.localeCompare(rightMember); + if (memberDelta !== 0) { + return memberDelta; + } + + const leftReason = String(left.reasonCode || ""); + const rightReason = String(right.reasonCode || ""); + const reasonDelta = leftReason.localeCompare(rightReason); + if (reasonDelta !== 0) { + return reasonDelta; + } + + const leftSurfaces = normalizeAffectedSurfaces(left.affectedSurfaces).join("|"); + const rightSurfaces = normalizeAffectedSurfaces(right.affectedSurfaces).join("|"); + return leftSurfaces.localeCompare(rightSurfaces); +}; + +const normalizeSkipRecord = (record) => { + const normalized = { + legacyRoundId: String(record && record.legacyRoundId ? record.legacyRoundId : "").trim(), + memberId: String(record && record.memberId ? record.memberId : "").trim(), + reasonCode: String(record && record.reasonCode ? record.reasonCode : "").trim(), + affectedSurfaces: normalizeAffectedSurfaces(record && record.affectedSurfaces), + }; + if (record && record.memberHandle) { + normalized.memberHandle = String(record.memberHandle).trim(); + } + if (record && Array.isArray(record.coderIds) && record.coderIds.length > 0) { + normalized.coderIds = Array.from( + new Set(record.coderIds.map((coderId) => String(coderId || "").trim()).filter(Boolean)) + ).sort((left, right) => left.localeCompare(right, undefined, { numeric: true })); + } + if (record && record.counts && typeof record.counts === "object") { + const entries = Object.entries(record.counts) + .map(([key, value]) => [String(key), Number.parseInt(value, 10)]) + .filter(([, value]) => Number.isFinite(value) && value > 0) + .sort(([left], [right]) => left.localeCompare(right)); + if (entries.length > 0) { + normalized.counts = Object.fromEntries(entries); + } + } + return normalized; +}; + +const normalizeSkipRecords = (records = []) => + records + .map((record) => normalizeSkipRecord(record)) + .filter( + (record) => + record.legacyRoundId && + record.memberId && + record.reasonCode && + record.affectedSurfaces.length > 0 + ) + .sort(compareSkipRecords); + +const collectReasonCodes = (records = []) => + Array.from( + new Set( + records + .map((record) => String(record && record.reasonCode ? record.reasonCode : "").trim()) + .filter(Boolean) + ) + ).sort(); + +const resolveSkippedFilePath = ({ + skippedFilePath, + roundIds = [], + cwd = process.cwd(), +}) => { + const normalizedPath = String(skippedFilePath || "").trim(); + if (normalizedPath) { + return path.isAbsolute(normalizedPath) + ? normalizedPath + : path.resolve(cwd, normalizedPath); + } + const roundToken = Array.from(new Set(roundIds.map((roundId) => String(roundId || "").trim()).filter(Boolean))) + .sort((left, right) => left.localeCompare(right, undefined, { numeric: true })) + .join("-"); + const suffix = roundToken || "selected-rounds"; + return path.resolve(cwd, `historical-mm-skipped-${suffix}.json`); +}; + +const buildSkippedArtifact = ({ selectedRoundIds = [], records = [] }) => { + const normalizedRecords = normalizeSkipRecords(records); + return { + schemaVersion: SKIPPED_ARTIFACT_SCHEMA_VERSION, + selectedRoundIds: [...selectedRoundIds], + reasonCodes: collectReasonCodes(normalizedRecords), + records: normalizedRecords, + }; +}; + +const writeSkippedArtifact = ({ filePath, selectedRoundIds = [], records = [] }) => { + const artifact = buildSkippedArtifact({ selectedRoundIds, records }); + const dirPath = path.dirname(filePath); + fs.mkdirSync(dirPath, { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(artifact, null, 2)}\n`, "utf8"); + return artifact; +}; + +module.exports = { + SKIPPED_ARTIFACT_SCHEMA_VERSION, + MISSING_MEMBER_REASON_CODE, + FINALIST_WITHOUT_ATTACHABLE_SUBMISSION_REASON_CODE, + resolveSkippedFilePath, + normalizeSkipRecords, + collectReasonCodes, + buildSkippedArtifact, + writeSkippedArtifact, +}; diff --git a/data-migration/test/importHistoricalMarathonMatches.apply.test.js b/data-migration/test/importHistoricalMarathonMatches.apply.test.js index 77bfe86..e9ef203 100644 --- a/data-migration/test/importHistoricalMarathonMatches.apply.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.apply.test.js @@ -1,3 +1,6 @@ +const os = require("os"); +const path = require("path"); + const { derivePhaseWindows, buildChallengePhaseRows, @@ -6,6 +9,12 @@ const { runApplyMode, } = require("../src/scripts/importHistoricalMarathonMatches/apply"); +const buildSkippedFilePath = (suffix) => + path.join( + os.tmpdir(), + `mm-apply-skipped-${suffix}-${Date.now()}-${Math.random().toString(16).slice(2)}.json` + ); + describe("importHistoricalMarathonMatches apply create-path behavior", () => { test("derives coherent closed MM phase windows from legacy activity", () => { const windows = derivePhaseWindows("9892", { @@ -305,6 +314,7 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { prisma, options: { roundIds: ["9892"], + skippedFilePath: buildSkippedFilePath("rerun-convergence"), resourceClient: { listSubmitterResources: jest.fn().mockResolvedValue([]), createSubmitterResource: jest.fn().mockResolvedValue({}), @@ -556,7 +566,10 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { const result = await runApplyMode({ prisma, - options: { roundIds: ["7000"] }, + options: { + roundIds: ["7000"], + skippedFilePath: buildSkippedFilePath("unresolved-round"), + }, plan: { records: [ { @@ -592,14 +605,21 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { reason: "selected-round-round-type-is-not-marathon-match", }, ]); - expect(result.summary).toEqual({ - recordType: "apply-summary", - created: 0, - existing: 0, - unmatched: 0, - unresolved: 1, - errors: 0, - }); + expect(result.summary).toEqual( + expect.objectContaining({ + recordType: "apply-summary", + created: 0, + existing: 0, + unmatched: 0, + unresolved: 1, + errors: 0, + skippedFileArtifact: { + path: expect.stringContaining("mm-apply-skipped-unresolved-round-"), + reasonCodes: [], + recordCount: 0, + }, + }) + ); expect(tx.challenge.create).not.toHaveBeenCalled(); expect(tx.challengePhase.createMany).not.toHaveBeenCalled(); }); @@ -664,6 +684,7 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { prisma, options: { roundIds: ["9892"], + skippedFilePath: buildSkippedFilePath("resource-reconciliation"), resourceClient, submitterRoleId: "submitter-role", }, diff --git a/data-migration/test/importHistoricalMarathonMatches.missingMemberPlanning.test.js b/data-migration/test/importHistoricalMarathonMatches.missingMemberPlanning.test.js new file mode 100644 index 0000000..4c8f91e --- /dev/null +++ b/data-migration/test/importHistoricalMarathonMatches.missingMemberPlanning.test.js @@ -0,0 +1,305 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + buildDryRunPlan, +} = require("../src/scripts/importHistoricalMarathonMatches/planning"); +const { + runApplyMode, +} = require("../src/scripts/importHistoricalMarathonMatches/apply"); + +const writeJson = (baseDir, fileName, rootKey, rows) => { + fs.writeFileSync( + path.join(baseDir, fileName), + `${JSON.stringify({ [rootKey]: rows }, null, 2)}\n`, + "utf8" + ); +}; + +const buildFixtureDataDirectory = () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-missing-member-plan-fixture-")); + + writeJson(baseDir, "round_1.json", "round", [ + { round_id: "9892", round_type_id: "13", name: "MM 9892", short_name: "MM 9892" }, + ]); + writeJson(baseDir, "round_component_1.json", "round_component", [ + { round_id: "9892", component_id: "5503" }, + ]); + writeJson(baseDir, "component_1.json", "component", [ + { component_id: "5503", problem_id: "9001" }, + ]); + writeJson(baseDir, "problem_1.json", "problem", [{ problem_id: "9001" }]); + writeJson(baseDir, "long_component_state_1.json", "long_component_state", [ + { long_component_state_id: "lcs-1", round_id: "9892", coder_id: "1", component_id: "5503" }, + { long_component_state_id: "lcs-2", round_id: "9892", coder_id: "2", component_id: "5503" }, + { long_component_state_id: "lcs-3", round_id: "9892", coder_id: "3", component_id: "5503" }, + { long_component_state_id: "lcs-4", round_id: "9892", coder_id: "4", component_id: "5503" }, + ]); + writeJson(baseDir, "long_submission_1.json", "long_submission", [ + { long_component_state_id: "lcs-1", submission_number: "1", example: "0", submit_time: "100", open_time: "90", submission_points: "10.0" }, + { long_component_state_id: "lcs-1", submission_number: "2", example: "1", submit_time: "101", open_time: "90", submission_points: "11.0" }, + { long_component_state_id: "lcs-2", submission_number: "1", example: "0", submit_time: "102", open_time: "90", submission_points: "12.0" }, + { long_component_state_id: "lcs-3", submission_number: "1", example: "0", submit_time: "103", open_time: "90", submission_points: "13.0" }, + { long_component_state_id: "lcs-3", submission_number: "2", example: "0", submit_time: "104", open_time: "90", submission_points: "14.0" }, + ]); + writeJson(baseDir, "long_comp_result_1.json", "long_comp_result", [ + { round_id: "9892", coder_id: "1", system_point_total: "98.1", point_total: null, placed: "1" }, + { round_id: "9892", coder_id: "3", system_point_total: "91.5", point_total: null, placed: "2" }, + { round_id: "9892", coder_id: "4", system_point_total: "77.7", point_total: null, placed: "3" }, + ]); + writeJson(baseDir, "round_registration_1.json", "round_registration", [ + { round_id: "9892", coder_id: "1", eligible: "1", timestamp: "2020-01-01 00:00:00.0" }, + { round_id: "9892", coder_id: "2", eligible: "1", timestamp: "2020-01-01 00:01:00.0" }, + { round_id: "9892", coder_id: "3", eligible: "1", timestamp: "2020-01-01 00:02:00.0" }, + ]); + writeJson(baseDir, "user_1.json", "user", [ + { user_id: "1", handle: "alpha" }, + { user_id: "2", handle: "bravo" }, + { user_id: "3", handle: "charlie" }, + { user_id: "4", handle: "delta" }, + ]); + + return baseDir; +}; + +const buildOptions = (fixtureDir) => ({ + dataDir: fixtureDir, + roundFile: "round_1.json", + roundComponentFile: "round_component_1.json", + componentFile: "component_1.json", + problemFile: "problem_1.json", + longComponentStateFile: "long_component_state_1.json", + roundRegistrationPattern: "^round_registration_\\d+\\.json$", + userPattern: "^user_\\d+\\.json$", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + roundIds: ["9892"], + skippedFilePath: path.join(fixtureDir, "skipped-members.json"), +}); + +describe("importHistoricalMarathonMatches missing-member planning/reporting", () => { + let fixtureDir; + + beforeEach(() => { + fixtureDir = buildFixtureDataDirectory(); + }); + + afterEach(() => { + fs.rmSync(fixtureDir, { recursive: true, force: true }); + }); + + test("marks round unresolved when member-resolution prerequisite is unavailable", async () => { + const existingStateByRoundId = new Map([ + [ + "9892", + { + legacyRoundId: "9892", + matchStatus: "safe", + reason: "existing-v6-challenge-found", + challengeId: "challenge-1", + existing: { + phases: 3, + resources: 1, + submissions: 1, + finalScores: 0, + provisionalScores: 1, + }, + }, + ], + ]); + + const plan = await buildDryRunPlan( + buildOptions(fixtureDir), + existingStateByRoundId, + { + authoritativeDiscovery: { available: true }, + canonicalTimelineTemplate: { resolved: true, timelineTemplateId: "timeline-mm" }, + memberResolution: { + available: false, + reason: "target-member-resolution-unavailable", + }, + } + ); + + expect(plan.records).toHaveLength(1); + expect(plan.records[0].decision).toBe("unresolved"); + expect(plan.records[0].reason).toBe("target-member-resolution-unavailable"); + }); + + test("partitions member-owned surfaces into materialized, missing-member, and explicit skip reasons", async () => { + const existingStateByRoundId = new Map([ + [ + "9892", + { + legacyRoundId: "9892", + matchStatus: "safe", + reason: "existing-v6-challenge-found", + challengeId: "challenge-1", + existing: { + phases: 3, + resources: 1, + submissions: 1, + finalScores: 0, + provisionalScores: 1, + }, + }, + ], + ]); + + const plan = await buildDryRunPlan( + buildOptions(fixtureDir), + existingStateByRoundId, + { + authoritativeDiscovery: { available: true }, + canonicalTimelineTemplate: { resolved: true, timelineTemplateId: "timeline-mm" }, + memberResolution: { + available: true, + resolvedMemberIds: new Set(["1", "2", "4"]), + }, + } + ); + + const [record] = plan.records; + expect(record.decision).toBe("reuse/backfill-only"); + expect(record.partitions.resources).toEqual({ + toCreate: 1, + alreadyPresent: 1, + missingMember: 1, + explicitSkips: { + total: 0, + byReason: {}, + }, + }); + expect(record.partitions.submissions).toEqual({ + legacyNonExample: 4, + legacyExampleFiltered: 1, + toImport: 1, + alreadyPresent: 1, + missingMember: 2, + explicitSkips: { + total: 0, + byReason: {}, + }, + }); + expect(record.partitions.finalScores).toEqual({ + legacyFinalCandidates: 3, + toImport: 1, + alreadyPresent: 0, + missingMember: 1, + explicitSkips: { + total: 1, + byReason: { + "finalist-without-attachable-submission": 1, + }, + }, + }); + expect(record.partitions.provisionalScores).toEqual({ + legacyNonExample: 4, + toImport: 1, + alreadyPresent: 1, + missingMember: 2, + explicitSkips: { + total: 0, + byReason: {}, + }, + }); + + expect(record.plannedSkipRecords).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + legacyRoundId: "9892", + memberId: "3", + reasonCode: "missing-member", + affectedSurfaces: ["resource", "submission", "final-score", "provisional-score"], + }), + expect.objectContaining({ + legacyRoundId: "9892", + memberId: "4", + reasonCode: "finalist-without-attachable-submission", + affectedSurfaces: ["final-score"], + }), + ]) + ); + expect(record.skippedFileArtifact).toEqual( + expect.objectContaining({ + path: path.join(fixtureDir, "skipped-members.json"), + }) + ); + }); + + test("apply writes deterministic skipped-member artifact with stable reason codes", async () => { + const skippedFilePath = path.join(fixtureDir, "apply-skipped.json"); + const result = await runApplyMode({ + prisma: null, + options: { + roundIds: ["9892"], + skippedFilePath, + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "unresolved", + reason: "target-member-resolution-unavailable", + plannedSkipRecords: [ + { + legacyRoundId: "9892", + memberId: "4", + reasonCode: "finalist-without-attachable-submission", + affectedSurfaces: ["final-score"], + }, + { + legacyRoundId: "9892", + memberId: "3", + reasonCode: "missing-member", + affectedSurfaces: ["resource", "submission", "final-score", "provisional-score"], + }, + ], + }, + ], + roundDataById: new Map([ + [ + "9892", + { + round: { round_id: "9892", round_type_id: "13" }, + }, + ], + ]), + }, + actor: "importer", + }); + + expect(result.summary).toEqual( + expect.objectContaining({ + unresolved: 1, + skippedFileArtifact: { + path: skippedFilePath, + reasonCodes: ["finalist-without-attachable-submission", "missing-member"], + recordCount: 2, + }, + }) + ); + + const artifact = JSON.parse(fs.readFileSync(skippedFilePath, "utf8")); + expect(artifact).toEqual({ + schemaVersion: 1, + selectedRoundIds: ["9892"], + reasonCodes: ["finalist-without-attachable-submission", "missing-member"], + records: [ + { + legacyRoundId: "9892", + memberId: "3", + reasonCode: "missing-member", + affectedSurfaces: ["resource", "submission", "final-score", "provisional-score"], + }, + { + legacyRoundId: "9892", + memberId: "4", + reasonCode: "finalist-without-attachable-submission", + affectedSurfaces: ["final-score"], + }, + ], + }); + }); +}); diff --git a/data-migration/test/importHistoricalMarathonMatches.planningPrerequisites.test.js b/data-migration/test/importHistoricalMarathonMatches.planningPrerequisites.test.js index 41b3e6c..55a9e96 100644 --- a/data-migration/test/importHistoricalMarathonMatches.planningPrerequisites.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.planningPrerequisites.test.js @@ -104,6 +104,10 @@ describe("importHistoricalMarathonMatches planning prerequisites", () => { resolved: true, timelineTemplateId: "timeline-mm", }, + memberResolution: { + available: true, + resolvedMemberIds: new Set(["1"]), + }, } ); From 682e77eba961e8f05d4569b93a45fb1746d491cc Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 2 Apr 2026 14:00:08 +1100 Subject: [PATCH 16/27] Filter planned missing-member resources during apply reconciliation Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../importHistoricalMarathonMatches/apply.js | 47 ++++++- ...ortHistoricalMarathonMatches.apply.test.js | 128 ++++++++++++++++++ 2 files changed, 174 insertions(+), 1 deletion(-) diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js index 7b249cf..ad90338 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js @@ -10,6 +10,7 @@ const { normalizeSkipRecords, collectReasonCodes, writeSkippedArtifact, + MISSING_MEMBER_REASON_CODE, } = require("./skippedArtifact"); const STANDARD_PHASE_NAMES = ["Registration", "Submission", "Review"]; @@ -468,6 +469,41 @@ const collectPlannedSkipRecords = (roundIds, planRecordByRoundId) => { return normalizeSkipRecords(records); }; +const hasAffectedSurface = (record, surfaceName) => + Array.isArray(record && record.affectedSurfaces) && + record.affectedSurfaces.some( + (surface) => String(surface || "").trim().toLowerCase() === String(surfaceName || "").trim().toLowerCase() + ); + +const collectMissingMemberResourceSkipMemberIdsByRoundId = (roundIds, planRecordByRoundId) => { + const byRoundId = new Map(); + + roundIds.forEach((roundId) => { + const planRecord = planRecordByRoundId.get(roundId); + const skipMemberIds = new Set(); + + if (planRecord && Array.isArray(planRecord.plannedSkipRecords)) { + planRecord.plannedSkipRecords.forEach((record) => { + const reasonCode = String(record && record.reasonCode ? record.reasonCode : "").trim(); + if (reasonCode !== MISSING_MEMBER_REASON_CODE) { + return; + } + if (!hasAffectedSurface(record, "resource")) { + return; + } + const memberId = parseMemberId(record && record.memberId); + if (memberId) { + skipMemberIds.add(memberId); + } + }); + } + + byRoundId.set(roundId, skipMemberIds); + }); + + return byRoundId; +}; + const runApplyMode = async ({ prisma, options, @@ -482,6 +518,8 @@ const runApplyMode = async ({ cwd: options.cwd || process.cwd(), }); const plannedSkipRecords = collectPlannedSkipRecords(options.roundIds, planRecordByRoundId); + const missingMemberResourceSkipMemberIdsByRoundId = + collectMissingMemberResourceSkipMemberIdsByRoundId(options.roundIds, planRecordByRoundId); const skippedArtifact = writeSkippedArtifact({ filePath: skippedFilePath, selectedRoundIds: options.roundIds, @@ -600,6 +638,8 @@ const runApplyMode = async ({ resourceClient, submitterRoleId, challengeStatusController, + missingMemberResourceSkipMemberIds: + missingMemberResourceSkipMemberIdsByRoundId.get(roundId) || new Set(), }); applyRecords.push({ recordType: "apply-record", @@ -660,11 +700,16 @@ const reconcileSubmitterResourcesForRound = async ({ resourceClient, submitterRoleId, challengeStatusController, + missingMemberResourceSkipMemberIds = new Set(), }) => { + const plannedMissingMemberSkipIds = + missingMemberResourceSkipMemberIds instanceof Set + ? missingMemberResourceSkipMemberIds + : new Set(); const eligibleMemberIdentities = buildEligibleMemberIdentities({ eligibleCoderIds: counters && counters.eligibleRegistrants ? counters.eligibleRegistrants : new Set(), normalizedIdentityByCoderId, - }); + }).filter((identity) => !plannedMissingMemberSkipIds.has(identity.memberId)); const targetEligibleRegistrants = eligibleMemberIdentities.length; if (targetEligibleRegistrants === 0) { return { diff --git a/data-migration/test/importHistoricalMarathonMatches.apply.test.js b/data-migration/test/importHistoricalMarathonMatches.apply.test.js index e9ef203..6c240f5 100644 --- a/data-migration/test/importHistoricalMarathonMatches.apply.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.apply.test.js @@ -751,6 +751,134 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { ]); }); + test("apply mode filters planned missing-member resource skips before Resource API creates", async () => { + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({ id: "challenge-1" }), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([]), + createMany: jest.fn(), + }, + }; + + const phaseRows = [ + { id: "phase-registration", name: "Registration" }, + { id: "phase-submission", name: "Submission" }, + { id: "phase-review", name: "Review" }, + ]; + + const prisma = { + challengeType: { + findMany: jest.fn().mockResolvedValue([{ id: "type-mm" }]), + }, + challengeTrack: { + findMany: jest.fn().mockResolvedValue([{ id: "track-ds" }]), + }, + phase: { + findMany: jest + .fn() + .mockResolvedValueOnce(phaseRows) + .mockResolvedValueOnce(phaseRows), + }, + challengeTimelineTemplate: { + findMany: jest.fn().mockResolvedValue([ + { + timelineTemplateId: "timeline-mm", + isDefault: true, + timelineTemplate: { + phases: [ + { phaseId: "phase-registration" }, + { phaseId: "phase-submission" }, + { phaseId: "phase-review" }, + ], + }, + }, + ]), + }, + $transaction: async (callback) => callback(tx), + }; + + const resourceClient = { + listSubmitterResources: jest.fn().mockResolvedValue([]), + createSubmitterResource: jest.fn().mockImplementation(async ({ memberId }) => { + if (memberId === "2") { + throw new Error("missing-member should have been filtered before Resource API create"); + } + return {}; + }), + }; + + const result = await runApplyMode({ + prisma, + options: { + roundIds: ["9892"], + skippedFilePath: buildSkippedFilePath("resource-missing-member-filter"), + resourceClient, + submitterRoleId: "submitter-role", + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "create", + reason: "no-matching-v6-challenge-found", + plannedSkipRecords: [ + { + legacyRoundId: "9892", + memberId: "2", + reasonCode: "missing-member", + affectedSurfaces: ["resource", "submission", "final-score", "provisional-score"], + }, + ], + }, + ], + roundDataById: new Map([ + [ + "9892", + { + round: { round_id: "9892", round_type_id: "13" }, + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2"]), + nonExampleSubmissions: 3, + }, + ], + ]), + }, + actor: "importer", + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ]), + }); + + expect(resourceClient.createSubmitterResource).toHaveBeenCalledTimes(1); + expect(resourceClient.createSubmitterResource).toHaveBeenCalledWith({ + challengeId: "challenge-1", + memberId: "1", + roleId: "submitter-role", + }); + expect(result.records).toEqual([ + { + recordType: "apply-record", + legacyRoundId: "9892", + status: "created", + challengeId: "challenge-1", + resourceReconciliation: { + targetEligibleRegistrants: 1, + existingSubmitterResources: 0, + createdSubmitterResources: 1, + unchangedSubmitterResources: 0, + }, + }, + ]); + }); + test("resource reconciliation temporarily transitions COMPLETED challenges and restores status on retry success", async () => { const completedRestrictionError = new Error( "Failed to create submitter resource for challenge challenge-1 member 2 (400 Bad Request): challenge is completed." From a0214d1fb6532ba4b8db525e144cc67f51a86591 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 2 Apr 2026 15:02:51 +1100 Subject: [PATCH 17/27] Import deterministic non-example submission history for resolvable members --- .../importHistoricalMarathonMatches.js | 26 + .../importHistoricalMarathonMatches/apply.js | 103 +++- .../skippedArtifact.js | 10 + .../submissionHistory.js | 471 ++++++++++++++++++ ...alMarathonMatches.applySubmissions.test.js | 214 ++++++++ ...lMarathonMatches.submissionHistory.test.js | 214 ++++++++ 6 files changed, 1030 insertions(+), 8 deletions(-) create mode 100644 data-migration/src/scripts/importHistoricalMarathonMatches/submissionHistory.js create mode 100644 data-migration/test/importHistoricalMarathonMatches.applySubmissions.test.js create mode 100644 data-migration/test/importHistoricalMarathonMatches.submissionHistory.test.js diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches.js b/data-migration/src/scripts/importHistoricalMarathonMatches.js index 23c5a06..5410143 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches.js @@ -77,10 +77,13 @@ const run = async () => { const snapshotByRoundId = loadExistingState(options.dataDir, options.existingStateFile); const shouldAttemptDatabaseDiscovery = options.apply || Boolean(String(process.env.DATABASE_URL || "").trim()); + const reviewDbUrl = String(process.env.REVIEW_DB_URL || "").trim(); + const reviewDbSchema = String(process.env.REVIEW_DB_SCHEMA || "reviews").trim(); const memberDbUrl = String(process.env.MEMBER_DB_URL || process.env.DATABASE_URL || "").trim(); const memberDbSchema = String(process.env.MEMBER_DB_SCHEMA || DEFAULT_MEMBER_SCHEMA).trim(); let prisma = null; let memberLookupPrisma = null; + let reviewPrisma = null; let resolveMemberPresence = null; if (shouldAttemptDatabaseDiscovery) { @@ -101,6 +104,23 @@ const run = async () => { }, }); } + if (options.apply) { + if (!reviewDbUrl) { + throw new Error("REVIEW_DB_URL must be set for apply mode submission import."); + } + const { PrismaClient } = requireFromRoot("@prisma/client"); + const databaseUrl = String(process.env.DATABASE_URL || "").trim(); + reviewPrisma = + prisma && databaseUrl && reviewDbUrl === databaseUrl + ? prisma + : new PrismaClient({ + datasources: { + db: { + url: reviewDbUrl, + }, + }, + }); + } try { let existingStateByRoundId = null; @@ -224,6 +244,9 @@ const run = async () => { cwd: process.cwd(), submitterRoleId, resourceClient: createDefaultResourceClient(), + reviewClient: reviewPrisma, + reviewSchema: reviewDbSchema, + importSubmissions: true, }, plan, actor: DEFAULT_ACTOR, @@ -233,6 +256,9 @@ const run = async () => { if (memberLookupPrisma && memberLookupPrisma !== prisma) { await memberLookupPrisma.$disconnect(); } + if (reviewPrisma && reviewPrisma !== prisma && reviewPrisma !== memberLookupPrisma) { + await reviewPrisma.$disconnect(); + } if (prisma) { await prisma.$disconnect(); } diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js index ad90338..aed4fd7 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js @@ -12,6 +12,12 @@ const { writeSkippedArtifact, MISSING_MEMBER_REASON_CODE, } = require("./skippedArtifact"); +const { + DEFAULT_REVIEW_SCHEMA, + loadNonExampleLegacySubmissionRowsByRoundId, + createReviewSubmissionStore, + reconcileRoundSubmissionHistory, +} = require("./submissionHistory"); const STANDARD_PHASE_NAMES = ["Registration", "Submission", "Review"]; const DEFAULT_SUBMITTER_ROLE_ID = "732339e7-8e30-49d7-9198-cccf9451e221"; @@ -475,7 +481,11 @@ const hasAffectedSurface = (record, surfaceName) => (surface) => String(surface || "").trim().toLowerCase() === String(surfaceName || "").trim().toLowerCase() ); -const collectMissingMemberResourceSkipMemberIdsByRoundId = (roundIds, planRecordByRoundId) => { +const collectMissingMemberSkipMemberIdsByRoundId = ({ + roundIds, + planRecordByRoundId, + affectedSurface, +}) => { const byRoundId = new Map(); roundIds.forEach((roundId) => { @@ -488,7 +498,7 @@ const collectMissingMemberResourceSkipMemberIdsByRoundId = (roundIds, planRecord if (reasonCode !== MISSING_MEMBER_REASON_CODE) { return; } - if (!hasAffectedSurface(record, "resource")) { + if (!hasAffectedSurface(record, affectedSurface)) { return; } const memberId = parseMemberId(record && record.memberId); @@ -519,8 +529,18 @@ const runApplyMode = async ({ }); const plannedSkipRecords = collectPlannedSkipRecords(options.roundIds, planRecordByRoundId); const missingMemberResourceSkipMemberIdsByRoundId = - collectMissingMemberResourceSkipMemberIdsByRoundId(options.roundIds, planRecordByRoundId); - const skippedArtifact = writeSkippedArtifact({ + collectMissingMemberSkipMemberIdsByRoundId({ + roundIds: options.roundIds, + planRecordByRoundId, + affectedSurface: "resource", + }); + const missingMemberSubmissionSkipMemberIdsByRoundId = + collectMissingMemberSkipMemberIdsByRoundId({ + roundIds: options.roundIds, + planRecordByRoundId, + affectedSurface: "submission", + }); + let skippedArtifact = writeSkippedArtifact({ filePath: skippedFilePath, selectedRoundIds: options.roundIds, records: plannedSkipRecords, @@ -539,11 +559,17 @@ const runApplyMode = async ({ return decision === "create"; }); const submitterRoleId = String(options.submitterRoleId || DEFAULT_SUBMITTER_ROLE_ID).trim(); + const submissionImportEnabled = options.importSubmissions === true; const resourceClient = options.resourceClient; if (actionableRoundIds.length > 0 && !resourceClient) { throw new Error("Resource API client is required for apply mode participant reconciliation."); } + if (actionableRoundIds.length > 0 && submissionImportEnabled && !options.reviewClient && !options.submissionStore) { + throw new Error( + "Review DB client is required for apply mode submission-history reconciliation." + ); + } const challengeStatusController = options.challengeStatusController || createPrismaChallengeStatusController({ prisma, actor }); @@ -555,7 +581,7 @@ const runApplyMode = async ({ ? providedNormalizedIdentityByCoderId : null; if (!normalizedIdentityByCoderId) { - const eligibleCoderIds = new Set(); + const relevantCoderIds = new Set(); actionableRoundIds.forEach((roundId) => { const counters = plan.roundDataById.get(roundId); if (!counters || !(counters.eligibleRegistrants instanceof Set)) { @@ -564,18 +590,52 @@ const runApplyMode = async ({ counters.eligibleRegistrants.forEach((coderId) => { const normalizedCoderId = String(coderId || "").trim(); if (normalizedCoderId) { - eligibleCoderIds.add(normalizedCoderId); + relevantCoderIds.add(normalizedCoderId); } }); + if (counters.nonExampleSubmitterCoderIds instanceof Set) { + counters.nonExampleSubmitterCoderIds.forEach((coderId) => { + const normalizedCoderId = String(coderId || "").trim(); + if (normalizedCoderId) { + relevantCoderIds.add(normalizedCoderId); + } + }); + } + if (counters.finalCandidateCoderIds instanceof Set) { + counters.finalCandidateCoderIds.forEach((coderId) => { + const normalizedCoderId = String(coderId || "").trim(); + if (normalizedCoderId) { + relevantCoderIds.add(normalizedCoderId); + } + }); + } }); normalizedIdentityByCoderId = await loadNormalizedIdentityByCoderId({ dataDir: options.dataDir, userPattern: options.userPattern || DEFAULT_USER_PATTERN, - coderIds: eligibleCoderIds, + coderIds: relevantCoderIds, }); } + let roundSubmissionRowsByRoundId = new Map(); + let submissionStore = null; + if (submissionImportEnabled && actionableRoundIds.length > 0) { + roundSubmissionRowsByRoundId = await loadNonExampleLegacySubmissionRowsByRoundId({ + dataDir: options.dataDir, + longComponentStateFile: options.longComponentStateFile, + longSubmissionPattern: options.longSubmissionPattern, + roundIds: actionableRoundIds, + }); + submissionStore = + options.submissionStore || + (await createReviewSubmissionStore({ + reviewClient: options.reviewClient, + reviewSchema: options.reviewSchema || DEFAULT_REVIEW_SCHEMA, + actor, + })); + } + let marathonTypeId = null; let dataScienceTrackId = null; let phaseIdsByName = null; @@ -594,6 +654,7 @@ const runApplyMode = async ({ } const applyRecords = []; + const runtimeSkipRecords = []; for (const roundId of options.roundIds) { const counters = plan.roundDataById.get(roundId); const planRecord = planRecordByRoundId.get(roundId); @@ -641,12 +702,28 @@ const runApplyMode = async ({ missingMemberResourceSkipMemberIds: missingMemberResourceSkipMemberIdsByRoundId.get(roundId) || new Set(), }); + const submissionReconciliation = + submissionImportEnabled && submissionStore + ? await reconcileRoundSubmissionHistory({ + roundId, + challengeId: result.challengeId, + rowsByRoundId: roundSubmissionRowsByRoundId, + normalizedIdentityByCoderId, + missingMemberSubmissionSkipMemberIds: + missingMemberSubmissionSkipMemberIdsByRoundId.get(roundId) || new Set(), + submissionStore, + }) + : null; + if (submissionReconciliation && Array.isArray(submissionReconciliation.skippedSubmissionRecords)) { + runtimeSkipRecords.push(...submissionReconciliation.skippedSubmissionRecords); + } applyRecords.push({ recordType: "apply-record", legacyRoundId: roundId, status: result.status, challengeId: result.challengeId, resourceReconciliation, + ...(submissionReconciliation ? { submissionReconciliation } : {}), }); } catch (error) { applyRecords.push({ @@ -659,6 +736,16 @@ const runApplyMode = async ({ } } + const finalSkipRecords = normalizeSkipRecords([ + ...plannedSkipRecords, + ...runtimeSkipRecords, + ]); + skippedArtifact = writeSkippedArtifact({ + filePath: skippedFilePath, + selectedRoundIds: options.roundIds, + records: finalSkipRecords, + }); + const summary = applyRecords.reduce( (acc, record) => { if (record.status === "created") { @@ -678,7 +765,7 @@ const runApplyMode = async ({ ); summary.skippedFileArtifact = { path: skippedFilePath, - reasonCodes: collectReasonCodes(plannedSkipRecords), + reasonCodes: collectReasonCodes(finalSkipRecords), recordCount: skippedArtifact.records.length, }; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/skippedArtifact.js b/data-migration/src/scripts/importHistoricalMarathonMatches/skippedArtifact.js index b382ec3..d4d5f95 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/skippedArtifact.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/skippedArtifact.js @@ -55,6 +55,13 @@ const compareSkipRecords = (left, right) => { return reasonDelta; } + const leftLegacySubmissionId = String(left.legacySubmissionId || ""); + const rightLegacySubmissionId = String(right.legacySubmissionId || ""); + const legacySubmissionDelta = leftLegacySubmissionId.localeCompare(rightLegacySubmissionId); + if (legacySubmissionDelta !== 0) { + return legacySubmissionDelta; + } + const leftSurfaces = normalizeAffectedSurfaces(left.affectedSurfaces).join("|"); const rightSurfaces = normalizeAffectedSurfaces(right.affectedSurfaces).join("|"); return leftSurfaces.localeCompare(rightSurfaces); @@ -75,6 +82,9 @@ const normalizeSkipRecord = (record) => { new Set(record.coderIds.map((coderId) => String(coderId || "").trim()).filter(Boolean)) ).sort((left, right) => left.localeCompare(right, undefined, { numeric: true })); } + if (record && record.legacySubmissionId) { + normalized.legacySubmissionId = String(record.legacySubmissionId).trim(); + } if (record && record.counts && typeof record.counts === "object") { const entries = Object.entries(record.counts) .map(([key, value]) => [String(key), Number.parseInt(value, 10)]) diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/submissionHistory.js b/data-migration/src/scripts/importHistoricalMarathonMatches/submissionHistory.js new file mode 100644 index 0000000..1000dbb --- /dev/null +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/submissionHistory.js @@ -0,0 +1,471 @@ +"use strict"; + +const crypto = require("crypto"); + +const { + ensureFileExists, + listFilesByPattern, + resolveFilePath, + streamJsonArray, +} = require("./legacyDataReader"); +const { + MISSING_MEMBER_REASON_CODE, +} = require("./skippedArtifact"); + +const CONTEST_SUBMISSION_TYPE = "CONTEST_SUBMISSION"; +const ACTIVE_SUBMISSION_STATUS = "ACTIVE"; +const DEFAULT_REVIEW_SCHEMA = "reviews"; + +const parsePositiveInteger = (value) => { + const parsed = Number.parseInt(String(value || "").trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +}; + +const parseEpochMs = (value) => { + const parsed = Number.parseInt(String(value || "").trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +}; + +const normalizeMemberId = (value) => { + const parsed = parsePositiveInteger(value); + if (!parsed) { + return null; + } + return String(parsed); +}; + +const normalizeReviewSchema = (value) => { + const normalized = String(value || DEFAULT_REVIEW_SCHEMA).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(normalized)) { + throw new Error(`Invalid REVIEW_DB_SCHEMA "${normalized}"`); + } + return normalized; +}; + +const buildQualifiedTableName = (schemaName, tableName) => + `"${String(schemaName).replace(/"/g, "\"\"")}"."${String(tableName).replace(/"/g, "\"\"")}"`; + +const compareSubmissionRows = (left, right) => { + const leftSubmitTime = Number.isFinite(left.submitTimeMs) ? left.submitTimeMs : Number.MAX_SAFE_INTEGER; + const rightSubmitTime = Number.isFinite(right.submitTimeMs) ? right.submitTimeMs : Number.MAX_SAFE_INTEGER; + if (leftSubmitTime !== rightSubmitTime) { + return leftSubmitTime - rightSubmitTime; + } + + const leftState = String(left.longComponentStateId || ""); + const rightState = String(right.longComponentStateId || ""); + const stateDelta = leftState.localeCompare(rightState, undefined, { numeric: true }); + if (stateDelta !== 0) { + return stateDelta; + } + + const leftSubmissionNumber = Number.isFinite(left.submissionNumber) ? left.submissionNumber : Number.MAX_SAFE_INTEGER; + const rightSubmissionNumber = Number.isFinite(right.submissionNumber) ? right.submissionNumber : Number.MAX_SAFE_INTEGER; + if (leftSubmissionNumber !== rightSubmissionNumber) { + return leftSubmissionNumber - rightSubmissionNumber; + } + + return String(left.legacySubmissionId || "").localeCompare(String(right.legacySubmissionId || "")); +}; + +const deriveLegacySubmissionId = ({ longComponentStateId, submissionNumber }) => { + const normalizedStateId = String(longComponentStateId || "").trim(); + if (!normalizedStateId) { + throw new Error("Cannot derive legacySubmissionId without long_component_state_id."); + } + const normalizedSubmissionNumber = parsePositiveInteger(submissionNumber); + if (!normalizedSubmissionNumber) { + throw new Error("Cannot derive legacySubmissionId without a positive submission_number."); + } + const suffix = String(normalizedSubmissionNumber).padStart(4, "0"); + return `${normalizedStateId}${suffix}`; +}; + +const resolveIdentityForCoderId = (coderId, normalizedIdentityByCoderId = new Map()) => { + const normalizedCoderId = String(coderId || "").trim(); + if (!normalizedCoderId) { + return null; + } + const knownIdentity = normalizedIdentityByCoderId.get(normalizedCoderId); + const knownMemberId = normalizeMemberId(knownIdentity && knownIdentity.memberId); + if (knownMemberId) { + return { + coderId: normalizedCoderId, + memberId: knownMemberId, + memberHandle: knownIdentity.memberHandle || null, + }; + } + + const fallbackMemberId = normalizeMemberId(normalizedCoderId); + if (!fallbackMemberId) { + return null; + } + return { + coderId: normalizedCoderId, + memberId: fallbackMemberId, + memberHandle: null, + }; +}; + +const loadNonExampleLegacySubmissionRowsByRoundId = async ({ + dataDir, + longComponentStateFile, + longSubmissionPattern, + roundIds, +}) => { + const selectedRoundIds = Array.from(new Set((roundIds || []).map((roundId) => String(roundId || "").trim()).filter(Boolean))); + const rowsByRoundId = new Map(selectedRoundIds.map((roundId) => [roundId, []])); + if (selectedRoundIds.length === 0) { + return rowsByRoundId; + } + + const longComponentStatePath = resolveFilePath(dataDir, longComponentStateFile); + ensureFileExists(longComponentStatePath, "long component state"); + const longSubmissionFiles = listFilesByPattern( + dataDir, + longSubmissionPattern, + "long submission" + ); + + const selectedRoundIdSet = new Set(selectedRoundIds); + const stateInfoById = new Map(); + await streamJsonArray(longComponentStatePath, "long_component_state", (row) => { + const roundId = String(row && row.round_id ? row.round_id : "").trim(); + if (!selectedRoundIdSet.has(roundId)) { + return; + } + const longComponentStateId = String( + row && row.long_component_state_id ? row.long_component_state_id : "" + ).trim(); + const coderId = String(row && row.coder_id ? row.coder_id : "").trim(); + if (!longComponentStateId || !coderId) { + return; + } + stateInfoById.set(longComponentStateId, { + legacyRoundId: roundId, + coderId, + }); + }); + + const generatedSubmissionOrdinalByStateId = new Map(); + await Promise.all( + longSubmissionFiles.map((filePath) => + streamJsonArray(filePath, "long_submission", (row) => { + const longComponentStateId = String( + row && row.long_component_state_id ? row.long_component_state_id : "" + ).trim(); + const stateInfo = stateInfoById.get(longComponentStateId); + if (!stateInfo) { + return; + } + + const isExample = String(row && row.example ? row.example : "").trim() === "1"; + if (isExample) { + return; + } + + const currentOrdinal = generatedSubmissionOrdinalByStateId.get(longComponentStateId) || 0; + const fallbackOrdinal = currentOrdinal + 1; + generatedSubmissionOrdinalByStateId.set(longComponentStateId, fallbackOrdinal); + const submissionNumber = + parsePositiveInteger(row && row.submission_number) || fallbackOrdinal; + + const legacySubmissionId = deriveLegacySubmissionId({ + longComponentStateId, + submissionNumber, + }); + rowsByRoundId.get(stateInfo.legacyRoundId).push({ + legacyRoundId: stateInfo.legacyRoundId, + coderId: stateInfo.coderId, + longComponentStateId, + submissionNumber, + submitTimeMs: parseEpochMs(row && row.submit_time), + submittedDate: parseEpochMs(row && row.submit_time) + ? new Date(parseEpochMs(row && row.submit_time)) + : null, + legacySubmissionId, + }); + }) + ) + ); + + rowsByRoundId.forEach((rows, roundId) => { + const sortedRows = [...rows].sort(compareSubmissionRows); + rowsByRoundId.set(roundId, sortedRows); + }); + + return rowsByRoundId; +}; + +const formatImportedCountsByMemberId = (countsByMemberId) => + Object.fromEntries( + Array.from(countsByMemberId.entries()).sort(([left], [right]) => + String(left).localeCompare(String(right), undefined, { numeric: true }) + ) + ); + +const createReviewSubmissionStore = async ({ + reviewClient, + reviewSchema = DEFAULT_REVIEW_SCHEMA, + actor = "historical-mm-importer", +}) => { + if (!reviewClient || typeof reviewClient.$queryRawUnsafe !== "function") { + throw new Error("Review DB client with $queryRawUnsafe is required for submission import."); + } + + const schema = normalizeReviewSchema(reviewSchema); + const submissionTable = buildQualifiedTableName(schema, "submission"); + + const columnRows = await reviewClient.$queryRawUnsafe( + `SELECT column_name AS "columnName", + data_type AS "dataType", + udt_name AS "udtName" + FROM information_schema.columns + WHERE table_schema = $1 + AND table_name = 'submission'`, + schema + ); + const columnsByName = new Map( + (columnRows || []).map((columnRow) => [String(columnRow.columnName), columnRow]) + ); + + if (!columnsByName.has("challengeId") || !columnsByName.has("legacySubmissionId")) { + throw new Error( + `Review submission table ${schema}.submission must expose challengeId and legacySubmissionId columns.` + ); + } + + const toEnumCastExpression = ({ index, udtName }) => { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(udtName || ""))) { + throw new Error(`Unsupported enum type "${udtName}" for submission.type.`); + } + return `$${index}::"${schema}"."${udtName}"`; + }; + + const listExistingSubmissionsByLegacyId = async ({ challengeId }) => { + const selectedColumns = [`"legacySubmissionId"`]; + if (columnsByName.has("memberId")) { + selectedColumns.push(`"memberId"`); + } + if (columnsByName.has("submitter")) { + selectedColumns.push(`"submitter"`); + } + + const rows = await reviewClient.$queryRawUnsafe( + `SELECT ${selectedColumns.join(", ")} + FROM ${submissionTable} + WHERE "challengeId" = $1 + AND "legacySubmissionId" IS NOT NULL`, + challengeId + ); + + const byLegacyId = new Map(); + (rows || []).forEach((row) => { + const legacySubmissionId = String(row && row.legacySubmissionId ? row.legacySubmissionId : "").trim(); + if (!legacySubmissionId) { + return; + } + byLegacyId.set(legacySubmissionId, { + legacySubmissionId, + memberId: normalizeMemberId(row && row.memberId), + submitter: row && row.submitter ? String(row.submitter) : null, + }); + }); + return byLegacyId; + }; + + const createSubmission = async ({ + challengeId, + legacySubmissionId, + memberId, + memberHandle, + submittedDate, + }) => { + const normalizedLegacySubmissionId = String(legacySubmissionId || "").trim(); + if (!normalizedLegacySubmissionId) { + throw new Error("createSubmission requires legacySubmissionId."); + } + + const derivedId = crypto + .createHash("sha1") + .update(normalizedLegacySubmissionId) + .digest("hex") + .slice(0, 14); + + const columns = []; + const placeholders = []; + const values = []; + const pushColumn = (columnName, value, placeholderExpression = null) => { + columns.push(`"${columnName}"`); + values.push(value); + placeholders.push(placeholderExpression || `$${values.length}`); + }; + + if (columnsByName.has("id")) { + pushColumn("id", derivedId); + } + pushColumn("challengeId", challengeId); + pushColumn("legacySubmissionId", normalizedLegacySubmissionId); + if (columnsByName.has("memberId") && memberId) { + pushColumn("memberId", String(memberId)); + } + if (columnsByName.has("submitter")) { + pushColumn("submitter", memberHandle || null); + } + if (columnsByName.has("submittedDate") && submittedDate) { + pushColumn("submittedDate", submittedDate); + } + if (columnsByName.has("isExample")) { + pushColumn("isExample", false); + } + if (columnsByName.has("createdBy")) { + pushColumn("createdBy", actor); + } + if (columnsByName.has("updatedBy")) { + pushColumn("updatedBy", actor); + } + if (columnsByName.has("type")) { + const typeColumn = columnsByName.get("type"); + const placeholderExpression = + String(typeColumn.dataType || "").toUpperCase() === "USER-DEFINED" + ? toEnumCastExpression({ index: values.length + 1, udtName: typeColumn.udtName }) + : `$${values.length + 1}`; + pushColumn("type", CONTEST_SUBMISSION_TYPE, placeholderExpression); + } + if (columnsByName.has("status")) { + const statusColumn = columnsByName.get("status"); + const placeholderExpression = + String(statusColumn.dataType || "").toUpperCase() === "USER-DEFINED" + ? toEnumCastExpression({ index: values.length + 1, udtName: statusColumn.udtName }) + : `$${values.length + 1}`; + pushColumn("status", ACTIVE_SUBMISSION_STATUS, placeholderExpression); + } + + await reviewClient.$queryRawUnsafe( + `INSERT INTO ${submissionTable} (${columns.join(", ")}) + VALUES (${placeholders.join(", ")})`, + ...values + ); + }; + + return { + listExistingSubmissionsByLegacyId, + createSubmission, + }; +}; + +const reconcileRoundSubmissionHistory = async ({ + roundId, + challengeId, + rowsByRoundId, + normalizedIdentityByCoderId, + missingMemberSubmissionSkipMemberIds = new Set(), + submissionStore, +}) => { + if (!submissionStore) { + throw new Error("submissionStore is required for submission reconciliation."); + } + + const legacyRows = rowsByRoundId.get(roundId) || []; + const existingByLegacySubmissionId = await submissionStore.listExistingSubmissionsByLegacyId({ + challengeId, + }); + + let createdSubmissions = 0; + let alreadyPresentSubmissions = 0; + let missingMemberSkippedSubmissions = 0; + const importedCountsByMemberId = new Map(); + const importedMemberIds = new Set(); + const missingMemberIds = new Set(); + const skippedSubmissionRecords = []; + const missingMemberIdsSet = + missingMemberSubmissionSkipMemberIds instanceof Set + ? new Set(Array.from(missingMemberSubmissionSkipMemberIds).map((memberId) => normalizeMemberId(memberId)).filter(Boolean)) + : new Set(); + + const incrementImportedCount = (memberId) => { + importedMemberIds.add(memberId); + importedCountsByMemberId.set(memberId, (importedCountsByMemberId.get(memberId) || 0) + 1); + }; + + for (const row of legacyRows) { + const identity = resolveIdentityForCoderId(row.coderId, normalizedIdentityByCoderId); + const memberId = normalizeMemberId(identity && identity.memberId); + const memberHandle = identity && identity.memberHandle ? identity.memberHandle : null; + if (!memberId || missingMemberIdsSet.has(memberId)) { + missingMemberSkippedSubmissions += 1; + if (memberId) { + missingMemberIds.add(memberId); + } + skippedSubmissionRecords.push({ + legacyRoundId: roundId, + memberId: memberId || String(row.coderId || "").trim(), + memberHandle: memberHandle || undefined, + coderIds: [String(row.coderId || "").trim()].filter(Boolean), + reasonCode: MISSING_MEMBER_REASON_CODE, + affectedSurfaces: ["submission"], + legacySubmissionId: row.legacySubmissionId, + counts: { + submission: 1, + }, + }); + continue; + } + + const existing = existingByLegacySubmissionId.get(row.legacySubmissionId); + if (existing) { + const existingMemberId = normalizeMemberId(existing.memberId); + if (existingMemberId && existingMemberId !== memberId) { + throw new Error( + `Existing submission legacySubmissionId "${row.legacySubmissionId}" is linked to memberId ${existingMemberId} but legacy coder ${row.coderId} resolves to memberId ${memberId}.` + ); + } + alreadyPresentSubmissions += 1; + incrementImportedCount(memberId); + continue; + } + + await submissionStore.createSubmission({ + challengeId, + legacySubmissionId: row.legacySubmissionId, + memberId, + memberHandle, + submittedDate: row.submittedDate, + }); + existingByLegacySubmissionId.set(row.legacySubmissionId, { + legacySubmissionId: row.legacySubmissionId, + memberId, + submitter: memberHandle, + }); + createdSubmissions += 1; + incrementImportedCount(memberId); + } + + return { + legacyNonExampleSubmissions: legacyRows.length, + importedSubmissions: createdSubmissions + alreadyPresentSubmissions, + alreadyPresentSubmissions, + createdSubmissions, + missingMemberSkippedSubmissions, + importedDistinctSubmitters: importedMemberIds.size, + missingMemberDistinctSubmitters: missingMemberIds.size, + importedSubmissionCountsByMemberId: formatImportedCountsByMemberId(importedCountsByMemberId), + skippedSubmissionRecords, + }; +}; + +module.exports = { + CONTEST_SUBMISSION_TYPE, + ACTIVE_SUBMISSION_STATUS, + DEFAULT_REVIEW_SCHEMA, + deriveLegacySubmissionId, + loadNonExampleLegacySubmissionRowsByRoundId, + createReviewSubmissionStore, + reconcileRoundSubmissionHistory, +}; diff --git a/data-migration/test/importHistoricalMarathonMatches.applySubmissions.test.js b/data-migration/test/importHistoricalMarathonMatches.applySubmissions.test.js new file mode 100644 index 0000000..fa85f8f --- /dev/null +++ b/data-migration/test/importHistoricalMarathonMatches.applySubmissions.test.js @@ -0,0 +1,214 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + runApplyMode, +} = require("../src/scripts/importHistoricalMarathonMatches/apply"); + +const writeJson = (baseDir, fileName, rootKey, rows) => { + fs.writeFileSync( + path.join(baseDir, fileName), + `${JSON.stringify({ [rootKey]: rows }, null, 2)}\n`, + "utf8" + ); +}; + +describe("importHistoricalMarathonMatches apply mode submission-history wiring", () => { + let fixtureDir; + + beforeEach(() => { + fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-apply-submissions-fixture-")); + writeJson(fixtureDir, "long_component_state_1.json", "long_component_state", [ + { long_component_state_id: "1001", round_id: "9892", coder_id: "1", component_id: "5503" }, + { long_component_state_id: "1002", round_id: "9892", coder_id: "2", component_id: "5503" }, + ]); + writeJson(fixtureDir, "long_submission_1.json", "long_submission", [ + { long_component_state_id: "1001", submission_number: "1", example: "0", submit_time: "1000" }, + { long_component_state_id: "1002", submission_number: "1", example: "0", submit_time: "1001" }, + ]); + }); + + afterEach(() => { + fs.rmSync(fixtureDir, { recursive: true, force: true }); + }); + + test("apply-mode creates resolvable submissions and appends per-submission missing-member skips", async () => { + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({ id: "challenge-1" }), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([]), + createMany: jest.fn(), + }, + }; + + const phaseRows = [ + { id: "phase-registration", name: "Registration" }, + { id: "phase-submission", name: "Submission" }, + { id: "phase-review", name: "Review" }, + ]; + + const prisma = { + challengeType: { + findMany: jest.fn().mockResolvedValue([{ id: "type-mm" }]), + }, + challengeTrack: { + findMany: jest.fn().mockResolvedValue([{ id: "track-ds" }]), + }, + phase: { + findMany: jest + .fn() + .mockResolvedValueOnce(phaseRows) + .mockResolvedValueOnce(phaseRows), + }, + challengeTimelineTemplate: { + findMany: jest.fn().mockResolvedValue([ + { + timelineTemplateId: "timeline-mm", + isDefault: true, + timelineTemplate: { + phases: [ + { phaseId: "phase-registration" }, + { phaseId: "phase-submission" }, + { phaseId: "phase-review" }, + ], + }, + }, + ]), + }, + $transaction: async (callback) => callback(tx), + }; + + const submissionStoreRecords = new Map(); + const submissionStore = { + listExistingSubmissionsByLegacyId: async () => new Map(submissionStoreRecords), + createSubmission: async ({ legacySubmissionId, memberId, submitter }) => { + submissionStoreRecords.set(legacySubmissionId, { + legacySubmissionId, + memberId: String(memberId), + submitter: submitter || null, + }); + }, + }; + + const skippedFilePath = path.join(fixtureDir, "apply-skipped.json"); + const result = await runApplyMode({ + prisma, + options: { + roundIds: ["9892"], + skippedFilePath, + importSubmissions: true, + submissionStore, + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + resourceClient: { + listSubmitterResources: jest.fn().mockResolvedValue([]), + createSubmitterResource: jest.fn().mockResolvedValue({}), + }, + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "create", + reason: "no-matching-v6-challenge-found", + plannedSkipRecords: [ + { + legacyRoundId: "9892", + memberId: "2", + reasonCode: "missing-member", + affectedSurfaces: ["resource", "submission"], + }, + ], + }, + ], + roundDataById: new Map([ + [ + "9892", + { + round: { round_id: "9892", round_type_id: "13", name: "MM 9892" }, + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2"]), + nonExampleSubmissions: 2, + nonExampleSubmitterCoderIds: new Set(["1", "2"]), + finalCandidateCoderIds: new Set(), + }, + ], + ]), + }, + actor: "importer", + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ]), + }); + + expect(result.records).toEqual([ + expect.objectContaining({ + recordType: "apply-record", + legacyRoundId: "9892", + status: "created", + challengeId: "challenge-1", + submissionReconciliation: { + legacyNonExampleSubmissions: 2, + importedSubmissions: 1, + alreadyPresentSubmissions: 0, + createdSubmissions: 1, + missingMemberSkippedSubmissions: 1, + importedDistinctSubmitters: 1, + missingMemberDistinctSubmitters: 1, + importedSubmissionCountsByMemberId: { + 1: 1, + }, + skippedSubmissionRecords: [ + expect.objectContaining({ + legacyRoundId: "9892", + memberId: "2", + reasonCode: "missing-member", + legacySubmissionId: "10020001", + affectedSurfaces: ["submission"], + }), + ], + }, + }), + ]); + + expect(result.summary).toEqual( + expect.objectContaining({ + skippedFileArtifact: { + path: skippedFilePath, + reasonCodes: ["missing-member"], + recordCount: 2, + }, + }) + ); + + const artifact = JSON.parse(fs.readFileSync(skippedFilePath, "utf8")); + expect(artifact.reasonCodes).toEqual(["missing-member"]); + expect(artifact.records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + legacyRoundId: "9892", + memberId: "2", + reasonCode: "missing-member", + affectedSurfaces: ["resource", "submission"], + }), + expect.objectContaining({ + legacyRoundId: "9892", + memberId: "2", + reasonCode: "missing-member", + affectedSurfaces: ["submission"], + legacySubmissionId: "10020001", + }), + ]) + ); + }); +}); diff --git a/data-migration/test/importHistoricalMarathonMatches.submissionHistory.test.js b/data-migration/test/importHistoricalMarathonMatches.submissionHistory.test.js new file mode 100644 index 0000000..6da6ac7 --- /dev/null +++ b/data-migration/test/importHistoricalMarathonMatches.submissionHistory.test.js @@ -0,0 +1,214 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + loadNonExampleLegacySubmissionRowsByRoundId, + reconcileRoundSubmissionHistory, +} = require("../src/scripts/importHistoricalMarathonMatches/submissionHistory"); + +const writeJson = (baseDir, fileName, rootKey, rows) => { + fs.writeFileSync( + path.join(baseDir, fileName), + `${JSON.stringify({ [rootKey]: rows }, null, 2)}\n`, + "utf8" + ); +}; + +const createFixtureDataDirectory = () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-submission-history-fixture-")); + + writeJson(baseDir, "long_component_state_1.json", "long_component_state", [ + { long_component_state_id: "1001", round_id: "9892", coder_id: "1", component_id: "5503" }, + { long_component_state_id: "1002", round_id: "9892", coder_id: "2", component_id: "5503" }, + ]); + writeJson(baseDir, "long_submission_1.json", "long_submission", [ + { long_component_state_id: "1001", submission_number: "1", example: "0", submit_time: "1000" }, + { long_component_state_id: "1001", submission_number: "2", example: "1", submit_time: "1001" }, + { long_component_state_id: "1001", submission_number: "3", example: "0", submit_time: "1002" }, + { long_component_state_id: "1002", submission_number: "1", example: "0", submit_time: "1003" }, + ]); + + return baseDir; +}; + +const createInMemorySubmissionStore = () => { + const byChallengeId = new Map(); + return { + listExistingSubmissionsByLegacyId: async ({ challengeId }) => + new Map(byChallengeId.get(challengeId) || []), + createSubmission: async ({ challengeId, legacySubmissionId, memberId, memberHandle, submittedDate }) => { + if (!byChallengeId.has(challengeId)) { + byChallengeId.set(challengeId, new Map()); + } + byChallengeId.get(challengeId).set(legacySubmissionId, { + legacySubmissionId, + memberId: String(memberId), + submitter: memberHandle || null, + submittedDate: submittedDate ? submittedDate.toISOString() : null, + }); + }, + }; +}; + +describe("importHistoricalMarathonMatches submission history", () => { + let fixtureDir; + + beforeEach(() => { + fixtureDir = createFixtureDataDirectory(); + }); + + afterEach(() => { + fs.rmSync(fixtureDir, { recursive: true, force: true }); + }); + + test("loads non-example rows only and derives deterministic legacySubmissionId values", async () => { + const rowsByRoundId = await loadNonExampleLegacySubmissionRowsByRoundId({ + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + roundIds: ["9892"], + }); + + expect(rowsByRoundId.get("9892")).toEqual([ + expect.objectContaining({ + legacyRoundId: "9892", + coderId: "1", + longComponentStateId: "1001", + submissionNumber: 1, + legacySubmissionId: "10010001", + }), + expect.objectContaining({ + legacyRoundId: "9892", + coderId: "1", + longComponentStateId: "1001", + submissionNumber: 3, + legacySubmissionId: "10010003", + }), + expect.objectContaining({ + legacyRoundId: "9892", + coderId: "2", + longComponentStateId: "1002", + submissionNumber: 1, + legacySubmissionId: "10020001", + }), + ]); + }); + + test("imports resolvable rows, skips missing-member rows with submission identities, and is rerun-idempotent", async () => { + const rowsByRoundId = await loadNonExampleLegacySubmissionRowsByRoundId({ + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + roundIds: ["9892"], + }); + const submissionStore = createInMemorySubmissionStore(); + const normalizedIdentityByCoderId = new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ]); + + const firstRun = await reconcileRoundSubmissionHistory({ + roundId: "9892", + challengeId: "challenge-1", + rowsByRoundId, + normalizedIdentityByCoderId, + missingMemberSubmissionSkipMemberIds: new Set(["2"]), + submissionStore, + }); + + expect(firstRun).toEqual({ + legacyNonExampleSubmissions: 3, + importedSubmissions: 2, + alreadyPresentSubmissions: 0, + createdSubmissions: 2, + missingMemberSkippedSubmissions: 1, + importedDistinctSubmitters: 1, + missingMemberDistinctSubmitters: 1, + importedSubmissionCountsByMemberId: { + 1: 2, + }, + skippedSubmissionRecords: [ + expect.objectContaining({ + legacyRoundId: "9892", + memberId: "2", + reasonCode: "missing-member", + affectedSurfaces: ["submission"], + legacySubmissionId: "10020001", + counts: { + submission: 1, + }, + }), + ], + }); + + const secondRun = await reconcileRoundSubmissionHistory({ + roundId: "9892", + challengeId: "challenge-1", + rowsByRoundId, + normalizedIdentityByCoderId, + missingMemberSubmissionSkipMemberIds: new Set(["2"]), + submissionStore, + }); + + expect(secondRun).toEqual({ + legacyNonExampleSubmissions: 3, + importedSubmissions: 2, + alreadyPresentSubmissions: 2, + createdSubmissions: 0, + missingMemberSkippedSubmissions: 1, + importedDistinctSubmitters: 1, + missingMemberDistinctSubmitters: 1, + importedSubmissionCountsByMemberId: { + 1: 2, + }, + skippedSubmissionRecords: [ + expect.objectContaining({ + legacyRoundId: "9892", + memberId: "2", + reasonCode: "missing-member", + affectedSurfaces: ["submission"], + legacySubmissionId: "10020001", + counts: { + submission: 1, + }, + }), + ], + }); + }); + + test("fails when an existing legacySubmissionId is already attached to a different memberId", async () => { + const rowsByRoundId = await loadNonExampleLegacySubmissionRowsByRoundId({ + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + roundIds: ["9892"], + }); + const submissionStore = { + listExistingSubmissionsByLegacyId: async () => + new Map([ + [ + "10010001", + { legacySubmissionId: "10010001", memberId: "999", submitter: "wrong-member" }, + ], + ]), + createSubmission: jest.fn(), + }; + + await expect( + reconcileRoundSubmissionHistory({ + roundId: "9892", + challengeId: "challenge-1", + rowsByRoundId, + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ]), + missingMemberSubmissionSkipMemberIds: new Set(), + submissionStore, + }) + ).rejects.toThrow( + 'Existing submission legacySubmissionId "10010001" is linked to memberId 999 but legacy coder 1 resolves to memberId 1.' + ); + }); +}); From 8df25119a6fe9cf8988d423b487fd3392e6732e4 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 2 Apr 2026 17:47:30 +1100 Subject: [PATCH 18/27] PM-4686: block challenge activation for invalid billing accounts What was broken Challenges tied to projects could still be activated when the linked billing account was inactive, expired, or had no remaining budget. Root cause Activation validation only checked whether a project billing account id existed. It did not verify billing-account lifecycle state or remaining funds against the billing account data. What was changed Added billing-account detail lookup support in the project helper, including normalized active, end-date, status, and remaining-budget fields. Updated challenge activation validation to block project-backed launches when the billing account is missing, inactive, expired, not found, or has no remaining funds. Wired the billing account validation into challenge status updates while preserving the existing project billing-account persistence flow. Any added/updated tests Added focused unit coverage for the new activation billing-account validation paths. Updated challenge service tests to cover inactive, expired, insufficient-funds, and valid billing-account activation scenarios. --- config/default.js | 2 + src/common/project-helper.js | 126 +++++++++++- src/services/ChallengeService.js | 190 ++++++++++++++++-- test/unit/ChallengeService.test.js | 179 ++++++++++++++++- .../unit/challenge-activation-billing.test.js | 120 +++++++++++ 5 files changed, 594 insertions(+), 23 deletions(-) create mode 100644 test/unit/challenge-activation-billing.test.js diff --git a/config/default.js b/config/default.js index cb0d509..bafb8db 100644 --- a/config/default.js +++ b/config/default.js @@ -58,6 +58,8 @@ module.exports = { CUSTOMER_PAYMENTS_URL: process.env.CUSTOMER_PAYMENTS_URL || "https://api.topcoder-dev.com/v5/customer-payments", FINANCE_API_URL: process.env.FINANCE_API_URL || "http://localhost:8080", + BILLING_ACCOUNTS_API_URL: + process.env.BILLING_ACCOUNTS_API_URL || "http://localhost:4000/v6/billing-accounts", CHALLENGE_MIGRATION_APP_URL: process.env.CHALLENGE_MIGRATION_APP_URL || "https://api.topcoder.com/v5/challenge-migration", // copilot resource role ids allowed to upload attachment diff --git a/src/common/project-helper.js b/src/common/project-helper.js index 4c34843..4bd6f47 100644 --- a/src/common/project-helper.js +++ b/src/common/project-helper.js @@ -33,6 +33,64 @@ function normalizeBillingMarkup(rawMarkup) { return markup > 1 ? markup / 100 : markup; } +/** + * Normalizes optional billing-account string values returned by upstream APIs. + * + * @param {unknown} rawValue String-like value from Projects API or Billing Accounts API. + * @returns {string|null} Trimmed string or `null` when the value is empty. + */ +function normalizeOptionalString(rawValue) { + if (_.isNil(rawValue)) { + return null; + } + + const normalizedValue = _.toString(rawValue).trim(); + + return normalizedValue || null; +} + +/** + * Normalizes optional billing-account boolean values returned by upstream APIs. + * + * @param {unknown} rawValue Boolean-like value from Projects API or Billing Accounts API. + * @returns {boolean|null} Parsed boolean or `null` when the value cannot be resolved. + */ +function normalizeOptionalBoolean(rawValue) { + if (_.isBoolean(rawValue)) { + return rawValue; + } + + if (_.isString(rawValue)) { + const normalizedValue = rawValue.trim().toLowerCase(); + + if (normalizedValue === "true") { + return true; + } + + if (normalizedValue === "false") { + return false; + } + } + + return null; +} + +/** + * Normalizes optional billing-account numeric values returned by upstream APIs. + * + * @param {unknown} rawValue Number-like value from Billing Accounts API. + * @returns {number|null} Parsed finite number or `null` when the value is empty. + */ +function normalizeOptionalNumber(rawValue) { + if (_.isNil(rawValue) || rawValue === "") { + return null; + } + + const normalizedValue = _.toNumber(rawValue); + + return Number.isFinite(normalizedValue) ? normalizedValue : null; +} + class ProjectHelper { /** * Get Project Details. @@ -89,10 +147,13 @@ class ProjectHelper { } /** - * This functions gets the default billing account for a given project id + * Gets the default billing-account metadata for a project. * - * @param {Number} projectId The id of the project for which to get the default terms of use - * @returns {Promise} The billing account ID + * Returns the linked billing-account id and markup for challenge persistence, + * along with activity/expiry metadata used to validate challenge launches. + * + * @param {Number} projectId Project identifier whose default billing account should be fetched. + * @returns {Promise} Normalized billing-account fields resolved from Projects API. */ async getProjectBillingInformation(projectId) { const token = await m2mHelper.getM2MToken(); @@ -105,10 +166,14 @@ class ProjectHelper { logger.debug( `projectHelper.getProjectBillingInformation: response status ${res.status} for project ${projectId}` ); + const active = normalizeOptionalBoolean(_.get(res, "data.active", null)); + const endDate = normalizeOptionalString(_.get(res, "data.endDate", null)); return { billingAccountId: _.get(res, "data.tcBillingAccountId", null), markup: normalizeBillingMarkup(_.get(res, "data.markup", null)), + ...(active !== null ? { active } : {}), + ...(endDate ? { endDate } : {}), }; } catch (err) { const responseCode = _.get(err, "response.status"); @@ -128,6 +193,61 @@ class ProjectHelper { } } } + + /** + * Gets detailed billing-account metadata needed for launch validation. + * + * The Billing Accounts API is the source of truth for lifecycle status and + * remaining budget. Challenge launch validation uses this method to block + * launches for inactive, expired, or depleted billing accounts. + * + * @param {string|number} billingAccountId Billing-account identifier to fetch. + * @returns {Promise} Normalized billing-account details, or `null` when not found. + */ + async getBillingAccountDetails(billingAccountId) { + const normalizedBillingAccountId = normalizeOptionalString(billingAccountId); + + if (!normalizedBillingAccountId) { + return null; + } + + const token = await m2mHelper.getM2MToken(); + const url = `${config.BILLING_ACCOUNTS_API_URL}/${encodeURIComponent(normalizedBillingAccountId)}`; + logger.debug(`projectHelper.getBillingAccountDetails: GET ${url}`); + + try { + const res = await axios.get(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + logger.debug( + `projectHelper.getBillingAccountDetails: response status ${res.status} for billingAccountId ${normalizedBillingAccountId}` + ); + + return { + active: normalizeOptionalBoolean(_.get(res, "data.active", null)), + billingAccountId: + normalizeOptionalString(_.get(res, "data.id", null)) || normalizedBillingAccountId, + endDate: normalizeOptionalString(_.get(res, "data.endDate", null)), + status: normalizeOptionalString(_.get(res, "data.status", null)), + totalBudgetRemaining: normalizeOptionalNumber( + _.get(res, "data.totalBudgetRemaining", null) + ), + }; + } catch (err) { + const responseCode = _.get(err, "response.status"); + + if (responseCode === HttpStatus.NOT_FOUND) { + return null; + } + + logger.debug( + `projectHelper.getBillingAccountDetails: error for billingAccountId ${normalizedBillingAccountId} - status ${ + responseCode || "n/a" + }: ${err.message}` + ); + throw err; + } + } } module.exports = new ProjectHelper(); diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 30c62f7..223db69 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -2408,6 +2408,168 @@ function validateTask(currentUser, challenge, data, challengeResources) { } } +/** + * Normalizes optional text values used by launch validation. + * + * @param {unknown} value Raw upstream value. + * @returns {string|undefined} Trimmed string or `undefined` when empty. + */ +function normalizeOptionalString(value) { + if (_.isNil(value)) { + return undefined; + } + + const normalizedValue = _.toString(value).trim(); + + return normalizedValue || undefined; +} + +/** + * Normalizes optional numeric values used by launch validation. + * + * @param {unknown} value Raw upstream value. + * @returns {number|undefined} Parsed number or `undefined` when invalid. + */ +function normalizeOptionalNumber(value) { + if (_.isNil(value) || value === "") { + return undefined; + } + + const normalizedValue = _.toNumber(value); + + return Number.isFinite(normalizedValue) ? normalizedValue : undefined; +} + +/** + * Resolves billing-account activity from either a boolean field or textual + * status returned by upstream services. + * + * @param {object|undefined|null} billingAccount Billing-account metadata. + * @returns {boolean|undefined} Billing-account activity flag when available. + */ +function resolveBillingAccountActive(billingAccount) { + if (_.isBoolean(_.get(billingAccount, "active"))) { + return billingAccount.active; + } + + const normalizedStatus = normalizeOptionalString(_.get(billingAccount, "status")); + + if (!normalizedStatus) { + return undefined; + } + + if (normalizedStatus.toUpperCase() === "ACTIVE") { + return true; + } + + if (normalizedStatus.toUpperCase() === "INACTIVE") { + return false; + } + + return undefined; +} + +/** + * Determines whether a billing account should be treated as expired. + * + * @param {boolean|undefined} active Billing-account activity flag. + * @param {string|undefined} endDate Billing-account end date. + * @returns {boolean} `true` when the billing account has expired. + */ +function isBillingAccountExpired(active, endDate) { + if (active === false) { + return true; + } + + if (!endDate) { + return false; + } + + const endDateTimestamp = Date.parse(endDate); + + if (Number.isNaN(endDateTimestamp)) { + return false; + } + + return Date.now() >= endDateTimestamp; +} + +/** + * Validates the project billing account before activating a challenge. + * + * @param {object} params Validation inputs. + * @param {boolean|undefined|null} params.active Billing-account activity returned by Projects API. + * @param {string|number|null|undefined} params.billingAccountId Project billing-account identifier. + * @param {object} params.challenge Existing challenge model. + * @param {string|undefined|null} params.endDate Billing-account end date returned by Projects API. + * @returns {Promise} Resolves when the billing account can be used for launch. + * @throws {errors.BadRequestError} When the billing account is missing, inactive, expired, or depleted. + */ +async function validateChallengeActivationBillingAccount({ + active, + billingAccountId, + challenge, + endDate, +}) { + if (!challengeHelper.isProjectIdRequired(challenge.timelineTemplateId)) { + return; + } + + const normalizedBillingAccountId = normalizeOptionalString(billingAccountId); + + if (!normalizedBillingAccountId) { + throw new errors.BadRequestError( + "Cannot activate challenge because the project has no billing account." + ); + } + + const resolvedProjectActive = _.isBoolean(active) ? active : undefined; + const resolvedProjectEndDate = normalizeOptionalString(endDate); + + if (resolvedProjectActive === false) { + throw new errors.BadRequestError( + "Cannot activate challenge because the project billing account is inactive." + ); + } + + if (isBillingAccountExpired(resolvedProjectActive, resolvedProjectEndDate)) { + throw new errors.BadRequestError( + "Cannot activate challenge because the project billing account is expired." + ); + } + + const billingAccountDetails = await projectHelper.getBillingAccountDetails(normalizedBillingAccountId); + const resolvedActive = resolveBillingAccountActive(billingAccountDetails); + const resolvedEndDate = + normalizeOptionalString(_.get(billingAccountDetails, "endDate")); + + if (!billingAccountDetails) { + throw new errors.BadRequestError( + "Cannot activate challenge because the project billing account could not be found." + ); + } + + if (resolvedActive === false) { + throw new errors.BadRequestError( + "Cannot activate challenge because the project billing account is inactive." + ); + } + + if (isBillingAccountExpired(resolvedActive, resolvedEndDate)) { + throw new errors.BadRequestError( + "Cannot activate challenge because the project billing account is expired." + ); + } + + const remainingBudget = normalizeOptionalNumber(billingAccountDetails.totalBudgetRemaining); + + if (!_.isNil(remainingBudget) && remainingBudget <= 0) { + throw new errors.BadRequestError( + "Cannot activate challenge because the project billing account has insufficient remaining funds." + ); + } +} + function prepareTaskCompletionData(challenge, challengeResources, data) { const isTask = helper.getTaskInfo(challenge).isTask || _.get(challenge, "legacy.pureV5Task"); const isCompleteTask = @@ -2500,14 +2662,19 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { // No conversion needed - values are already in dollars in the database - let projectId, billingAccountId, markup; + let projectId, billingAccountId, markup, billingAccountActive, billingAccountEndDate; if (challengeHelper.isProjectIdRequired(challenge.timelineTemplateId)) { projectId = _.get(challenge, "projectId"); logger.debug( `updateChallenge(${challengeId}): requesting billing information for project ${projectId}`, ); - ({ billingAccountId, markup } = await projectHelper.getProjectBillingInformation(projectId)); + ({ + billingAccountId, + markup, + active: billingAccountActive, + endDate: billingAccountEndDate, + } = await projectHelper.getProjectBillingInformation(projectId)); logger.debug( `updateChallenge(${challengeId}): billing lookup complete (hasAccount=${ billingAccountId != null @@ -2675,16 +2842,12 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { let isChallengeBeingCancelled = false; if (data.status) { if (data.status === ChallengeStatusEnum.ACTIVE) { - // if activating a challenge, the challenge must have a billing account id - if ( - (!billingAccountId || billingAccountId === null) && - challenge.status === ChallengeStatusEnum.DRAFT && - challengeHelper.isProjectIdRequired(challenge.timelineTemplateId) - ) { - throw new errors.BadRequestError( - "Cannot Activate this project, it has no active billing account.", - ); - } + await validateChallengeActivationBillingAccount({ + active: billingAccountActive, + billingAccountId, + challenge, + endDate: billingAccountEndDate, + }); } if ( @@ -4425,6 +4588,9 @@ async function indexChallengeAndPostToKafka(updatedChallenge, track, type) { } module.exports = { + __testables: { + validateChallengeActivationBillingAccount, + }, searchChallenges, createChallenge, getChallenge, diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index a4bb060..320a3b0 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -1072,6 +1072,35 @@ describe('challenge service unit tests', () => { }) } + const createProjectActivationChallenge = async (status = ChallengeStatusEnum.NEW) => { + return prisma.challenge.create({ + data: { + id: uuid(), + name: `Project activation check ${Date.now()}`, + description: 'project activation check', + typeId: data.challenge.typeId, + trackId: data.challenge.trackId, + timelineTemplateId: data.timelineTemplate.id, + projectId: 12345, + startDate: new Date(), + status, + tags: [], + groups: [], + createdBy: 'activation-test', + updatedBy: 'activation-test' + } + }) + } + + const buildActivationReviewers = () => ([ + { + phaseId: data.phase.id, + scorecardId: 'activation-scorecard', + isMemberReview: false, + aiWorkflowId: 'workflow-123' + } + ]) + it('update challenge successfully 1', async () => { const challengeData = testChallengeData const result = await service.updateChallenge({ isMachine: true, sub: 'sub3', userId: 22838965 }, id, { @@ -1606,14 +1635,7 @@ describe('challenge service unit tests', () => { activationChallenge.id, { status: ChallengeStatusEnum.ACTIVE, - reviewers: [ - { - phaseId: data.phase.id, - scorecardId: 'activation-scorecard', - isMemberReview: false, - aiWorkflowId: 'workflow-123' - } - ] + reviewers: buildActivationReviewers() } ) should.equal(updated.status, ChallengeStatusEnum.ACTIVE) @@ -1625,6 +1647,147 @@ describe('challenge service unit tests', () => { } }) + it('update challenge - prevent activating with an inactive project billing account', async () => { + const activationChallenge = await createProjectActivationChallenge(ChallengeStatusEnum.DRAFT) + const originalGetProjectBillingInformation = projectHelper.getProjectBillingInformation + + projectHelper.getProjectBillingInformation = async () => ({ + active: false, + billingAccountId: '80001061', + endDate: '2099-01-01T00:00:00.000Z', + markup: 0.25 + }) + + try { + await service.updateChallenge( + { isMachine: true, sub: 'sub-activate', userId: 22838965 }, + activationChallenge.id, + { + status: ChallengeStatusEnum.ACTIVE, + reviewers: buildActivationReviewers() + } + ) + } catch (e) { + should.equal(e.message, 'Cannot activate challenge because the project billing account is inactive.') + return + } finally { + projectHelper.getProjectBillingInformation = originalGetProjectBillingInformation + await prisma.challenge.delete({ where: { id: activationChallenge.id } }) + } + + throw new Error('should not reach here') + }) + + it('update challenge - prevent activating with an expired project billing account', async () => { + const activationChallenge = await createProjectActivationChallenge(ChallengeStatusEnum.DRAFT) + const originalGetProjectBillingInformation = projectHelper.getProjectBillingInformation + + projectHelper.getProjectBillingInformation = async () => ({ + active: true, + billingAccountId: '80001061', + endDate: '2000-01-01T00:00:00.000Z', + markup: 0.25 + }) + + try { + await service.updateChallenge( + { isMachine: true, sub: 'sub-activate', userId: 22838965 }, + activationChallenge.id, + { + status: ChallengeStatusEnum.ACTIVE, + reviewers: buildActivationReviewers() + } + ) + } catch (e) { + should.equal(e.message, 'Cannot activate challenge because the project billing account is expired.') + return + } finally { + projectHelper.getProjectBillingInformation = originalGetProjectBillingInformation + await prisma.challenge.delete({ where: { id: activationChallenge.id } }) + } + + throw new Error('should not reach here') + }) + + it('update challenge - prevent activating with insufficient project billing funds', async () => { + const activationChallenge = await createProjectActivationChallenge(ChallengeStatusEnum.DRAFT) + const originalGetProjectBillingInformation = projectHelper.getProjectBillingInformation + const originalGetBillingAccountDetails = projectHelper.getBillingAccountDetails + + projectHelper.getProjectBillingInformation = async () => ({ + active: true, + billingAccountId: '80001061', + endDate: '2099-01-01T00:00:00.000Z', + markup: 0.25 + }) + projectHelper.getBillingAccountDetails = async () => ({ + active: true, + billingAccountId: '80001061', + endDate: '2099-01-01T00:00:00.000Z', + status: 'ACTIVE', + totalBudgetRemaining: 0 + }) + + try { + await service.updateChallenge( + { isMachine: true, sub: 'sub-activate', userId: 22838965 }, + activationChallenge.id, + { + status: ChallengeStatusEnum.ACTIVE, + reviewers: buildActivationReviewers() + } + ) + } catch (e) { + should.equal( + e.message, + 'Cannot activate challenge because the project billing account has insufficient remaining funds.' + ) + return + } finally { + projectHelper.getProjectBillingInformation = originalGetProjectBillingInformation + projectHelper.getBillingAccountDetails = originalGetBillingAccountDetails + await prisma.challenge.delete({ where: { id: activationChallenge.id } }) + } + + throw new Error('should not reach here') + }) + + it('update challenge - allow activating with a valid project billing account', async () => { + const activationChallenge = await createProjectActivationChallenge(ChallengeStatusEnum.DRAFT) + const originalGetProjectBillingInformation = projectHelper.getProjectBillingInformation + const originalGetBillingAccountDetails = projectHelper.getBillingAccountDetails + + projectHelper.getProjectBillingInformation = async () => ({ + active: true, + billingAccountId: '80001061', + endDate: '2099-01-01T00:00:00.000Z', + markup: 0.25 + }) + projectHelper.getBillingAccountDetails = async () => ({ + active: true, + billingAccountId: '80001061', + endDate: '2099-01-01T00:00:00.000Z', + status: 'ACTIVE', + totalBudgetRemaining: 150 + }) + + try { + const updated = await service.updateChallenge( + { isMachine: true, sub: 'sub-activate', userId: 22838965 }, + activationChallenge.id, + { + status: ChallengeStatusEnum.ACTIVE, + reviewers: buildActivationReviewers() + } + ) + should.equal(updated.status, ChallengeStatusEnum.ACTIVE) + } finally { + projectHelper.getProjectBillingInformation = originalGetProjectBillingInformation + projectHelper.getBillingAccountDetails = originalGetBillingAccountDetails + await prisma.challenge.delete({ where: { id: activationChallenge.id } }) + } + }) + it('update challenge - prevent activating when reviewer is missing required fields', async () => { const activationChallenge = await createActivationChallenge() await prisma.challengeReviewer.create({ diff --git a/test/unit/challenge-activation-billing.test.js b/test/unit/challenge-activation-billing.test.js new file mode 100644 index 0000000..b5bb32f --- /dev/null +++ b/test/unit/challenge-activation-billing.test.js @@ -0,0 +1,120 @@ +if (!process.env.REVIEW_DB_URL && process.env.DATABASE_URL) { + process.env.REVIEW_DB_URL = process.env.DATABASE_URL; +} + +require("../../app-bootstrap"); +const chai = require("chai"); +const service = require("../../src/services/ChallengeService"); +const projectHelper = require("../../src/common/project-helper"); +const { ChallengeStatusEnum } = require("../../src/common/prisma"); + +const should = chai.should(); + +describe("challenge activation billing validation unit tests", () => { + const validateChallengeActivationBillingAccount = + service.__testables.validateChallengeActivationBillingAccount; + const projectChallenge = { + status: ChallengeStatusEnum.DRAFT, + timelineTemplateId: "project-required-template", + }; + const originalGetBillingAccountDetails = projectHelper.getBillingAccountDetails; + + afterEach(() => { + projectHelper.getBillingAccountDetails = originalGetBillingAccountDetails; + }); + + it("prevents activation when the project has no billing account", async () => { + try { + await validateChallengeActivationBillingAccount({ + billingAccountId: null, + challenge: projectChallenge, + }); + } catch (error) { + should.equal(error.message, "Cannot activate challenge because the project has no billing account."); + return; + } + + throw new Error("should not reach here"); + }); + + it("prevents activation when the project billing account is inactive", async () => { + try { + await validateChallengeActivationBillingAccount({ + active: false, + billingAccountId: "80001061", + challenge: projectChallenge, + }); + } catch (error) { + should.equal( + error.message, + "Cannot activate challenge because the project billing account is inactive." + ); + return; + } + + throw new Error("should not reach here"); + }); + + it("prevents activation when the project billing account is expired", async () => { + try { + await validateChallengeActivationBillingAccount({ + active: true, + billingAccountId: "80001061", + challenge: projectChallenge, + endDate: "2000-01-01T00:00:00.000Z", + }); + } catch (error) { + should.equal( + error.message, + "Cannot activate challenge because the project billing account is expired." + ); + return; + } + + throw new Error("should not reach here"); + }); + + it("prevents activation when the project billing account has no remaining funds", async () => { + projectHelper.getBillingAccountDetails = async () => ({ + active: true, + billingAccountId: "80001061", + endDate: "2099-01-01T00:00:00.000Z", + status: "ACTIVE", + totalBudgetRemaining: 0, + }); + + try { + await validateChallengeActivationBillingAccount({ + active: true, + billingAccountId: "80001061", + challenge: projectChallenge, + endDate: "2099-01-01T00:00:00.000Z", + }); + } catch (error) { + should.equal( + error.message, + "Cannot activate challenge because the project billing account has insufficient remaining funds." + ); + return; + } + + throw new Error("should not reach here"); + }); + + it("allows activation when the billing account is active, unexpired, and funded", async () => { + projectHelper.getBillingAccountDetails = async () => ({ + active: true, + billingAccountId: "80001061", + endDate: "2099-01-01T00:00:00.000Z", + status: "ACTIVE", + totalBudgetRemaining: 150, + }); + + await validateChallengeActivationBillingAccount({ + active: true, + billingAccountId: "80001061", + challenge: projectChallenge, + endDate: "2099-01-01T00:00:00.000Z", + }); + }); +}); From dde73170d496dcb31d93a83e29208efd35736aab Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 2 Apr 2026 20:22:59 +1100 Subject: [PATCH 19/27] Finalize historical MM final-score reconciliation and fixture selection --- .factory/library/legacy-data.md | 7 +- .../importHistoricalMarathonMatches.js | 1 + .../importHistoricalMarathonMatches/apply.js | 101 +++ .../finalScores.js | 601 ++++++++++++++++++ ...alMarathonMatches.applyFinalScores.test.js | 210 ++++++ ...toricalMarathonMatches.finalScores.test.js | 253 ++++++++ 6 files changed, 1170 insertions(+), 3 deletions(-) create mode 100644 data-migration/src/scripts/importHistoricalMarathonMatches/finalScores.js create mode 100644 data-migration/test/importHistoricalMarathonMatches.applyFinalScores.test.js create mode 100644 data-migration/test/importHistoricalMarathonMatches.finalScores.test.js diff --git a/.factory/library/legacy-data.md b/.factory/library/legacy-data.md index 67312ae..f946a46 100644 --- a/.factory/library/legacy-data.md +++ b/.factory/library/legacy-data.md @@ -64,10 +64,11 @@ Use this legacy relationship when deriving participant/submission/final-score da ## Fixture Rounds -- `10815`: `836` eligible registrations, `1445` non-example submissions, `2424` example submissions, `267` submitters with non-example history, and `16` unattachable finalists; use as the primary missing-historical create-path fixture -- a score-rich Marathon Match round should be selected during score-feature work; do not assume `10089` remains a valid Marathon Match fixture in the current validation environment without reconfirmation +- `10815`: `836` eligible registrations, `1445` non-example submissions, `2424` example submissions, `267` submitters with non-example history, and fallback-heavy final-score behavior; in the current target-member snapshot this round plans `283` final candidates split into `266` importable finals, `2` missing-member final skips, and `15` explicit `finalist-without-attachable-submission` skips. Treat this as the selected unattachable-finalists fixture for score validation. +- `17948`: selected score-rich Marathon Match fixture for final-score validation. Current planning/apply evidence for this round yields `81` legacy final candidates with `45` importable finals, `36` `missing-member` final skips, and `0` explicit `finalist-without-attachable-submission` skips. Imported finals on this fixture are `system_point_total`-backed and preserve legacy placement order when sorted by aggregate score descending after excluding missing-member finalists. +- `13897`: remains a useful large MM backfill fixture, but it is **not** the selected score-rich placement fixture because it currently includes `33` explicit `finalist-without-attachable-submission` skips. - `14272`: second selected-round filter fixture; current validation guidance treats it as an unresolved/non-Marathon-Match round rather than an importable Marathon Match target -- an edge-case Marathon Match round with unattachable finalists should be selected during score-feature work; do not assume `10722` remains valid in the current validation environment without reconfirmation +- `10089` and `10722` remain non-Marathon in current planning and should not be used as Marathon Match score fixtures. ## Existing-State Snapshot File (`--existing-state-file`) diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches.js b/data-migration/src/scripts/importHistoricalMarathonMatches.js index 5410143..83899af 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches.js @@ -247,6 +247,7 @@ const run = async () => { reviewClient: reviewPrisma, reviewSchema: reviewDbSchema, importSubmissions: true, + importFinalScores: true, }, plan, actor: DEFAULT_ACTOR, diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js index aed4fd7..23381ba 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js @@ -11,6 +11,7 @@ const { collectReasonCodes, writeSkippedArtifact, MISSING_MEMBER_REASON_CODE, + FINALIST_WITHOUT_ATTACHABLE_SUBMISSION_REASON_CODE, } = require("./skippedArtifact"); const { DEFAULT_REVIEW_SCHEMA, @@ -18,6 +19,11 @@ const { createReviewSubmissionStore, reconcileRoundSubmissionHistory, } = require("./submissionHistory"); +const { + loadLegacyFinalRowsByRoundId, + createReviewFinalScoreStore, + reconcileRoundFinalScores, +} = require("./finalScores"); const STANDARD_PHASE_NAMES = ["Registration", "Submission", "Review"]; const DEFAULT_SUBMITTER_ROLE_ID = "732339e7-8e30-49d7-9198-cccf9451e221"; @@ -514,6 +520,43 @@ const collectMissingMemberSkipMemberIdsByRoundId = ({ return byRoundId; }; +const collectSkipMemberIdsByRoundId = ({ + roundIds, + planRecordByRoundId, + reasonCode, + affectedSurface, +}) => { + const byRoundId = new Map(); + const normalizedReasonCode = String(reasonCode || "").trim(); + + roundIds.forEach((roundId) => { + const planRecord = planRecordByRoundId.get(roundId); + const skipMemberIds = new Set(); + + if (planRecord && Array.isArray(planRecord.plannedSkipRecords)) { + planRecord.plannedSkipRecords.forEach((record) => { + const candidateReasonCode = String( + record && record.reasonCode ? record.reasonCode : "" + ).trim(); + if (candidateReasonCode !== normalizedReasonCode) { + return; + } + if (!hasAffectedSurface(record, affectedSurface)) { + return; + } + const memberId = parseMemberId(record && record.memberId); + if (memberId) { + skipMemberIds.add(memberId); + } + }); + } + + byRoundId.set(roundId, skipMemberIds); + }); + + return byRoundId; +}; + const runApplyMode = async ({ prisma, options, @@ -540,6 +583,18 @@ const runApplyMode = async ({ planRecordByRoundId, affectedSurface: "submission", }); + const missingMemberFinalSkipMemberIdsByRoundId = + collectMissingMemberSkipMemberIdsByRoundId({ + roundIds: options.roundIds, + planRecordByRoundId, + affectedSurface: "final-score", + }); + const plannedUnattachableFinalSkipMemberIdsByRoundId = collectSkipMemberIdsByRoundId({ + roundIds: options.roundIds, + planRecordByRoundId, + reasonCode: FINALIST_WITHOUT_ATTACHABLE_SUBMISSION_REASON_CODE, + affectedSurface: "final-score", + }); let skippedArtifact = writeSkippedArtifact({ filePath: skippedFilePath, selectedRoundIds: options.roundIds, @@ -560,6 +615,7 @@ const runApplyMode = async ({ }); const submitterRoleId = String(options.submitterRoleId || DEFAULT_SUBMITTER_ROLE_ID).trim(); const submissionImportEnabled = options.importSubmissions === true; + const finalScoreImportEnabled = options.importFinalScores === true; const resourceClient = options.resourceClient; if (actionableRoundIds.length > 0 && !resourceClient) { @@ -570,6 +626,16 @@ const runApplyMode = async ({ "Review DB client is required for apply mode submission-history reconciliation." ); } + if ( + actionableRoundIds.length > 0 && + finalScoreImportEnabled && + !options.reviewClient && + !options.finalScoreStore + ) { + throw new Error( + "Review DB client is required for apply mode final-score reconciliation." + ); + } const challengeStatusController = options.challengeStatusController || createPrismaChallengeStatusController({ prisma, actor }); @@ -619,7 +685,9 @@ const runApplyMode = async ({ } let roundSubmissionRowsByRoundId = new Map(); + let roundFinalRowsByRoundId = new Map(); let submissionStore = null; + let finalScoreStore = null; if (submissionImportEnabled && actionableRoundIds.length > 0) { roundSubmissionRowsByRoundId = await loadNonExampleLegacySubmissionRowsByRoundId({ dataDir: options.dataDir, @@ -635,6 +703,21 @@ const runApplyMode = async ({ actor, })); } + if (finalScoreImportEnabled && actionableRoundIds.length > 0) { + roundFinalRowsByRoundId = await loadLegacyFinalRowsByRoundId({ + dataDir: options.dataDir, + longComponentStateFile: options.longComponentStateFile, + longCompResultPattern: options.longCompResultPattern, + roundIds: actionableRoundIds, + }); + finalScoreStore = + options.finalScoreStore || + (await createReviewFinalScoreStore({ + reviewClient: options.reviewClient, + reviewSchema: options.reviewSchema || DEFAULT_REVIEW_SCHEMA, + actor, + })); + } let marathonTypeId = null; let dataScienceTrackId = null; @@ -717,6 +800,23 @@ const runApplyMode = async ({ if (submissionReconciliation && Array.isArray(submissionReconciliation.skippedSubmissionRecords)) { runtimeSkipRecords.push(...submissionReconciliation.skippedSubmissionRecords); } + const finalScoreReconciliation = + finalScoreImportEnabled && finalScoreStore + ? await reconcileRoundFinalScores({ + roundId, + challengeId: result.challengeId, + finalRowsByRoundId: roundFinalRowsByRoundId, + normalizedIdentityByCoderId, + missingMemberFinalSkipMemberIds: + missingMemberFinalSkipMemberIdsByRoundId.get(roundId) || new Set(), + plannedUnattachableFinalSkipMemberIds: + plannedUnattachableFinalSkipMemberIdsByRoundId.get(roundId) || new Set(), + finalScoreStore, + }) + : null; + if (finalScoreReconciliation && Array.isArray(finalScoreReconciliation.runtimeSkipRecords)) { + runtimeSkipRecords.push(...finalScoreReconciliation.runtimeSkipRecords); + } applyRecords.push({ recordType: "apply-record", legacyRoundId: roundId, @@ -724,6 +824,7 @@ const runApplyMode = async ({ challengeId: result.challengeId, resourceReconciliation, ...(submissionReconciliation ? { submissionReconciliation } : {}), + ...(finalScoreReconciliation ? { finalScoreReconciliation } : {}), }); } catch (error) { applyRecords.push({ diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/finalScores.js b/data-migration/src/scripts/importHistoricalMarathonMatches/finalScores.js new file mode 100644 index 0000000..af4efb5 --- /dev/null +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/finalScores.js @@ -0,0 +1,601 @@ +"use strict"; + +const { + ensureFileExists, + listFilesByPattern, + resolveFilePath, + streamJsonArray, +} = require("./legacyDataReader"); +const { + MISSING_MEMBER_REASON_CODE, + FINALIST_WITHOUT_ATTACHABLE_SUBMISSION_REASON_CODE, +} = require("./skippedArtifact"); + +const DEFAULT_REVIEW_SCHEMA = "reviews"; + +const normalizeReviewSchema = (value) => { + const normalized = String(value || DEFAULT_REVIEW_SCHEMA).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(normalized)) { + throw new Error(`Invalid REVIEW_DB_SCHEMA "${normalized}"`); + } + return normalized; +}; + +const buildQualifiedTableName = (schemaName, tableName) => + `"${String(schemaName).replace(/"/g, "\"\"")}"."${String(tableName).replace(/"/g, "\"\"")}"`; + +const parsePositiveInteger = (value) => { + const parsed = Number.parseInt(String(value || "").trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +}; + +const normalizeMemberId = (value) => { + const parsed = parsePositiveInteger(value); + if (!parsed) { + return null; + } + return String(parsed); +}; + +const parseNumericScore = (value) => { + if (value === null || value === undefined) { + return null; + } + const normalized = String(value).trim(); + if (!normalized || normalized.toLowerCase() === "null") { + return null; + } + const parsed = Number.parseFloat(normalized); + if (!Number.isFinite(parsed)) { + return null; + } + return parsed; +}; + +const parsePlacement = (value) => { + const parsed = parsePositiveInteger(value); + return parsed || null; +}; + +const hasAnyFinalSignal = (finalResultRow) => { + const candidates = [ + finalResultRow && finalResultRow.system_point_total, + finalResultRow && finalResultRow.point_total, + finalResultRow && finalResultRow.placed, + ]; + return candidates.some((value) => { + const normalized = String(value || "").trim().toLowerCase(); + return normalized && normalized !== "null"; + }); +}; + +const resolveIdentityForCoderId = (coderId, normalizedIdentityByCoderId = new Map()) => { + const normalizedCoderId = String(coderId || "").trim(); + if (!normalizedCoderId) { + return null; + } + const knownIdentity = normalizedIdentityByCoderId.get(normalizedCoderId); + const knownMemberId = normalizeMemberId(knownIdentity && knownIdentity.memberId); + if (knownMemberId) { + return { + coderId: normalizedCoderId, + memberId: knownMemberId, + memberHandle: knownIdentity.memberHandle || null, + }; + } + + const fallbackMemberId = normalizeMemberId(normalizedCoderId); + if (!fallbackMemberId) { + return null; + } + return { + coderId: normalizedCoderId, + memberId: fallbackMemberId, + memberHandle: null, + }; +}; + +const deriveFinalScore = ({ systemPointTotal, pointTotal, rankingScore }) => { + if (Number.isFinite(systemPointTotal)) { + return { aggregateScore: systemPointTotal, scoreSource: "system_point_total" }; + } + if (Number.isFinite(pointTotal)) { + return { aggregateScore: pointTotal, scoreSource: "point_total" }; + } + if (Number.isFinite(rankingScore)) { + return { aggregateScore: rankingScore, scoreSource: "ranking_score" }; + } + return { aggregateScore: null, scoreSource: null }; +}; + +const compareFinalRows = (left, right) => { + const leftPlacement = Number.isFinite(left.legacyPlacement) ? left.legacyPlacement : Number.MAX_SAFE_INTEGER; + const rightPlacement = Number.isFinite(right.legacyPlacement) ? right.legacyPlacement : Number.MAX_SAFE_INTEGER; + if (leftPlacement !== rightPlacement) { + return leftPlacement - rightPlacement; + } + return String(left.coderId || "").localeCompare(String(right.coderId || ""), undefined, { + numeric: true, + }); +}; + +const loadLegacyFinalRowsByRoundId = async ({ + dataDir, + longComponentStateFile, + longCompResultPattern, + roundIds, +}) => { + const selectedRoundIds = Array.from( + new Set((roundIds || []).map((roundId) => String(roundId || "").trim()).filter(Boolean)) + ); + const rowsByRoundId = new Map(selectedRoundIds.map((roundId) => [roundId, []])); + if (selectedRoundIds.length === 0) { + return rowsByRoundId; + } + + const selectedRoundIdSet = new Set(selectedRoundIds); + const longComponentStatePath = resolveFilePath(dataDir, longComponentStateFile); + ensureFileExists(longComponentStatePath, "long component state"); + const longCompResultFiles = listFilesByPattern( + dataDir, + longCompResultPattern, + "long comp result" + ); + + const rankingScoreByRoundCoder = new Map(); + await streamJsonArray(longComponentStatePath, "long_component_state", (row) => { + const roundId = String(row && row.round_id ? row.round_id : "").trim(); + if (!selectedRoundIdSet.has(roundId)) { + return; + } + const coderId = String(row && row.coder_id ? row.coder_id : "").trim(); + if (!coderId) { + return; + } + const points = parseNumericScore(row && row.points); + if (!Number.isFinite(points)) { + return; + } + rankingScoreByRoundCoder.set(`${roundId}:${coderId}`, points); + }); + + await Promise.all( + longCompResultFiles.map((filePath) => + streamJsonArray(filePath, "long_comp_result", (row) => { + const roundId = String(row && row.round_id ? row.round_id : "").trim(); + if (!selectedRoundIdSet.has(roundId)) { + return; + } + if (!hasAnyFinalSignal(row)) { + return; + } + const coderId = String(row && row.coder_id ? row.coder_id : "").trim(); + if (!coderId) { + return; + } + + const systemPointTotal = parseNumericScore(row && row.system_point_total); + const pointTotal = parseNumericScore(row && row.point_total); + const rankingScore = rankingScoreByRoundCoder.get(`${roundId}:${coderId}`) || null; + const { aggregateScore, scoreSource } = deriveFinalScore({ + systemPointTotal, + pointTotal, + rankingScore, + }); + + rowsByRoundId.get(roundId).push({ + legacyRoundId: roundId, + coderId, + legacyPlacement: parsePlacement(row && row.placed), + aggregateScore, + scoreSource, + systemPointTotal, + pointTotal, + rankingScore, + }); + }) + ) + ); + + rowsByRoundId.forEach((rows, roundId) => { + rowsByRoundId.set(roundId, [...rows].sort(compareFinalRows)); + }); + + return rowsByRoundId; +}; + +const parseTimestamp = (value) => { + if (!value) { + return null; + } + if (value instanceof Date && !Number.isNaN(value.getTime())) { + return value.getTime(); + } + const parsed = Date.parse(String(value)); + if (!Number.isFinite(parsed)) { + return null; + } + return parsed; +}; + +const compareImportedSubmissions = (left, right) => { + const leftSubmitted = parseTimestamp(left.submittedDate); + const rightSubmitted = parseTimestamp(right.submittedDate); + if (leftSubmitted !== rightSubmitted) { + return (leftSubmitted || 0) - (rightSubmitted || 0); + } + + const leftCreated = parseTimestamp(left.createdAt); + const rightCreated = parseTimestamp(right.createdAt); + if (leftCreated !== rightCreated) { + return (leftCreated || 0) - (rightCreated || 0); + } + + return String(left.legacySubmissionId || "").localeCompare( + String(right.legacySubmissionId || ""), + undefined, + { numeric: true } + ); +}; + +const buildLatestImportedSubmissionByMemberId = (submissions = []) => { + const latestByMemberId = new Map(); + + submissions.forEach((submission) => { + const memberId = normalizeMemberId(submission && submission.memberId); + if (!memberId) { + return; + } + const submissionId = String(submission && submission.id ? submission.id : "").trim(); + if (!submissionId) { + return; + } + const legacySubmissionId = String( + submission && submission.legacySubmissionId ? submission.legacySubmissionId : "" + ).trim(); + if (!legacySubmissionId) { + return; + } + + const existing = latestByMemberId.get(memberId); + if (!existing || compareImportedSubmissions(existing, submission) < 0) { + latestByMemberId.set(memberId, submission); + } + }); + + return latestByMemberId; +}; + +const reconcileRoundFinalScores = async ({ + roundId, + challengeId, + finalRowsByRoundId, + normalizedIdentityByCoderId, + missingMemberFinalSkipMemberIds = new Set(), + plannedUnattachableFinalSkipMemberIds = new Set(), + finalScoreStore, +}) => { + if ( + !finalScoreStore || + typeof finalScoreStore.listImportedNonExampleSubmissionsByChallenge !== "function" || + typeof finalScoreStore.listExistingFinalSummationsBySubmissionId !== "function" || + typeof finalScoreStore.createFinalSummation !== "function" + ) { + throw new Error( + "finalScoreStore must provide listImportedNonExampleSubmissionsByChallenge, listExistingFinalSummationsBySubmissionId, and createFinalSummation." + ); + } + + const legacyFinalRows = finalRowsByRoundId.get(roundId) || []; + const importedSubmissions = await finalScoreStore.listImportedNonExampleSubmissionsByChallenge({ + challengeId, + }); + const latestImportedSubmissionByMemberId = buildLatestImportedSubmissionByMemberId( + importedSubmissions + ); + const existingFinalSummationsBySubmissionId = + await finalScoreStore.listExistingFinalSummationsBySubmissionId({ + challengeId, + }); + const missingMemberIds = new Set( + Array.from(missingMemberFinalSkipMemberIds || []) + .map((memberId) => normalizeMemberId(memberId)) + .filter(Boolean) + ); + const plannedUnattachableMemberIds = new Set( + Array.from(plannedUnattachableFinalSkipMemberIds || []) + .map((memberId) => normalizeMemberId(memberId)) + .filter(Boolean) + ); + + let createdFinalScores = 0; + let alreadyPresentFinalScores = 0; + let missingMemberSkippedFinalScores = 0; + let explicitSkippedFinalScores = 0; + const runtimeSkipRecords = []; + + for (const finalRow of legacyFinalRows) { + const identity = resolveIdentityForCoderId( + finalRow.coderId, + normalizedIdentityByCoderId + ); + const memberId = normalizeMemberId(identity && identity.memberId); + + if (!memberId || missingMemberIds.has(memberId)) { + missingMemberSkippedFinalScores += 1; + continue; + } + + const attachableSubmission = latestImportedSubmissionByMemberId.get(memberId); + if (!attachableSubmission) { + explicitSkippedFinalScores += 1; + if (!plannedUnattachableMemberIds.has(memberId)) { + runtimeSkipRecords.push({ + legacyRoundId: roundId, + memberId, + memberHandle: identity && identity.memberHandle ? identity.memberHandle : undefined, + coderIds: [String(finalRow.coderId || "").trim()].filter(Boolean), + reasonCode: FINALIST_WITHOUT_ATTACHABLE_SUBMISSION_REASON_CODE, + affectedSurfaces: ["final-score"], + counts: { + finalScore: 1, + }, + }); + } + continue; + } + + if (!Number.isFinite(finalRow.aggregateScore)) { + throw new Error( + `Legacy final result for round ${roundId} coder ${finalRow.coderId} is missing a numeric score across system_point_total, point_total, and ranking score fallback.` + ); + } + + const submissionId = String(attachableSubmission.id || "").trim(); + const existingFinalSummations = + existingFinalSummationsBySubmissionId.get(submissionId) || []; + if (existingFinalSummations.length > 0) { + alreadyPresentFinalScores += 1; + continue; + } + + await finalScoreStore.createFinalSummation({ + submissionId, + aggregateScore: finalRow.aggregateScore, + isPassing: finalRow.aggregateScore > 0, + reviewedDate: + attachableSubmission.submittedDate || + attachableSubmission.createdAt || + null, + legacySubmissionId: + attachableSubmission.legacySubmissionId || null, + isFinal: true, + isExample: false, + metadata: { + legacyRoundId: roundId, + legacyCoderId: finalRow.coderId, + scoreSource: finalRow.scoreSource, + legacyPlacement: finalRow.legacyPlacement, + }, + }); + existingFinalSummationsBySubmissionId.set(submissionId, [ + { + submissionId, + aggregateScore: finalRow.aggregateScore, + }, + ]); + createdFinalScores += 1; + } + + return { + legacyFinalCandidates: legacyFinalRows.length, + importedFinalScores: createdFinalScores + alreadyPresentFinalScores, + alreadyPresentFinalScores, + createdFinalScores, + missingMemberSkippedFinalScores, + explicitSkippedFinalScores, + runtimeSkipRecords, + }; +}; + +const createReviewFinalScoreStore = async ({ + reviewClient, + reviewSchema = DEFAULT_REVIEW_SCHEMA, + actor = "historical-mm-importer", +}) => { + if (!reviewClient || typeof reviewClient.$queryRawUnsafe !== "function") { + throw new Error( + "Review DB client with $queryRawUnsafe is required for final-score import." + ); + } + + const schema = normalizeReviewSchema(reviewSchema); + const submissionTable = buildQualifiedTableName(schema, "submission"); + const reviewSummationTable = buildQualifiedTableName(schema, "reviewSummation"); + + const columnRows = await reviewClient.$queryRawUnsafe( + `SELECT table_name AS "tableName", + column_name AS "columnName", + data_type AS "dataType", + is_nullable AS "isNullable" + FROM information_schema.columns + WHERE table_schema = $1 + AND table_name IN ('submission', 'reviewSummation')`, + schema + ); + + const submissionColumnsByName = new Map(); + const reviewSummationColumnsByName = new Map(); + (columnRows || []).forEach((columnRow) => { + if (columnRow.tableName === "submission") { + submissionColumnsByName.set(String(columnRow.columnName), columnRow); + } else if (columnRow.tableName === "reviewSummation") { + reviewSummationColumnsByName.set(String(columnRow.columnName), columnRow); + } + }); + + if (!submissionColumnsByName.has("id") || !submissionColumnsByName.has("challengeId")) { + throw new Error( + `Review submission table ${schema}.submission must expose id and challengeId columns.` + ); + } + if ( + !reviewSummationColumnsByName.has("submissionId") || + !reviewSummationColumnsByName.has("aggregateScore") || + !reviewSummationColumnsByName.has("isPassing") + ) { + throw new Error( + `Review reviewSummation table ${schema}.reviewSummation must expose submissionId, aggregateScore, and isPassing columns.` + ); + } + + const listImportedNonExampleSubmissionsByChallenge = async ({ challengeId }) => { + const selectedColumns = [`"id"`, `"memberId"`, `"legacySubmissionId"`]; + if (submissionColumnsByName.has("submittedDate")) { + selectedColumns.push(`"submittedDate"`); + } + if (submissionColumnsByName.has("createdAt")) { + selectedColumns.push(`"createdAt"`); + } + if (submissionColumnsByName.has("isExample")) { + selectedColumns.push(`"isExample"`); + } + + const whereClauses = [`"challengeId" = $1`, `"legacySubmissionId" IS NOT NULL`]; + if (submissionColumnsByName.has("isExample")) { + whereClauses.push(`COALESCE("isExample", false) = false`); + } + + const rows = await reviewClient.$queryRawUnsafe( + `SELECT ${selectedColumns.join(", ")} + FROM ${submissionTable} + WHERE ${whereClauses.join(" AND ")}`, + challengeId + ); + + return (rows || []) + .map((row) => ({ + id: String(row && row.id ? row.id : "").trim(), + memberId: normalizeMemberId(row && row.memberId), + legacySubmissionId: String( + row && row.legacySubmissionId ? row.legacySubmissionId : "" + ).trim(), + submittedDate: row && row.submittedDate ? row.submittedDate : null, + createdAt: row && row.createdAt ? row.createdAt : null, + isExample: Boolean(row && row.isExample), + })) + .filter( + (row) => row.id && row.memberId && row.legacySubmissionId && row.isExample !== true + ); + }; + + const listExistingFinalSummationsBySubmissionId = async ({ challengeId }) => { + const whereClauses = [ + `s."challengeId" = $1`, + `rs."isFinal" = true`, + ]; + if (reviewSummationColumnsByName.has("isExample")) { + whereClauses.push(`COALESCE(rs."isExample", false) = false`); + } + const rows = await reviewClient.$queryRawUnsafe( + `SELECT rs."submissionId" AS "submissionId", + rs."aggregateScore" AS "aggregateScore" + FROM ${reviewSummationTable} rs + INNER JOIN ${submissionTable} s ON s."id" = rs."submissionId" + WHERE ${whereClauses.join(" AND ")}`, + challengeId + ); + + const bySubmissionId = new Map(); + (rows || []).forEach((row) => { + const submissionId = String(row && row.submissionId ? row.submissionId : "").trim(); + if (!submissionId) { + return; + } + if (!bySubmissionId.has(submissionId)) { + bySubmissionId.set(submissionId, []); + } + bySubmissionId.get(submissionId).push({ + submissionId, + aggregateScore: parseNumericScore(row && row.aggregateScore), + }); + }); + return bySubmissionId; + }; + + const createFinalSummation = async ({ + submissionId, + aggregateScore, + isPassing, + reviewedDate, + legacySubmissionId, + isFinal = true, + isExample = false, + metadata = null, + }) => { + const columns = []; + const placeholders = []; + const values = []; + const pushColumn = (columnName, value) => { + columns.push(`"${columnName}"`); + values.push(value); + placeholders.push(`$${values.length}`); + }; + + pushColumn("submissionId", submissionId); + pushColumn("aggregateScore", aggregateScore); + pushColumn("isPassing", Boolean(isPassing)); + if (reviewSummationColumnsByName.has("isFinal")) { + pushColumn("isFinal", Boolean(isFinal)); + } + if (reviewSummationColumnsByName.has("reviewedDate") && reviewedDate) { + pushColumn("reviewedDate", reviewedDate); + } + if (reviewSummationColumnsByName.has("legacySubmissionId") && legacySubmissionId) { + pushColumn("legacySubmissionId", String(legacySubmissionId)); + } + if (reviewSummationColumnsByName.has("isExample")) { + pushColumn("isExample", Boolean(isExample)); + } + if (reviewSummationColumnsByName.has("metadata") && metadata) { + pushColumn("metadata", metadata); + } + if (reviewSummationColumnsByName.has("createdBy")) { + pushColumn("createdBy", actor); + } + if (reviewSummationColumnsByName.has("updatedBy")) { + pushColumn("updatedBy", actor); + } + const updatedAtColumn = reviewSummationColumnsByName.get("updatedAt"); + if ( + updatedAtColumn && + String(updatedAtColumn.isNullable || "").toUpperCase() === "NO" + ) { + pushColumn("updatedAt", reviewedDate || new Date()); + } + + await reviewClient.$queryRawUnsafe( + `INSERT INTO ${reviewSummationTable} (${columns.join(", ")}) + VALUES (${placeholders.join(", ")})`, + ...values + ); + }; + + return { + listImportedNonExampleSubmissionsByChallenge, + listExistingFinalSummationsBySubmissionId, + createFinalSummation, + }; +}; + +module.exports = { + DEFAULT_REVIEW_SCHEMA, + loadLegacyFinalRowsByRoundId, + reconcileRoundFinalScores, + createReviewFinalScoreStore, + MISSING_MEMBER_REASON_CODE, + FINALIST_WITHOUT_ATTACHABLE_SUBMISSION_REASON_CODE, +}; diff --git a/data-migration/test/importHistoricalMarathonMatches.applyFinalScores.test.js b/data-migration/test/importHistoricalMarathonMatches.applyFinalScores.test.js new file mode 100644 index 0000000..da16ae4 --- /dev/null +++ b/data-migration/test/importHistoricalMarathonMatches.applyFinalScores.test.js @@ -0,0 +1,210 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + runApplyMode, +} = require("../src/scripts/importHistoricalMarathonMatches/apply"); + +const writeJson = (baseDir, fileName, rootKey, rows) => { + fs.writeFileSync( + path.join(baseDir, fileName), + `${JSON.stringify({ [rootKey]: rows }, null, 2)}\n`, + "utf8" + ); +}; + +describe("importHistoricalMarathonMatches apply mode final-score wiring", () => { + let fixtureDir; + + beforeEach(() => { + fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-apply-final-scores-fixture-")); + writeJson(fixtureDir, "long_component_state_1.json", "long_component_state", [ + { long_component_state_id: "1001", round_id: "9892", coder_id: "1", component_id: "5503", points: "10.0" }, + { long_component_state_id: "1002", round_id: "9892", coder_id: "3", component_id: "5503", points: "5.0" }, + ]); + writeJson(fixtureDir, "long_submission_1.json", "long_submission", [ + { long_component_state_id: "1001", submission_number: "1", example: "0", submit_time: "1000" }, + ]); + writeJson(fixtureDir, "long_comp_result_1.json", "long_comp_result", [ + { round_id: "9892", coder_id: "1", system_point_total: "10.0", point_total: null, placed: "1" }, + { round_id: "9892", coder_id: "3", system_point_total: "5.0", point_total: null, placed: "2" }, + ]); + }); + + afterEach(() => { + fs.rmSync(fixtureDir, { recursive: true, force: true }); + }); + + test("apply-mode imports final scores and appends runtime unattachable-finalist skips", async () => { + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({ id: "challenge-1" }), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([]), + createMany: jest.fn(), + }, + }; + + const phaseRows = [ + { id: "phase-registration", name: "Registration" }, + { id: "phase-submission", name: "Submission" }, + { id: "phase-review", name: "Review" }, + ]; + + const prisma = { + challengeType: { + findMany: jest.fn().mockResolvedValue([{ id: "type-mm" }]), + }, + challengeTrack: { + findMany: jest.fn().mockResolvedValue([{ id: "track-ds" }]), + }, + phase: { + findMany: jest + .fn() + .mockResolvedValueOnce(phaseRows) + .mockResolvedValueOnce(phaseRows), + }, + challengeTimelineTemplate: { + findMany: jest.fn().mockResolvedValue([ + { + timelineTemplateId: "timeline-mm", + isDefault: true, + timelineTemplate: { + phases: [ + { phaseId: "phase-registration" }, + { phaseId: "phase-submission" }, + { phaseId: "phase-review" }, + ], + }, + }, + ]), + }, + $transaction: async (callback) => callback(tx), + }; + + const submissionStoreRecordsByChallengeId = new Map(); + const submissionStore = { + listExistingSubmissionsByLegacyId: async ({ challengeId }) => + new Map(submissionStoreRecordsByChallengeId.get(challengeId) || []), + createSubmission: async ({ challengeId, legacySubmissionId, memberId, submittedDate }) => { + if (!submissionStoreRecordsByChallengeId.has(challengeId)) { + submissionStoreRecordsByChallengeId.set(challengeId, new Map()); + } + submissionStoreRecordsByChallengeId.get(challengeId).set(legacySubmissionId, { + id: `sub-${legacySubmissionId}`, + legacySubmissionId, + memberId: String(memberId), + submittedDate, + createdAt: submittedDate, + }); + }, + }; + + const createdFinalSummations = []; + const finalScoreStore = { + listImportedNonExampleSubmissionsByChallenge: async ({ challengeId }) => + Array.from( + (submissionStoreRecordsByChallengeId.get(challengeId) || new Map()).values() + ), + listExistingFinalSummationsBySubmissionId: async () => new Map(), + createFinalSummation: async (payload) => { + createdFinalSummations.push(payload); + }, + }; + + const skippedFilePath = path.join(fixtureDir, "apply-skipped.json"); + const result = await runApplyMode({ + prisma, + options: { + roundIds: ["9892"], + skippedFilePath, + importSubmissions: true, + importFinalScores: true, + submissionStore, + finalScoreStore, + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + resourceClient: { + listSubmitterResources: jest.fn().mockResolvedValue([]), + createSubmitterResource: jest.fn().mockResolvedValue({}), + }, + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "create", + reason: "no-matching-v6-challenge-found", + }, + ], + roundDataById: new Map([ + [ + "9892", + { + round: { round_id: "9892", round_type_id: "13", name: "MM 9892" }, + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T01:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-01T01:00:00.000Z"), + eligibleRegistrants: new Set(["1", "3"]), + nonExampleSubmissions: 1, + nonExampleSubmitterCoderIds: new Set(["1"]), + finalCandidateCoderIds: new Set(["1", "3"]), + }, + ], + ]), + }, + actor: "importer", + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["3", { coderId: "3", memberId: 3, memberHandle: "charlie" }], + ]), + }); + + expect(createdFinalSummations).toHaveLength(1); + expect(createdFinalSummations[0]).toEqual( + expect.objectContaining({ + submissionId: "sub-10010001", + aggregateScore: 10, + legacySubmissionId: "10010001", + }) + ); + expect(result.records).toEqual([ + expect.objectContaining({ + recordType: "apply-record", + legacyRoundId: "9892", + status: "created", + challengeId: "challenge-1", + finalScoreReconciliation: { + legacyFinalCandidates: 2, + importedFinalScores: 1, + alreadyPresentFinalScores: 0, + createdFinalScores: 1, + missingMemberSkippedFinalScores: 0, + explicitSkippedFinalScores: 1, + runtimeSkipRecords: [ + expect.objectContaining({ + reasonCode: "finalist-without-attachable-submission", + memberId: "3", + }), + ], + }, + }), + ]); + expect(result.summary).toEqual( + expect.objectContaining({ + skippedFileArtifact: { + path: skippedFilePath, + reasonCodes: ["finalist-without-attachable-submission"], + recordCount: 1, + }, + }) + ); + }); +}); diff --git a/data-migration/test/importHistoricalMarathonMatches.finalScores.test.js b/data-migration/test/importHistoricalMarathonMatches.finalScores.test.js new file mode 100644 index 0000000..560a2c0 --- /dev/null +++ b/data-migration/test/importHistoricalMarathonMatches.finalScores.test.js @@ -0,0 +1,253 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + loadLegacyFinalRowsByRoundId, + reconcileRoundFinalScores, +} = require("../src/scripts/importHistoricalMarathonMatches/finalScores"); +const { + FINALIST_WITHOUT_ATTACHABLE_SUBMISSION_REASON_CODE, +} = require("../src/scripts/importHistoricalMarathonMatches/skippedArtifact"); + +const writeJson = (baseDir, fileName, rootKey, rows) => { + fs.writeFileSync( + path.join(baseDir, fileName), + `${JSON.stringify({ [rootKey]: rows }, null, 2)}\n`, + "utf8" + ); +}; + +const createFixtureDataDirectory = () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-final-scores-fixture-")); + + writeJson(baseDir, "long_component_state_1.json", "long_component_state", [ + { long_component_state_id: "1001", round_id: "9892", coder_id: "1", points: "100.0" }, + { long_component_state_id: "1002", round_id: "9892", coder_id: "2", points: "70.0" }, + { long_component_state_id: "1003", round_id: "9892", coder_id: "3", points: "60.0" }, + { long_component_state_id: "1004", round_id: "9892", coder_id: "4", points: "40.0" }, + { long_component_state_id: "2001", round_id: "10000", coder_id: "77", points: "777.0" }, + ]); + writeJson(baseDir, "long_comp_result_1.json", "long_comp_result", [ + { round_id: "9892", coder_id: "1", system_point_total: "100.0", point_total: "90.0", placed: "1" }, + { round_id: "9892", coder_id: "2", system_point_total: null, point_total: "70.0", placed: "2" }, + { round_id: "9892", coder_id: "3", system_point_total: null, point_total: null, placed: "3" }, + { round_id: "9892", coder_id: "4", system_point_total: "40.0", point_total: null, placed: "4" }, + { round_id: "9892", coder_id: "5", system_point_total: "30.0", point_total: null, placed: "5" }, + { round_id: "10000", coder_id: "77", system_point_total: "777.0", point_total: null, placed: "1" }, + ]); + + return baseDir; +}; + +describe("importHistoricalMarathonMatches final score import", () => { + let fixtureDir; + + beforeEach(() => { + fixtureDir = createFixtureDataDirectory(); + }); + + afterEach(() => { + fs.rmSync(fixtureDir, { recursive: true, force: true }); + }); + + test("loads final candidates and applies score fallback precedence", async () => { + const rowsByRoundId = await loadLegacyFinalRowsByRoundId({ + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + roundIds: ["9892"], + }); + + expect(rowsByRoundId.get("9892")).toEqual([ + expect.objectContaining({ + legacyRoundId: "9892", + coderId: "1", + legacyPlacement: 1, + scoreSource: "system_point_total", + aggregateScore: 100, + }), + expect.objectContaining({ + legacyRoundId: "9892", + coderId: "2", + legacyPlacement: 2, + scoreSource: "point_total", + aggregateScore: 70, + }), + expect.objectContaining({ + legacyRoundId: "9892", + coderId: "3", + legacyPlacement: 3, + scoreSource: "ranking_score", + aggregateScore: 60, + }), + expect.objectContaining({ + legacyRoundId: "9892", + coderId: "4", + legacyPlacement: 4, + scoreSource: "system_point_total", + aggregateScore: 40, + }), + expect.objectContaining({ + legacyRoundId: "9892", + coderId: "5", + legacyPlacement: 5, + scoreSource: "system_point_total", + aggregateScore: 30, + }), + ]); + }); + + test("attaches one final per member to latest imported non-example submission and tracks skips", async () => { + const rowsByRoundId = await loadLegacyFinalRowsByRoundId({ + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + roundIds: ["9892"], + }); + + const created = []; + const finalScoreStore = { + listImportedNonExampleSubmissionsByChallenge: async () => [ + { + id: "sub-1-old", + memberId: "1", + legacySubmissionId: "10010001", + submittedDate: new Date("2020-01-01T01:00:00.000Z"), + createdAt: new Date("2020-01-01T01:00:00.000Z"), + }, + { + id: "sub-1-new", + memberId: "1", + legacySubmissionId: "10010002", + submittedDate: new Date("2020-01-01T02:00:00.000Z"), + createdAt: new Date("2020-01-01T02:00:00.000Z"), + }, + { + id: "sub-2", + memberId: "2", + legacySubmissionId: "10020001", + submittedDate: new Date("2020-01-01T01:30:00.000Z"), + createdAt: new Date("2020-01-01T01:30:00.000Z"), + }, + { + id: "sub-3", + memberId: "3", + legacySubmissionId: "10030001", + submittedDate: new Date("2020-01-01T01:45:00.000Z"), + createdAt: new Date("2020-01-01T01:45:00.000Z"), + }, + ], + listExistingFinalSummationsBySubmissionId: async () => + new Map([ + [ + "sub-3", + [{ submissionId: "sub-3", aggregateScore: 60 }], + ], + ]), + createFinalSummation: async (payload) => { + created.push(payload); + }, + }; + + const result = await reconcileRoundFinalScores({ + roundId: "9892", + challengeId: "challenge-1", + finalRowsByRoundId: rowsByRoundId, + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ["3", { coderId: "3", memberId: 3, memberHandle: "charlie" }], + ["4", { coderId: "4", memberId: 4, memberHandle: "delta" }], + ["5", { coderId: "5", memberId: 5, memberHandle: "echo" }], + ]), + missingMemberFinalSkipMemberIds: new Set(["4"]), + plannedUnattachableFinalSkipMemberIds: new Set(["5"]), + finalScoreStore, + }); + + expect(result).toEqual({ + legacyFinalCandidates: 5, + importedFinalScores: 3, + alreadyPresentFinalScores: 1, + createdFinalScores: 2, + missingMemberSkippedFinalScores: 1, + explicitSkippedFinalScores: 1, + runtimeSkipRecords: [], + }); + + expect(created).toEqual([ + expect.objectContaining({ + submissionId: "sub-1-new", + aggregateScore: 100, + legacySubmissionId: "10010002", + }), + expect.objectContaining({ + submissionId: "sub-2", + aggregateScore: 70, + legacySubmissionId: "10020001", + }), + ]); + }); + + test("records runtime unattachable-finalist skip when no attachable submission exists unexpectedly", async () => { + const rowsByRoundId = await loadLegacyFinalRowsByRoundId({ + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + roundIds: ["9892"], + }); + + const result = await reconcileRoundFinalScores({ + roundId: "9892", + challengeId: "challenge-1", + finalRowsByRoundId: rowsByRoundId, + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ["3", { coderId: "3", memberId: 3, memberHandle: "charlie" }], + ["4", { coderId: "4", memberId: 4, memberHandle: "delta" }], + ["5", { coderId: "5", memberId: 5, memberHandle: "echo" }], + ]), + missingMemberFinalSkipMemberIds: new Set(["4"]), + plannedUnattachableFinalSkipMemberIds: new Set(), + finalScoreStore: { + listImportedNonExampleSubmissionsByChallenge: async () => [ + { + id: "sub-1", + memberId: "1", + legacySubmissionId: "10010001", + submittedDate: new Date("2020-01-01T01:00:00.000Z"), + createdAt: new Date("2020-01-01T01:00:00.000Z"), + }, + { + id: "sub-2", + memberId: "2", + legacySubmissionId: "10020001", + submittedDate: new Date("2020-01-01T01:00:00.000Z"), + createdAt: new Date("2020-01-01T01:00:00.000Z"), + }, + { + id: "sub-3", + memberId: "3", + legacySubmissionId: "10030001", + submittedDate: new Date("2020-01-01T01:00:00.000Z"), + createdAt: new Date("2020-01-01T01:00:00.000Z"), + }, + ], + listExistingFinalSummationsBySubmissionId: async () => new Map(), + createFinalSummation: jest.fn(), + }, + }); + + expect(result.explicitSkippedFinalScores).toBe(1); + expect(result.runtimeSkipRecords).toEqual([ + expect.objectContaining({ + legacyRoundId: "9892", + memberId: "5", + reasonCode: FINALIST_WITHOUT_ATTACHABLE_SUBMISSION_REASON_CODE, + affectedSurfaces: ["final-score"], + }), + ]); + }); +}); From 1d381fe05bc55445f161aeb70fa69e9ba2a401b5 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 3 Apr 2026 04:15:28 +1100 Subject: [PATCH 20/27] PM-3167: tighten registration reopen dependency checks What was broken challenge-api-v6 still allowed Registration to be reopened whenever a Submission phase was open, even when that Submission phase depended on Checkpoint Review instead of Registration. That kept surfacing the wrong Reopen action after the earlier platform-ui fix. Root cause ChallengePhaseService had a special-case exemption that bypassed explicit dependency checks for Registration whenever any submission variant was open. What was changed Removed the submission-based Registration reopen exemption so reopening now requires a real open dependent phase. Added a regression test for the Checkpoint Review to Submission path. Stabilized ChallengePhaseService unit setup so the seeded phase records are reset between tests and challenge update publishing is stubbed for this suite. Any added/updated tests Added a regression test that forbids reopening Registration when the open Submission phase depends on Checkpoint Review. Updated ChallengePhaseService unit setup to run the full file reliably in isolation. --- src/services/ChallengePhaseService.js | 895 ++++++++++++------------ test/unit/ChallengePhaseService.test.js | 519 +++++++++++--- 2 files changed, 847 insertions(+), 567 deletions(-) diff --git a/src/services/ChallengePhaseService.js b/src/services/ChallengePhaseService.js index a019d8a..d7389e3 100644 --- a/src/services/ChallengePhaseService.js +++ b/src/services/ChallengePhaseService.js @@ -1,258 +1,261 @@ /** * This service provides operations of challenge phases. */ -const _ = require("lodash"); -const Joi = require("joi"); -const moment = require("moment"); -const { Prisma } = require("@prisma/client"); -const config = require("config"); -const helper = require("../common/helper"); -const logger = require("../common/logger"); -const errors = require("../common/errors"); -const constants = require("../../app-constants"); -const { getReviewClient } = require("../common/review-prisma"); -const { indexChallengeAndPostToKafka, ensureAIScreeningCanBeClosed } = require("./ChallengeService"); - -const { getClient } = require("../common/prisma"); -const prisma = getClient(); -const PENDING_REVIEW_STATUSES = Object.freeze(["PENDING", "IN_PROGRESS", "DRAFT", "SUBMITTED"]); +const _ = require('lodash') +const Joi = require('joi') +const moment = require('moment') +const { Prisma } = require('@prisma/client') +const config = require('config') +const helper = require('../common/helper') +const logger = require('../common/logger') +const errors = require('../common/errors') +const constants = require('../../app-constants') +const { getReviewClient } = require('../common/review-prisma') +const { + indexChallengeAndPostToKafka, + ensureAIScreeningCanBeClosed +} = require('./ChallengeService') + +const { getClient } = require('../common/prisma') +const prisma = getClient() +const PENDING_REVIEW_STATUSES = Object.freeze(['PENDING', 'IN_PROGRESS', 'DRAFT', 'SUBMITTED']) const REVIEW_PHASE_NAMES = Object.freeze([ - "checkpoint review", - "checkpoint screening", - "screening", - "review", - "approval", -]); -const REVIEW_PHASE_NAME_SET = new Set(REVIEW_PHASE_NAMES.map((name) => name.toLowerCase())); + 'checkpoint review', + 'checkpoint screening', + 'screening', + 'review', + 'approval' +]) +const REVIEW_PHASE_NAME_SET = new Set(REVIEW_PHASE_NAMES.map((name) => name.toLowerCase())) const PHASE_RESOURCE_ROLE_REQUIREMENTS = Object.freeze({ - "iterative review": "Iterative Reviewer", - "checkpoint screening": "Checkpoint Screener", - screening: "Screener", - review: "Reviewer", - approval: "Approver", - "checkpoint review": "Checkpoint Reviewer", -}); -const SUBMISSION_PHASE_NAME_SET = new Set(["submission", "topgear submission"]); -const REGISTRATION_PHASE_NAME = "registration"; - -const normalizePhaseName = (name) => String(name || "").trim().toLowerCase(); - -function datesAreSame(dateA, dateB) { + 'iterative review': 'Iterative Reviewer', + 'checkpoint screening': 'Checkpoint Screener', + screening: 'Screener', + review: 'Reviewer', + approval: 'Approver', + 'checkpoint review': 'Checkpoint Reviewer' +}) +const normalizePhaseName = (name) => + String(name || '') + .trim() + .toLowerCase() + +function datesAreSame (dateA, dateB) { if (_.isNil(dateA) && _.isNil(dateB)) { - return true; + return true } if (_.isNil(dateA) || _.isNil(dateB)) { - return false; + return false } - return new Date(dateA).getTime() === new Date(dateB).getTime(); + return new Date(dateA).getTime() === new Date(dateB).getTime() } -function dateIsAfter(dateA, dateB) { +function dateIsAfter (dateA, dateB) { if (_.isNil(dateA) || _.isNil(dateB)) { - return false; + return false } - const timeA = new Date(dateA).getTime(); - const timeB = new Date(dateB).getTime(); + const timeA = new Date(dateA).getTime() + const timeB = new Date(dateB).getTime() if (Number.isNaN(timeA) || Number.isNaN(timeB)) { - return false; + return false } - return timeA > timeB; + return timeA > timeB } -function buildPhaseIdentifiers(phase) { - const identifiers = []; +function buildPhaseIdentifiers (phase) { + const identifiers = [] if (phase && phase.id) { - identifiers.push(String(phase.id)); + identifiers.push(String(phase.id)) } if (phase && !_.isNil(phase.phaseId)) { - identifiers.push(String(phase.phaseId)); + identifiers.push(String(phase.phaseId)) } - return identifiers; + return identifiers } -async function recalculateDependentPhaseDates(tx, challengeId, predecessorPhase, currentUserId) { +async function recalculateDependentPhaseDates (tx, challengeId, predecessorPhase, currentUserId) { if (!predecessorPhase || _.isNil(predecessorPhase.scheduledEndDate)) { - return; + return } const phases = await tx.challengePhase.findMany({ - where: { challengeId }, - }); + where: { challengeId } + }) - const successorsByPredecessor = new Map(); + const successorsByPredecessor = new Map() for (const phase of phases) { if (_.isNil(phase.predecessor)) { - continue; + continue } - const key = String(phase.predecessor); + const key = String(phase.predecessor) if (!successorsByPredecessor.has(key)) { - successorsByPredecessor.set(key, []); + successorsByPredecessor.set(key, []) } - successorsByPredecessor.get(key).push(phase); + successorsByPredecessor.get(key).push(phase) } - const queue = [predecessorPhase]; - const visited = new Set(); + const queue = [predecessorPhase] + const visited = new Set() while (queue.length > 0) { - const currentPhase = queue.shift(); - const currentEndDate = currentPhase?.scheduledEndDate; + const currentPhase = queue.shift() + const currentEndDate = currentPhase?.scheduledEndDate if (_.isNil(currentEndDate)) { - continue; + continue } - const predecessorKeys = buildPhaseIdentifiers(currentPhase); + const predecessorKeys = buildPhaseIdentifiers(currentPhase) for (const predecessorKey of predecessorKeys) { - const successors = successorsByPredecessor.get(predecessorKey) || []; + const successors = successorsByPredecessor.get(predecessorKey) || [] for (const successor of successors) { if (visited.has(successor.id)) { - continue; + continue } - let successorForQueue = successor; + let successorForQueue = successor if (_.isNil(successor.actualStartDate)) { - const alignToPredecessorStart = normalizePhaseName(successor.name) === "iterative review"; + const alignToPredecessorStart = normalizePhaseName(successor.name) === 'iterative review' const desiredStartDate = new Date( alignToPredecessorStart && currentPhase.scheduledStartDate ? currentPhase.scheduledStartDate : currentEndDate - ); - const durationSeconds = Number(successor.duration); + ) + const durationSeconds = Number(successor.duration) if (!Number.isFinite(durationSeconds) || Number.isNaN(desiredStartDate.getTime())) { - visited.add(successor.id); - queue.push(successorForQueue); - continue; + visited.add(successor.id) + queue.push(successorForQueue) + continue } - const desiredEndDate = new Date(desiredStartDate.getTime() + durationSeconds * 1000); - const startChanged = !datesAreSame(successor.scheduledStartDate, desiredStartDate); - const endChanged = !datesAreSame(successor.scheduledEndDate, desiredEndDate); + const desiredEndDate = new Date(desiredStartDate.getTime() + durationSeconds * 1000) + const startChanged = !datesAreSame(successor.scheduledStartDate, desiredStartDate) + const endChanged = !datesAreSame(successor.scheduledEndDate, desiredEndDate) if (startChanged || endChanged) { successorForQueue = await tx.challengePhase.update({ data: { scheduledStartDate: desiredStartDate, scheduledEndDate: desiredEndDate, - updatedBy: currentUserId, + updatedBy: currentUserId }, where: { - id: successor.id, - }, - }); + id: successor.id + } + }) } else { successorForQueue = { ...successor, scheduledStartDate: successor.scheduledStartDate, - scheduledEndDate: successor.scheduledEndDate, - }; + scheduledEndDate: successor.scheduledEndDate + } } } - visited.add(successor.id); - queue.push(successorForQueue); + visited.add(successor.id) + queue.push(successorForQueue) } } } } -async function shiftDependentPhaseDates(tx, challengeId, predecessorPhase, deltaMs, currentUserId) { +async function shiftDependentPhaseDates (tx, challengeId, predecessorPhase, deltaMs, currentUserId) { if (!predecessorPhase || !Number.isFinite(deltaMs) || deltaMs === 0) { - return; + return } const phases = await tx.challengePhase.findMany({ - where: { challengeId }, - }); + where: { challengeId } + }) - const successorsByPredecessor = new Map(); + const successorsByPredecessor = new Map() for (const phase of phases) { if (_.isNil(phase.predecessor)) { - continue; + continue } - const key = String(phase.predecessor); + const key = String(phase.predecessor) if (!successorsByPredecessor.has(key)) { - successorsByPredecessor.set(key, []); + successorsByPredecessor.set(key, []) } - successorsByPredecessor.get(key).push(phase); + successorsByPredecessor.get(key).push(phase) } - const queue = [predecessorPhase]; - const visited = new Set(); + const queue = [predecessorPhase] + const visited = new Set() while (queue.length > 0) { - const currentPhase = queue.shift(); - const predecessorKeys = buildPhaseIdentifiers(currentPhase); + const currentPhase = queue.shift() + const predecessorKeys = buildPhaseIdentifiers(currentPhase) for (const predecessorKey of predecessorKeys) { - const successors = successorsByPredecessor.get(predecessorKey) || []; + const successors = successorsByPredecessor.get(predecessorKey) || [] for (const successor of successors) { if (visited.has(successor.id)) { - continue; + continue } - let successorForQueue = successor; + let successorForQueue = successor if (_.isNil(successor.actualStartDate)) { const scheduledStartTime = successor.scheduledStartDate ? new Date(successor.scheduledStartDate).getTime() - : Number.NaN; + : Number.NaN const scheduledEndTime = successor.scheduledEndDate ? new Date(successor.scheduledEndDate).getTime() - : Number.NaN; + : Number.NaN if (Number.isFinite(scheduledStartTime) && Number.isFinite(scheduledEndTime)) { - const desiredStartDate = new Date(scheduledStartTime + deltaMs); - const desiredEndDate = new Date(scheduledEndTime + deltaMs); - const startChanged = !datesAreSame(successor.scheduledStartDate, desiredStartDate); - const endChanged = !datesAreSame(successor.scheduledEndDate, desiredEndDate); + const desiredStartDate = new Date(scheduledStartTime + deltaMs) + const desiredEndDate = new Date(scheduledEndTime + deltaMs) + const startChanged = !datesAreSame(successor.scheduledStartDate, desiredStartDate) + const endChanged = !datesAreSame(successor.scheduledEndDate, desiredEndDate) if (startChanged || endChanged) { successorForQueue = await tx.challengePhase.update({ data: { scheduledStartDate: desiredStartDate, scheduledEndDate: desiredEndDate, - updatedBy: currentUserId, + updatedBy: currentUserId }, where: { - id: successor.id, - }, - }); + id: successor.id + } + }) } else { successorForQueue = { ...successor, scheduledStartDate: successor.scheduledStartDate, - scheduledEndDate: successor.scheduledEndDate, - }; + scheduledEndDate: successor.scheduledEndDate + } } } } - visited.add(successor.id); - queue.push(successorForQueue); + visited.add(successor.id) + queue.push(successorForQueue) } } } } -async function hasPendingScorecardsForPhase(challengePhaseId) { +async function hasPendingScorecardsForPhase (challengePhaseId) { if (!config.REVIEW_DB_URL) { logger.debug( `Skipping pending scorecard check for phase ${challengePhaseId} because REVIEW_DB_URL is not configured` - ); - return false; + ) + return false } - const reviewPrisma = getReviewClient(); - const reviewSchema = config.REVIEW_DB_SCHEMA; - const reviewTable = Prisma.raw(`"${reviewSchema}"."review"`); - const statusText = Prisma.raw(`"status"::text`); + const reviewPrisma = getReviewClient() + const reviewSchema = config.REVIEW_DB_SCHEMA + const reviewTable = Prisma.raw(`"${reviewSchema}"."review"`) + const statusText = Prisma.raw('"status"::text') const pendingStatusClause = PENDING_REVIEW_STATUSES.length > 0 ? Prisma.sql`${statusText} IN (${Prisma.join( PENDING_REVIEW_STATUSES.map((status) => Prisma.sql`${status}`) )})` - : Prisma.sql`FALSE`; + : Prisma.sql`FALSE` - let rows; + let rows try { rows = await reviewPrisma.$queryRaw( Prisma.sql` @@ -264,53 +267,53 @@ async function hasPendingScorecardsForPhase(challengePhaseId) { OR ${pendingStatusClause} ) ` - ); + ) } catch (err) { logger.error( `Failed to check pending scorecards for phase ${challengePhaseId}: ${err.message}`, err - ); - throw err; + ) + throw err } - const [{ count = 0 } = {}] = rows || []; - return Number(count) > 0; + const [{ count = 0 } = {}] = rows || [] + return Number(count) > 0 } -async function hasCompletedReviewsForPhase(challengePhaseIdOrIds) { +async function hasCompletedReviewsForPhase (challengePhaseIdOrIds) { if (!config.REVIEW_DB_URL) { logger.debug( `Skipping completed review check for phase ${challengePhaseIdOrIds} because REVIEW_DB_URL is not configured` - ); - return false; + ) + return false } const phaseIds = Array.isArray(challengePhaseIdOrIds) ? challengePhaseIdOrIds.filter((id) => !_.isNil(id)) - : [challengePhaseIdOrIds]; + : [challengePhaseIdOrIds] if (phaseIds.length === 0) { - return false; + return false } - const reviewPrisma = getReviewClient(); - const reviewSchema = config.REVIEW_DB_SCHEMA; - const reviewTable = Prisma.raw(`"${reviewSchema}"."review"`); - const statusText = Prisma.raw(`"status"::text`); - const completedStatuses = ["IN_PROGRESS", "COMPLETED"]; + const reviewPrisma = getReviewClient() + const reviewSchema = config.REVIEW_DB_SCHEMA + const reviewTable = Prisma.raw(`"${reviewSchema}"."review"`) + const statusText = Prisma.raw('"status"::text') + const completedStatuses = ['IN_PROGRESS', 'COMPLETED'] const phaseIdClause = phaseIds.length === 1 ? Prisma.sql`"phaseId" = ${phaseIds[0]}` : Prisma.sql`"phaseId" IN (${Prisma.join( phaseIds.map((phaseId) => Prisma.sql`${phaseId}`) - )})`; + )})` const completedStatusClause = Prisma.sql`${statusText} IN (${Prisma.join( completedStatuses.map((status) => Prisma.sql`${status}`) - )})`; + )})` - let rows; + let rows try { rows = await reviewPrisma.$queryRaw( Prisma.sql` @@ -319,36 +322,36 @@ async function hasCompletedReviewsForPhase(challengePhaseIdOrIds) { WHERE ${phaseIdClause} AND ${completedStatusClause} ` - ); + ) } catch (err) { logger.error( - `Failed to check completed reviews for phase(s) ${phaseIds.join(", ")}: ${err.message}`, + `Failed to check completed reviews for phase(s) ${phaseIds.join(', ')}: ${err.message}`, err - ); - throw err; + ) + throw err } - const [{ count = 0 } = {}] = rows || []; - return Number(count) > 0; + const [{ count = 0 } = {}] = rows || [] + return Number(count) > 0 } -async function hasSubmittedAppealsForChallenge(challengeId) { +async function hasSubmittedAppealsForChallenge (challengeId) { if (!config.REVIEW_DB_URL) { logger.debug( `Skipping submitted appeals check for challenge ${challengeId} because REVIEW_DB_URL is not configured` - ); - return false; + ) + return false } - const reviewPrisma = getReviewClient(); - const reviewSchema = config.REVIEW_DB_SCHEMA; - const appealTable = Prisma.raw(`"${reviewSchema}"."appeal"`); - const reviewItemCommentTable = Prisma.raw(`"${reviewSchema}"."reviewItemComment"`); - const reviewItemTable = Prisma.raw(`"${reviewSchema}"."reviewItem"`); - const reviewTable = Prisma.raw(`"${reviewSchema}"."review"`); - const submissionTable = Prisma.raw(`"${reviewSchema}"."submission"`); + const reviewPrisma = getReviewClient() + const reviewSchema = config.REVIEW_DB_SCHEMA + const appealTable = Prisma.raw(`"${reviewSchema}"."appeal"`) + const reviewItemCommentTable = Prisma.raw(`"${reviewSchema}"."reviewItemComment"`) + const reviewItemTable = Prisma.raw(`"${reviewSchema}"."reviewItem"`) + const reviewTable = Prisma.raw(`"${reviewSchema}"."review"`) + const submissionTable = Prisma.raw(`"${reviewSchema}"."submission"`) - let rows; + let rows try { rows = await reviewPrisma.$queryRaw( Prisma.sql` @@ -367,37 +370,37 @@ async function hasSubmittedAppealsForChallenge(challengeId) { ) ) ` - ); + ) } catch (err) { logger.error( `Failed to check submitted appeals for challenge ${challengeId}: ${err.message}`, err - ); - throw err; + ) + throw err } - const [{ count = 0 } = {}] = rows || []; - return Number(count) > 0; + const [{ count = 0 } = {}] = rows || [] + return Number(count) > 0 } -async function hasPendingAppealResponsesForChallenge(challengeId) { +async function hasPendingAppealResponsesForChallenge (challengeId) { if (!config.REVIEW_DB_URL) { logger.debug( `Skipping pending appeal response check for challenge ${challengeId} because REVIEW_DB_URL is not configured` - ); - return false; + ) + return false } - const reviewPrisma = getReviewClient(); - const reviewSchema = config.REVIEW_DB_SCHEMA; - const appealTable = Prisma.raw(`"${reviewSchema}"."appeal"`); - const appealResponseTable = Prisma.raw(`"${reviewSchema}"."appealResponse"`); - const reviewItemCommentTable = Prisma.raw(`"${reviewSchema}"."reviewItemComment"`); - const reviewItemTable = Prisma.raw(`"${reviewSchema}"."reviewItem"`); - const reviewTable = Prisma.raw(`"${reviewSchema}"."review"`); - const submissionTable = Prisma.raw(`"${reviewSchema}"."submission"`); + const reviewPrisma = getReviewClient() + const reviewSchema = config.REVIEW_DB_SCHEMA + const appealTable = Prisma.raw(`"${reviewSchema}"."appeal"`) + const appealResponseTable = Prisma.raw(`"${reviewSchema}"."appealResponse"`) + const reviewItemCommentTable = Prisma.raw(`"${reviewSchema}"."reviewItemComment"`) + const reviewItemTable = Prisma.raw(`"${reviewSchema}"."reviewItem"`) + const reviewTable = Prisma.raw(`"${reviewSchema}"."review"`) + const submissionTable = Prisma.raw(`"${reviewSchema}"."submission"`) - let rows; + let rows try { rows = await reviewPrisma.$queryRaw( Prisma.sql` @@ -418,34 +421,34 @@ async function hasPendingAppealResponsesForChallenge(challengeId) { ) ) ` - ); + ) } catch (err) { logger.error( `Failed to check pending appeal responses for challenge ${challengeId}: ${err.message}`, err - ); - throw err; + ) + throw err } - const [{ count = 0 } = {}] = rows || []; - return Number(count) > 0; + const [{ count = 0 } = {}] = rows || [] + return Number(count) > 0 } -async function hasPendingEscalationRequestsForChallenge(challengeId) { +async function hasPendingEscalationRequestsForChallenge (challengeId) { if (!config.REVIEW_DB_URL) { logger.debug( `Skipping pending escalation request check for challenge ${challengeId} because REVIEW_DB_URL is not configured` - ); - return false; + ) + return false } - const reviewPrisma = getReviewClient(); - const reviewSchema = config.REVIEW_DB_SCHEMA; - const escalationTable = Prisma.raw(`"${reviewSchema}"."aiReviewDecisionEscalation"`); - const decisionTable = Prisma.raw(`"${reviewSchema}"."aiReviewDecision"`); - const submissionTable = Prisma.raw(`"${reviewSchema}"."submission"`); + const reviewPrisma = getReviewClient() + const reviewSchema = config.REVIEW_DB_SCHEMA + const escalationTable = Prisma.raw(`"${reviewSchema}"."aiReviewDecisionEscalation"`) + const decisionTable = Prisma.raw(`"${reviewSchema}"."aiReviewDecision"`) + const submissionTable = Prisma.raw(`"${reviewSchema}"."submission"`) - let rows; + let rows try { rows = await reviewPrisma.$queryRaw( Prisma.sql` @@ -460,23 +463,23 @@ async function hasPendingEscalationRequestsForChallenge(challengeId) { AND s."id" = ard."submissionId" ) ` - ); + ) } catch (err) { logger.error( `Failed to check pending escalation requests for challenge ${challengeId}: ${err.message}`, err - ); - throw err; + ) + throw err } - const [{ count = 0 } = {}] = rows || []; - return Number(count) > 0; + const [{ count = 0 } = {}] = rows || [] + return Number(count) > 0 } -async function checkChallengeExists(challengeId) { - const challenge = await prisma.challenge.findUnique({ where: { id: challengeId } }); +async function checkChallengeExists (challengeId) { + const challenge = await prisma.challenge.findUnique({ where: { id: challengeId } }) if (!challenge) { - throw new errors.NotFoundError(`Challenge with id: ${challengeId} doesn't exist`); + throw new errors.NotFoundError(`Challenge with id: ${challengeId} doesn't exist`) } } @@ -484,62 +487,62 @@ async function checkChallengeExists(challengeId) { * Publish a challenge update event with the latest challenge payload. * @param {String} challengeId the challenge id */ -async function postChallengeUpdatedNotification(challengeId) { +async function postChallengeUpdatedNotification (challengeId) { try { - const challenge = await prisma.challenge.findUnique({ where: { id: challengeId } }); + const challenge = await prisma.challenge.findUnique({ where: { id: challengeId } }) if (!challenge) { - logger.error(`Failed to publish challenge update event: challenge ${challengeId} not found`); - return; + logger.error(`Failed to publish challenge update event: challenge ${challengeId} not found`) + return } - await indexChallengeAndPostToKafka(challenge); + await indexChallengeAndPostToKafka(challenge) } catch (error) { logger.error( `Failed to publish challenge update event for challenge ${challengeId}: ${error.message}`, error - ); - throw error; + ) + throw error } } -async function ensureRequiredResourcesBeforeOpeningPhase(challengeId, phaseName) { - const normalizedPhaseName = _.toLower(_.trim(phaseName || "")); - const requiredRoleName = PHASE_RESOURCE_ROLE_REQUIREMENTS[normalizedPhaseName]; +async function ensureRequiredResourcesBeforeOpeningPhase (challengeId, phaseName) { + const normalizedPhaseName = _.toLower(_.trim(phaseName || '')) + const requiredRoleName = PHASE_RESOURCE_ROLE_REQUIREMENTS[normalizedPhaseName] if (!requiredRoleName) { - return; + return } - const challengeResources = await helper.getChallengeResources(challengeId); - const requiredRoleNameLower = _.toLower(requiredRoleName); + const challengeResources = await helper.getChallengeResources(challengeId) + const requiredRoleNameLower = _.toLower(requiredRoleName) const hasRequiredRoleByName = (challengeResources || []).some((resource) => { const roleName = resource.roleName || resource.role || - _.get(resource, "role.name") || - _.get(resource, "resourceRoleName") || - _.get(resource, "resourceRole.name"); - return roleName && _.toLower(roleName) === requiredRoleNameLower; - }); + _.get(resource, 'role.name') || + _.get(resource, 'resourceRoleName') || + _.get(resource, 'resourceRole.name') + return roleName && _.toLower(roleName) === requiredRoleNameLower + }) - let hasRequiredRole = hasRequiredRoleByName; + let hasRequiredRole = hasRequiredRoleByName if (!hasRequiredRole) { - const resourceRoles = await helper.getResourceRoles(); + const resourceRoles = await helper.getResourceRoles() const requiredRoleIds = (resourceRoles || []) .filter((role) => role.name && _.toLower(role.name) === requiredRoleNameLower) - .map((role) => _.toString(role.id)); + .map((role) => _.toString(role.id)) if (requiredRoleIds.length > 0) { - const requiredRoleIdSet = new Set(requiredRoleIds); + const requiredRoleIdSet = new Set(requiredRoleIds) hasRequiredRole = (challengeResources || []).some( (resource) => resource.roleId && requiredRoleIdSet.has(_.toString(resource.roleId)) - ); + ) } } if (!hasRequiredRole) { - const displayPhaseName = phaseName || "phase"; + const displayPhaseName = phaseName || 'phase' throw new errors.BadRequestError( `Cannot open ${displayPhaseName} phase because the challenge does not have any resource with the ${requiredRoleName} role` - ); + ) } } @@ -548,26 +551,26 @@ async function ensureRequiredResourcesBeforeOpeningPhase(challengeId, phaseName) * @param {String} challengeId the challenge id * @returns {[Object]} the list of challenge phase */ -async function getAllChallengePhases(challengeId) { - await checkChallengeExists(challengeId); +async function getAllChallengePhases (challengeId) { + await checkChallengeExists(challengeId) const result = await prisma.challengePhase.findMany({ where: { challengeId }, - include: { phase: true, constraints: true }, - }); + include: { phase: true, constraints: true } + }) return _.map(result, (obj) => { - const ret = _.omit(obj, constants.auditFields); - ret.phase = _.omit(obj.phase, constants.auditFields); + const ret = _.omit(obj, constants.auditFields) + ret.phase = _.omit(obj.phase, constants.auditFields) ret.constraints = _.map(obj.constraints, (constraint) => _.omit(constraint, constants.auditFields) - ); - return ret; - }); + ) + return ret + }) } getAllChallengePhases.schema = { - challengeId: Joi.id(), -}; + challengeId: Joi.id() +} /** * Get challenge phase. @@ -575,27 +578,27 @@ getAllChallengePhases.schema = { * @param {String} id the challenge phase id * @returns {Object} the challengePhase with given challengeId and id */ -async function getChallengePhase(challengeId, id) { - await checkChallengeExists(challengeId); +async function getChallengePhase (challengeId, id) { + await checkChallengeExists(challengeId) const result = await prisma.challengePhase.findFirst({ where: { challengeId, id }, - include: { phase: true, constraints: true }, - }); + include: { phase: true, constraints: true } + }) if (!result) { throw new errors.NotFoundError( `ChallengePhase with challengeId: ${challengeId}, phaseId: ${id} doesn't exist` - ); + ) } - const ret = _.omit(result, constants.auditFields); - ret.phase = _.omit(result.phase, constants.auditFields); - ret.constraints = _.map(result.constraints, (constraint) => _.omit(constraint)); - return ret; + const ret = _.omit(result, constants.auditFields) + ret.phase = _.omit(result.phase, constants.auditFields) + ret.constraints = _.map(result.constraints, (constraint) => _.omit(constraint)) + return ret } getChallengePhase.schema = { challengeId: Joi.id(), - id: Joi.id(), -}; + id: Joi.id() +} /** * Partially update challenge phase @@ -604,65 +607,59 @@ getChallengePhase.schema = { * @param {String} id the phase id * @returns {Object} the updated challengePhase */ -async function partiallyUpdateChallengePhase(currentUser, challengeId, id, data) { - await checkChallengeExists(challengeId); +async function partiallyUpdateChallengePhase (currentUser, challengeId, id, data) { + await checkChallengeExists(challengeId) const challengePhase = await prisma.challengePhase.findFirst({ where: { challengeId, id }, - include: { constraints: true }, - }); + include: { constraints: true } + }) if (!challengePhase) { throw new errors.NotFoundError( `ChallengePhase with challengeId: ${challengeId}, phaseId: ${id} doesn't exist` - ); + ) } - const originalScheduledEndDate = challengePhase.scheduledEndDate; + const originalScheduledEndDate = challengePhase.scheduledEndDate const shouldAttemptSuccessorRecalc = Boolean( data.duration || data.scheduledStartDate || data.scheduledEndDate - ); + ) // isOpen should be false if it's passed as null - if ("isOpen" in data) { - if (!data["isOpen"]) { - data["isOpen"] = false; + if ('isOpen' in data) { + if (!data.isOpen) { + data.isOpen = false } } // check ChallengePhase data - if (data["phaseId"]) { - const phase = await prisma.phase.findUnique({ where: { id: data["phaseId"] } }); + if (data.phaseId) { + const phase = await prisma.phase.findUnique({ where: { id: data.phaseId } }) if (!phase) { - throw new errors.BadRequestError(`phaseId should be a valid phase`); + throw new errors.BadRequestError('phaseId should be a valid phase') } } - if (data["predecessor"]) { + if (data.predecessor) { const predecessor = await prisma.challengePhase.findFirst({ where: { challengeId, - OR: [ - { id: data["predecessor"] }, - { phaseId: data["predecessor"] }, - ], - }, - }); + OR: [{ id: data.predecessor }, { phaseId: data.predecessor }] + } + }) if (!predecessor) { throw new errors.BadRequestError( `predecessor should be a valid challenge phase in the same challenge: ${challengeId}` - ); + ) } } - const isOpeningPhase = "isOpen" in data && data["isOpen"] === true; - const predecessorId = data["predecessor"] || challengePhase.predecessor; + const isOpeningPhase = 'isOpen' in data && data.isOpen === true + const predecessorId = data.predecessor || challengePhase.predecessor if (isOpeningPhase && predecessorId) { const predecessorPhase = await prisma.challengePhase.findFirst({ where: { challengeId, - OR: [ - { id: predecessorId }, - { phaseId: predecessorId }, - ], - }, - }); + OR: [{ id: predecessorId }, { phaseId: predecessorId }] + } + }) if ( !predecessorPhase || @@ -671,250 +668,241 @@ async function partiallyUpdateChallengePhase(currentUser, challengeId, id, data) _.isNil(predecessorPhase.actualEndDate) ) { throw new errors.BadRequestError( - "Cannot open phase because predecessor phase must be closed with both actualStartDate and actualEndDate set" - ); + 'Cannot open phase because predecessor phase must be closed with both actualStartDate and actualEndDate set' + ) } } if (isOpeningPhase) { - const phaseName = data.name || challengePhase.name; - await ensureRequiredResourcesBeforeOpeningPhase(challengeId, phaseName); + const phaseName = data.name || challengePhase.name + await ensureRequiredResourcesBeforeOpeningPhase(challengeId, phaseName) // Check if this is the Appeals phase - const normalizedPhaseName = normalizePhaseName(phaseName); - if (normalizedPhaseName === "appeals") { - const hasPendingEscalations = await hasPendingEscalationRequestsForChallenge(challengeId); + const normalizedPhaseName = normalizePhaseName(phaseName) + if (normalizedPhaseName === 'appeals') { + const hasPendingEscalations = await hasPendingEscalationRequestsForChallenge(challengeId) if (hasPendingEscalations) { throw new errors.BadRequestError( - "Cannot open Appeals phase because there are pending escalation requests that need to be resolved first" - ); + 'Cannot open Appeals phase because there are pending escalation requests that need to be resolved first' + ) } } } - if (data["scheduledStartDate"] || data["scheduledEndDate"]) { - const startDate = data["scheduledStartDate"] || challengePhase.scheduledStartDate; - const endDate = data["scheduledEndDate"] || challengePhase.scheduledEndDate; + if (data.scheduledStartDate || data.scheduledEndDate) { + const startDate = data.scheduledStartDate || challengePhase.scheduledStartDate + const endDate = data.scheduledEndDate || challengePhase.scheduledEndDate if (moment(startDate).isAfter(moment(endDate))) { throw new errors.BadRequestError( `scheduledStartDate: ${startDate.toISOString()} should not be after scheduledEndDate: ${endDate.toISOString()}` - ); + ) } } - if (data["actualStartDate"] || data["actualEndDate"]) { - const startDate = data["actualStartDate"] || challengePhase.actualStartDate; - const endDate = data["actualEndDate"] || challengePhase.actualEndDate; + if (data.actualStartDate || data.actualEndDate) { + const startDate = data.actualStartDate || challengePhase.actualStartDate + const endDate = data.actualEndDate || challengePhase.actualEndDate if (moment(startDate).isAfter(moment(endDate))) { throw new errors.BadRequestError( `actualStartDate: ${startDate.toISOString()} should not be after actualEndDate: ${endDate.toISOString()}` - ); + ) } } - if (data["constraints"] && data["constraints"].length > 0) { - for (const constrain of data["constraints"]) { + if (data.constraints && data.constraints.length > 0) { + for (const constrain of data.constraints) { if (constrain.id && !challengePhase.constraints.some((cst) => cst.id === constrain.id)) { throw new errors.BadRequestError( `constraint: ${constrain.id} is not exists for the ChallengePhase` - ); + ) } } } const isClosingPhase = - "isOpen" in data && data["isOpen"] === false && Boolean(challengePhase.isOpen); - const isReopeningPhase = - "isOpen" in data && data["isOpen"] === true && !challengePhase.isOpen; + 'isOpen' in data && data.isOpen === false && Boolean(challengePhase.isOpen) + const isReopeningPhase = 'isOpen' in data && data.isOpen === true && !challengePhase.isOpen if (isClosingPhase) { - const closingPhaseName = data.name || challengePhase.name; - const normalizedClosingPhaseName = normalizePhaseName(closingPhaseName); - const pendingScorecards = await hasPendingScorecardsForPhase(challengePhase.id); + const closingPhaseName = data.name || challengePhase.name + const normalizedClosingPhaseName = normalizePhaseName(closingPhaseName) + const pendingScorecards = await hasPendingScorecardsForPhase(challengePhase.id) if (pendingScorecards) { - const phaseName = closingPhaseName || "phase"; + const phaseName = closingPhaseName || 'phase' throw new errors.ForbiddenError( `Cannot close ${phaseName} because there are still pending scorecards` - ); + ) } if ( - normalizedClosingPhaseName === "review" && + normalizedClosingPhaseName === 'review' && (await hasPendingEscalationRequestsForChallenge(challengePhase.challengeId)) ) { throw new errors.BadRequestError( - "Cannot close Review phase because there are pending escalation requests that need to be resolved first" - ); + 'Cannot close Review phase because there are pending escalation requests that need to be resolved first' + ) } if ( - normalizedClosingPhaseName === "appeals response" && + normalizedClosingPhaseName === 'appeals response' && (await hasPendingAppealResponsesForChallenge(challengePhase.challengeId)) ) { throw new errors.BadRequestError( "Appeals Response phase can't be closed because there are still appeals that haven't been responded to" - ); + ) } - if (normalizedClosingPhaseName === "ai screening") { - await ensureAIScreeningCanBeClosed(challengePhase.challengeId); + if (normalizedClosingPhaseName === 'ai screening') { + await ensureAIScreeningCanBeClosed(challengePhase.challengeId) } - if (!("actualEndDate" in data) || _.isNil(data.actualEndDate)) { - data.actualEndDate = new Date(); + if (!('actualEndDate' in data) || _.isNil(data.actualEndDate)) { + data.actualEndDate = new Date() } } if (isReopeningPhase) { - const phaseName = challengePhase.name; + const phaseName = challengePhase.name const openPhases = await prisma.challengePhase.findMany({ where: { challengeId: challengePhase.challengeId, - isOpen: true, + isOpen: true }, select: { id: true, phaseId: true, predecessor: true, - name: true, - }, - }); + name: true + } + }) const activeReviewPhase = openPhases.find((phase) => - REVIEW_PHASE_NAME_SET.has(String(phase?.name || "").toLowerCase()) - ); + REVIEW_PHASE_NAME_SET.has(String(phase?.name || '').toLowerCase()) + ) if (activeReviewPhase) { - const hasActiveScorecards = await hasCompletedReviewsForPhase( - activeReviewPhase.id - ); + const hasActiveScorecards = await hasCompletedReviewsForPhase(activeReviewPhase.id) if (hasActiveScorecards) { throw new errors.BadRequestError( `Cannot reopen ${phaseName} because the currently open phase '${activeReviewPhase.name}' has reviews in progress or completed` - ); + ) } } - if (phaseName === "Submission" || phaseName === "Registration") { - const hasCompletedReviews = await hasCompletedReviewsForPhase(challengePhase.id); + if (phaseName === 'Submission' || phaseName === 'Registration') { + const hasCompletedReviews = await hasCompletedReviewsForPhase(challengePhase.id) if (hasCompletedReviews) { throw new errors.ForbiddenError( - "Cannot reopen Submission/Registration phase because reviews are already in progress or completed" - ); + 'Cannot reopen Submission/Registration phase because reviews are already in progress or completed' + ) } } - if (phaseName === "Checkpoint Submission") { + if (phaseName === 'Checkpoint Submission') { const checkpointPhases = await prisma.challengePhase.findMany({ where: { challengeId: challengePhase.challengeId, - name: { in: ["Checkpoint Screening", "Checkpoint Review"] }, + name: { in: ['Checkpoint Screening', 'Checkpoint Review'] } }, - select: { id: true }, - }); - const checkpointPhaseIds = checkpointPhases.map((cp) => cp.id); + select: { id: true } + }) + const checkpointPhaseIds = checkpointPhases.map((cp) => cp.id) if (checkpointPhaseIds.length > 0) { - const hasCheckpointReviews = await hasCompletedReviewsForPhase(checkpointPhaseIds); + const hasCheckpointReviews = await hasCompletedReviewsForPhase(checkpointPhaseIds) if (hasCheckpointReviews) { throw new errors.ForbiddenError( - "Cannot reopen Checkpoint Submission phase because Checkpoint Screening or Checkpoint Review reviews are already in progress or completed" - ); + 'Cannot reopen Checkpoint Submission phase because Checkpoint Screening or Checkpoint Review reviews are already in progress or completed' + ) } } } - const hasActualStartDate = !_.isNil(challengePhase.actualStartDate); - const hasActualEndDate = !_.isNil(challengePhase.actualEndDate); + const hasActualStartDate = !_.isNil(challengePhase.actualStartDate) + const hasActualEndDate = !_.isNil(challengePhase.actualEndDate) if (hasActualStartDate && hasActualEndDate && openPhases.length > 0) { - const reopenedPhaseIdentifiers = new Set([String(challengePhase.id)]); + const reopenedPhaseIdentifiers = new Set([String(challengePhase.id)]) if (!_.isNil(challengePhase.phaseId)) { - reopenedPhaseIdentifiers.add(String(challengePhase.phaseId)); + reopenedPhaseIdentifiers.add(String(challengePhase.phaseId)) } const dependentOpenPhases = openPhases.filter((phase) => { if (!phase || _.isNil(phase.predecessor)) { - return false; + return false } - return reopenedPhaseIdentifiers.has(String(phase.predecessor)); - }); - - const normalizedPhaseName = normalizePhaseName(phaseName); - const hasSubmissionVariantOpen = openPhases.some((phase) => - SUBMISSION_PHASE_NAME_SET.has(normalizePhaseName(phase?.name)) - ); - const allowRegistrationReopenWithoutExplicitDependency = - normalizedPhaseName === REGISTRATION_PHASE_NAME && hasSubmissionVariantOpen; + return reopenedPhaseIdentifiers.has(String(phase.predecessor)) + }) - if (dependentOpenPhases.length === 0 && !allowRegistrationReopenWithoutExplicitDependency) { + if (dependentOpenPhases.length === 0) { throw new errors.ForbiddenError( `Cannot reopen ${phaseName} because no currently open phase depends on it` - ); + ) } const appealsDependentPhaseExists = dependentOpenPhases.some( - (phase) => String(phase.name || "").toLowerCase() === "appeals" - ); + (phase) => String(phase.name || '').toLowerCase() === 'appeals' + ) if (appealsDependentPhaseExists) { const hasSubmittedAppeals = await hasSubmittedAppealsForChallenge( challengePhase.challengeId - ); + ) if (hasSubmittedAppeals) { throw new errors.ForbiddenError( `Cannot reopen ${phaseName} because submitted appeals already exist in the Appeals phase` - ); + ) } } } if (hasActualStartDate) { - data.actualStartDate = challengePhase.actualStartDate; - } else if (!("actualStartDate" in data) || _.isNil(data.actualStartDate)) { - data.actualStartDate = new Date(); + data.actualStartDate = challengePhase.actualStartDate + } else if (!('actualStartDate' in data) || _.isNil(data.actualStartDate)) { + data.actualStartDate = new Date() } - data.actualEndDate = null; + data.actualEndDate = null } // Update ChallengePhase - const currentUserId = String(currentUser.userId); - data.updatedBy = currentUserId; + const currentUserId = String(currentUser.userId) + data.updatedBy = currentUserId if (!_.isNil(data.duration)) { const startInput = !_.isNil(data.scheduledStartDate) ? data.scheduledStartDate : !_.isNil(challengePhase.scheduledStartDate) - ? challengePhase.scheduledStartDate - : null; + ? challengePhase.scheduledStartDate + : null if (startInput) { - const startDate = new Date(startInput); + const startDate = new Date(startInput) if (!Number.isNaN(startDate.getTime())) { const recalculatedScheduledEndDate = new Date( startDate.getTime() + Number(data.duration) * 1000 - ); - data.scheduledEndDate = recalculatedScheduledEndDate; + ) + data.scheduledEndDate = recalculatedScheduledEndDate } } } - const dataToUpdate = _.omit(data, "constraints"); + const dataToUpdate = _.omit(data, 'constraints') const shouldRefreshPhaseNames = - Object.prototype.hasOwnProperty.call(data, "isOpen") || - Object.prototype.hasOwnProperty.call(data, "name"); + Object.prototype.hasOwnProperty.call(data, 'isOpen') || + Object.prototype.hasOwnProperty.call(data, 'name') const result = await prisma.$transaction(async (tx) => { const updatedPhase = await tx.challengePhase.update({ data: dataToUpdate, where: { - id: challengePhase.id, - }, - }); - let scheduleExtended = false; + id: challengePhase.id + } + }) + let scheduleExtended = false if (shouldAttemptSuccessorRecalc) { - scheduleExtended = dateIsAfter( - updatedPhase.scheduledEndDate, - originalScheduledEndDate - ); + scheduleExtended = dateIsAfter(updatedPhase.scheduledEndDate, originalScheduledEndDate) if (scheduleExtended) { - await recalculateDependentPhaseDates(tx, challengeId, updatedPhase, currentUserId); + await recalculateDependentPhaseDates(tx, challengeId, updatedPhase, currentUserId) } } - if (isClosingPhase && !_.isNil(originalScheduledEndDate) && !_.isNil(updatedPhase.actualEndDate)) { + if ( + isClosingPhase && + !_.isNil(originalScheduledEndDate) && + !_.isNil(updatedPhase.actualEndDate) + ) { const shiftBaselineScheduledEndDate = scheduleExtended && !_.isNil(updatedPhase.scheduledEndDate) ? updatedPhase.scheduledEndDate - : originalScheduledEndDate; - const scheduledEndTime = new Date(shiftBaselineScheduledEndDate).getTime(); - const actualEndTime = new Date(updatedPhase.actualEndDate).getTime(); + : originalScheduledEndDate + const scheduledEndTime = new Date(shiftBaselineScheduledEndDate).getTime() + const actualEndTime = new Date(updatedPhase.actualEndDate).getTime() if (Number.isFinite(scheduledEndTime) && Number.isFinite(actualEndTime)) { await shiftDependentPhaseDates( @@ -923,22 +911,22 @@ async function partiallyUpdateChallengePhase(currentUser, challengeId, id, data) updatedPhase, actualEndTime - scheduledEndTime, currentUserId - ); + ) } } - if (data["constraints"]) { - for (const constraint of data["constraints"]) { + if (data.constraints) { + for (const constraint of data.constraints) { if (constraint.id) { await tx.challengePhaseConstraint.update({ data: { name: constraint.name, value: constraint.value, - updatedBy: currentUserId, + updatedBy: currentUserId }, where: { - id: constraint.id, - }, - }); + id: constraint.id + } + }) } else { await tx.challengePhaseConstraint.create({ data: { @@ -946,105 +934,102 @@ async function partiallyUpdateChallengePhase(currentUser, challengeId, id, data) value: constraint.value, challengePhaseId: updatedPhase.id, createdBy: currentUserId, - updatedBy: currentUserId, - }, - }); + updatedBy: currentUserId + } + }) } } } if (shouldRefreshPhaseNames) { const openPhases = await tx.challengePhase.findMany({ where: { challengeId, isOpen: true }, - select: { name: true }, - }); + select: { name: true } + }) const currentPhaseNames = _.uniq( openPhases.map((phase) => phase.name).filter((name) => !_.isNil(name)) - ); + ) await tx.challenge.update({ where: { id: challengeId }, data: { currentPhaseNames, - updatedBy: currentUserId, - }, - }); + updatedBy: currentUserId + } + }) } - return updatedPhase; - }); - helper.flushInternalCache(); + return updatedPhase + }) + helper.flushInternalCache() // post bus event await helper.postBusEvent( constants.Topics.ChallengePhaseUpdated, _.assignIn({ id: result.id }, data) - ); - await postChallengeUpdatedNotification(challengeId); + ) + await postChallengeUpdatedNotification(challengeId) // send notification logic try { - const shouldNotifyClose = Boolean(isClosingPhase); - const shouldNotifyOpen = Boolean(isOpeningPhase); // includes reopen + const shouldNotifyClose = Boolean(isClosingPhase) + const shouldNotifyOpen = Boolean(isOpeningPhase) // includes reopen if (shouldNotifyClose || shouldNotifyOpen) { // Single template - single type - const notificationType = "PHASE_CHANGE"; + const notificationType = 'PHASE_CHANGE' - const operation = shouldNotifyClose - ? "close" - : (isReopeningPhase ? "reopen" : "open"); + const operation = shouldNotifyClose ? 'close' : isReopeningPhase ? 'reopen' : 'open' const at = shouldNotifyClose - ? (result.actualEndDate || new Date().toISOString()) - : (result.actualStartDate || new Date().toISOString()); + ? result.actualEndDate || new Date().toISOString() + : result.actualStartDate || new Date().toISOString() // fetch challenge name const challenge = await prisma.challenge.findUnique({ where: { id: challengeId }, - select: { name: true }, - }); + select: { name: true } + }) - const challengeName = challenge?.name; + const challengeName = challenge?.name // build recipients - const resources = await helper.getChallengeResources(challengeId); + const resources = await helper.getChallengeResources(challengeId) const recipients = Array.from( new Set( (resources || []) - .map(r => r?.email || r?.memberEmail) + .map((r) => r?.email || r?.memberEmail) .filter(Boolean) - .map(e => String(e).trim().toLowerCase()) + .map((e) => String(e).trim().toLowerCase()) ) - ); + ) if (!recipients.length) { logger.debug( `phase change notification skipped: no recipients for challenge ${challengeId}` - ); - return _.omit(result, constants.auditFields); + ) + return _.omit(result, constants.auditFields) } // build payload that matches the SendGrid HTML template - const phaseName = result.name || data.name || challengePhase.name; + const phaseName = result.name || data.name || challengePhase.name const payload = helper.buildPhaseChangeEmailData({ challengeId, challengeName, phaseName, operation, - at, - }); + at + }) - await helper.sendPhaseChangeNotification(notificationType, recipients, payload); + await helper.sendPhaseChangeNotification(notificationType, recipients, payload) } } catch (e) { logger.debug( `phase change notification failed for challenge ${challengeId}, phase ${id}: ${e.message}` - ); + ) } - return _.omit(result, constants.auditFields); + return _.omit(result, constants.auditFields) } - partiallyUpdateChallengePhase.schema = { currentUser: Joi.any(), challengeId: Joi.id(), @@ -1066,12 +1051,12 @@ partiallyUpdateChallengePhase.schema = { Joi.object({ id: Joi.any().optional(), name: Joi.string().required(), - value: Joi.number().integer().min(0).required(), + value: Joi.number().integer().min(0).required() }) ) - .optional(), - }), -}; + .optional() + }) +} /** * Delete challenge phase. @@ -1080,15 +1065,15 @@ partiallyUpdateChallengePhase.schema = { * @param {String} id the phase id * @returns {Object} the deleted challenge phase */ -async function deleteChallengePhase(currentUser, challengeId, id) { - await checkChallengeExists(challengeId); +async function deleteChallengePhase (currentUser, challengeId, id) { + await checkChallengeExists(challengeId) const result = await prisma.challengePhase.findFirst({ - where: { challengeId, id }, - }); + where: { challengeId, id } + }) if (!result) { throw new errors.NotFoundError( `ChallengePhase with challengeId: ${challengeId}, phaseId: ${id} doesn't exist` - ); + ) } await prisma.$transaction(async (tx) => { // recalculates the predecessors @@ -1097,45 +1082,45 @@ async function deleteChallengePhase(currentUser, challengeId, id) { // if result.predecessor exists, update successor's predecessor to predecessor of current challenge phase // otherwise update successor's predecessor to null predecessor: result.predecessor || null, - updatedBy: String(currentUser.userId), + updatedBy: String(currentUser.userId) }, where: { challengeId, - predecessor: result.id, - }, - }); + predecessor: result.id + } + }) // delete challengePhaseConstraint await tx.challengePhaseConstraint.deleteMany({ where: { - challengePhaseId: result.id, - }, - }); + challengePhaseId: result.id + } + }) // delete challengePhase await tx.challengePhase.delete({ where: { - id: result.id, - }, - }); - }); - helper.flushInternalCache(); - const ret = _.omit(result, constants.auditFields); + id: result.id + } + }) + }) + helper.flushInternalCache() + const ret = _.omit(result, constants.auditFields) // post bus event - await helper.postBusEvent(constants.Topics.ChallengePhaseDeleted, ret); - await postChallengeUpdatedNotification(challengeId); - return ret; + await helper.postBusEvent(constants.Topics.ChallengePhaseDeleted, ret) + await postChallengeUpdatedNotification(challengeId) + return ret } deleteChallengePhase.schema = { currentUser: Joi.any(), challengeId: Joi.id(), - id: Joi.id(), -}; + id: Joi.id() +} module.exports = { getAllChallengePhases, getChallengePhase, partiallyUpdateChallengePhase, - deleteChallengePhase, -}; + deleteChallengePhase +} -logger.buildService(module.exports); +logger.buildService(module.exports) diff --git a/test/unit/ChallengePhaseService.test.js b/test/unit/ChallengePhaseService.test.js index c828f44..33d4318 100644 --- a/test/unit/ChallengePhaseService.test.js +++ b/test/unit/ChallengePhaseService.test.js @@ -9,9 +9,12 @@ require('../../app-bootstrap') const chai = require('chai') const config = require('config') const { Prisma } = require('@prisma/client') -const { v4: uuid } = require('uuid'); +const { v4: uuid } = require('uuid') +const challengeService = require('../../src/services/ChallengeService') const { getReviewClient } = require('../../src/common/review-prisma') const prisma = require('../../src/common/prisma').getClient() +const originalIndexChallengeAndPostToKafka = challengeService.indexChallengeAndPostToKafka +challengeService.indexChallengeAndPostToKafka = async () => {} const service = require('../../src/services/ChallengePhaseService') const helper = require('../../src/common/helper') const testHelper = require('../testHelper') @@ -29,6 +32,67 @@ describe('challenge phase service unit tests', () => { const appealTable = Prisma.raw(`"${reviewSchema}"."appeal"`) let reviewClient const shortId = () => uuid().replace(/-/g, '').slice(0, 14) + const resetPrimaryChallengePhases = async () => { + await prisma.challengePhaseConstraint.update({ + where: { id: data.challengePhaseConstrain1Id }, + data: { + name: 'constraint-name-1', + value: 100, + updatedBy: 'admin' + } + }) + await prisma.challengePhaseConstraint.updateMany({ + where: { challengePhaseId: data.challengePhase2Id }, + data: { + name: 'constraint-name-2', + value: 200, + updatedBy: 'admin' + } + }) + await prisma.challengePhase.update({ + where: { id: data.challengePhase1Id }, + data: { + phaseId: data.phase.id, + name: 'Registration', + duration: 1000, + predecessor: null, + isOpen: false, + scheduledStartDate: null, + scheduledEndDate: null, + actualStartDate: null, + actualEndDate: null, + updatedBy: 'admin' + } + }) + await prisma.challengePhase.update({ + where: { id: data.challengePhase2Id }, + data: { + phaseId: data.phase2.id, + name: 'Submission', + duration: 2000, + predecessor: data.challengePhase1Id, + isOpen: false, + scheduledStartDate: null, + scheduledEndDate: null, + actualStartDate: null, + actualEndDate: null, + updatedBy: 'admin' + } + }) + await prisma.challenge.update({ + where: { id: data.challenge.id }, + data: { + currentPhaseNames: [], + updatedBy: 'admin' + } + }) + await reviewClient.$executeRawUnsafe(`TRUNCATE TABLE "${reviewSchema}"."appealResponse"`) + await reviewClient.$executeRawUnsafe(`TRUNCATE TABLE "${reviewSchema}"."appeal"`) + await reviewClient.$executeRawUnsafe(`TRUNCATE TABLE "${reviewSchema}"."reviewItemComment"`) + await reviewClient.$executeRawUnsafe(`TRUNCATE TABLE "${reviewSchema}"."reviewItem"`) + await reviewClient.$executeRawUnsafe(`TRUNCATE TABLE "${reviewSchema}"."review"`) + await reviewClient.$executeRawUnsafe(`TRUNCATE TABLE "${reviewSchema}"."submission"`) + } before(async () => { await testHelper.createData() data = testHelper.getData() @@ -90,6 +154,8 @@ describe('challenge phase service unit tests', () => { }) after(async () => { + challengeService.indexChallengeAndPostToKafka = originalIndexChallengeAndPostToKafka + if (reviewClient) { await reviewClient.$executeRawUnsafe(`TRUNCATE TABLE "${reviewSchema}"."appealResponse"`) await reviewClient.$executeRawUnsafe(`TRUNCATE TABLE "${reviewSchema}"."appeal"`) @@ -152,7 +218,10 @@ describe('challenge phase service unit tests', () => { try { await service.getChallengePhase(data.taskChallenge.id, data.challengePhase2Id) } catch (e) { - should.equal(e.message, `ChallengePhase with challengeId: ${data.taskChallenge.id}, phaseId: ${data.challengePhase2Id} doesn't exist`) + should.equal( + e.message, + `ChallengePhase with challengeId: ${data.taskChallenge.id}, phaseId: ${data.challengePhase2Id} doesn't exist` + ) return } throw new Error('should not reach here') @@ -180,32 +249,46 @@ describe('challenge phase service unit tests', () => { }) describe('partially update challenge phase tests', () => { + beforeEach(async () => { + await resetPrimaryChallengePhases() + }) + it('partially update challenge phase successfully', async function () { this.timeout(50000) const scheduledStartDate = '2025-01-01T00:00:00.000Z' - const expectedScheduledEndDate = new Date(new Date(scheduledStartDate).getTime() + 7200 * 1000).toISOString() - const challengePhase = await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase1Id, { - name: 'updated-Registration', - isOpen: true, - duration: 7200, - scheduledStartDate, - constraints: [ - { - id: data.challengePhaseConstrain1Id, - name: 'u1', - value: 10 - }, - { - name: 'i1', - value: 20 - } - ] - }) + const expectedScheduledEndDate = new Date( + new Date(scheduledStartDate).getTime() + 7200 * 1000 + ).toISOString() + const challengePhase = await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + data.challengePhase1Id, + { + name: 'updated-Registration', + isOpen: true, + duration: 7200, + scheduledStartDate, + constraints: [ + { + id: data.challengePhaseConstrain1Id, + name: 'u1', + value: 10 + }, + { + name: 'i1', + value: 20 + } + ] + } + ) should.equal(challengePhase.name, 'updated-Registration') should.equal(challengePhase.duration, 7200) should.equal(challengePhase.isOpen, true) should.equal(new Date(challengePhase.scheduledStartDate).toISOString(), scheduledStartDate) - should.equal(new Date(challengePhase.scheduledEndDate).toISOString(), expectedScheduledEndDate) + should.equal( + new Date(challengePhase.scheduledEndDate).toISOString(), + expectedScheduledEndDate + ) }) it('partially update challenge phase - closing sets actual end date', async () => { @@ -215,9 +298,14 @@ describe('challenge phase service unit tests', () => { }) const before = new Date() - const challengePhase = await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase1Id, { - isOpen: false - }) + const challengePhase = await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + data.challengePhase1Id, + { + isOpen: false + } + ) const after = new Date() should.equal(challengePhase.isOpen, false) @@ -295,7 +383,8 @@ describe('challenge phase service unit tests', () => { const actualEndMs = new Date(challengePhase.actualEndDate).getTime() const successorStartMs = new Date(successorPhase.scheduledStartDate).getTime() const successorEndMs = new Date(successorPhase.scheduledEndDate).getTime() - const aiScreeningDurationMs = aiScreeningScheduledEndDate.getTime() - aiScreeningScheduledStartDate.getTime() + const aiScreeningDurationMs = + aiScreeningScheduledEndDate.getTime() - aiScreeningScheduledStartDate.getTime() actualEndMs.should.be.at.least(before.getTime()) actualEndMs.should.be.at.most(after.getTime()) @@ -319,9 +408,14 @@ describe('challenge phase service unit tests', () => { }) const before = new Date() - const challengePhase = await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase1Id, { - isOpen: true - }) + const challengePhase = await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + data.challengePhase1Id, + { + isOpen: true + } + ) const after = new Date() should.equal(challengePhase.isOpen, true) @@ -352,9 +446,14 @@ describe('challenge phase service unit tests', () => { } }) - const challengePhase = await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase1Id, { - isOpen: true - }) + const challengePhase = await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + data.challengePhase1Id, + { + isOpen: true + } + ) should.equal(challengePhase.isOpen, true) should.equal(challengePhase.actualEndDate, null) @@ -414,9 +513,59 @@ describe('challenge phase service unit tests', () => { } }) - it('partially update challenge phase - can reopen registration when submission is open', async () => { + it('partially update challenge phase - cannot reopen registration when open submission depends on checkpoint review', async () => { const startDate = new Date('2025-06-01T00:00:00.000Z') const endDate = new Date('2025-06-02T00:00:00.000Z') + const checkpointReviewPhase = await prisma.phase.create({ + data: { + id: uuid(), + name: 'Checkpoint Review', + description: 'desc', + isOpen: false, + duration: 3600, + createdBy: 'admin', + updatedBy: 'admin' + } + }) + const checkpointReviewChallengePhaseId = uuid() + const checkpointReviewStartDate = new Date('2025-06-02T00:00:00.000Z') + const checkpointReviewEndDate = new Date('2025-06-03T00:00:00.000Z') + const registrationOriginalData = await prisma.challengePhase.findUnique({ + where: { id: data.challengePhase1Id }, + select: { + actualEndDate: true, + actualStartDate: true, + isOpen: true, + name: true, + predecessor: true + } + }) + const submissionOriginalData = await prisma.challengePhase.findUnique({ + where: { id: data.challengePhase2Id }, + select: { + actualEndDate: true, + actualStartDate: true, + isOpen: true, + name: true, + predecessor: true + } + }) + + await prisma.challengePhase.create({ + data: { + id: checkpointReviewChallengePhaseId, + challengeId: data.challenge.id, + phaseId: checkpointReviewPhase.id, + name: 'Checkpoint Review', + duration: 1000, + isOpen: false, + predecessor: data.phase.id, + actualStartDate: checkpointReviewStartDate, + actualEndDate: checkpointReviewEndDate, + createdBy: 'admin', + updatedBy: 'admin' + } + }) await prisma.challengePhase.update({ where: { id: data.challengePhase1Id }, @@ -430,12 +579,14 @@ describe('challenge phase service unit tests', () => { where: { id: data.challengePhase2Id }, data: { isOpen: true, - predecessor: null + actualStartDate: checkpointReviewEndDate, + actualEndDate: null, + predecessor: checkpointReviewPhase.id } }) try { - const challengePhase = await service.partiallyUpdateChallengePhase( + await service.partiallyUpdateChallengePhase( authUser, data.challenge.id, data.challengePhase1Id, @@ -443,31 +594,30 @@ describe('challenge phase service unit tests', () => { isOpen: true } ) - should.equal(challengePhase.id, data.challengePhase1Id) - should.equal(challengePhase.isOpen, true) - should.equal(challengePhase.actualEndDate, null) + } catch (e) { + should.equal(e.httpStatus || e.statusCode, 403) + should.equal( + e.message, + 'Cannot reopen Registration because no currently open phase depends on it' + ) + return } finally { await prisma.challengePhase.update({ - where: { id: data.challengePhase1Id }, - data: { - isOpen: false, - actualStartDate: startDate, - actualEndDate: endDate - } + where: { id: data.challengePhase2Id }, + data: submissionOriginalData }) await prisma.challengePhase.update({ - where: { id: data.challengePhase2Id }, - data: { - isOpen: false, - predecessor: data.challengePhase1Id, - name: 'Submission' - } + where: { id: data.challengePhase1Id }, + data: registrationOriginalData }) + await prisma.challengePhase.delete({ where: { id: checkpointReviewChallengePhaseId } }) + await prisma.phase.delete({ where: { id: checkpointReviewPhase.id } }) } + throw new Error('should not reach here') }) - it('partially update challenge phase - cannot reopen when open phase is not a successor or submission variant', async () => { + it('partially update challenge phase - cannot reopen when open phase is not a successor', async () => { const startDate = new Date('2025-06-01T00:00:00.000Z') const endDate = new Date('2025-06-02T00:00:00.000Z') @@ -489,9 +639,14 @@ describe('challenge phase service unit tests', () => { }) try { - await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase1Id, { - isOpen: true - }) + await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + data.challengePhase1Id, + { + isOpen: true + } + ) } catch (e) { should.equal(e.httpStatus || e.statusCode, 403) should.equal( @@ -570,9 +725,14 @@ describe('challenge phase service unit tests', () => { ) try { - await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase1Id, { - isOpen: true - }) + await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + data.challengePhase1Id, + { + isOpen: true + } + ) } catch (e) { should.equal(e.httpStatus || e.statusCode, 400) should.equal( @@ -581,7 +741,9 @@ describe('challenge phase service unit tests', () => { ) return } finally { - await reviewClient.$executeRaw(Prisma.sql`DELETE FROM ${reviewTable} WHERE "id" = ${reviewId}`) + await reviewClient.$executeRaw( + Prisma.sql`DELETE FROM ${reviewTable} WHERE "id" = ${reviewId}` + ) await prisma.challengePhase.delete({ where: { id: reviewChallengePhaseId } }) await prisma.phase.delete({ where: { id: reviewPhase.id } }) await prisma.challengePhase.update({ @@ -685,11 +847,25 @@ describe('challenge phase service unit tests', () => { VALUES (${appealId}, ${reviewItemCommentId}) ` ) + const originalGetChallengeResources = helper.getChallengeResources + const originalGetResourceRoles = helper.getResourceRoles + helper.getChallengeResources = async () => [ + { + roleId: 'reviewer-role-id', + resourceRole: { name: 'Reviewer' } + } + ] + helper.getResourceRoles = async () => [{ id: 'reviewer-role-id', name: 'Reviewer' }] try { - await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, reviewChallengePhaseId, { - isOpen: true - }) + await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + reviewChallengePhaseId, + { + isOpen: true + } + ) } catch (e) { should.equal(e.httpStatus || e.statusCode, 403) should.equal( @@ -698,13 +874,23 @@ describe('challenge phase service unit tests', () => { ) return } finally { - await reviewClient.$executeRaw(Prisma.sql`DELETE FROM ${appealTable} WHERE "id" = ${appealId}`) + await reviewClient.$executeRaw( + Prisma.sql`DELETE FROM ${appealTable} WHERE "id" = ${appealId}` + ) await reviewClient.$executeRaw( Prisma.sql`DELETE FROM ${reviewItemCommentTable} WHERE "id" = ${reviewItemCommentId}` ) - await reviewClient.$executeRaw(Prisma.sql`DELETE FROM ${reviewItemTable} WHERE "id" = ${reviewItemId}`) - await reviewClient.$executeRaw(Prisma.sql`DELETE FROM ${reviewTable} WHERE "id" = ${reviewId}`) - await reviewClient.$executeRaw(Prisma.sql`DELETE FROM ${submissionTable} WHERE "id" = ${submissionId}`) + await reviewClient.$executeRaw( + Prisma.sql`DELETE FROM ${reviewItemTable} WHERE "id" = ${reviewItemId}` + ) + await reviewClient.$executeRaw( + Prisma.sql`DELETE FROM ${reviewTable} WHERE "id" = ${reviewId}` + ) + await reviewClient.$executeRaw( + Prisma.sql`DELETE FROM ${submissionTable} WHERE "id" = ${submissionId}` + ) + helper.getChallengeResources = originalGetChallengeResources + helper.getResourceRoles = originalGetResourceRoles await prisma.challengePhase.deleteMany({ where: { id: { in: [appealsChallengePhaseId, reviewChallengePhaseId] } } }) @@ -716,9 +902,17 @@ describe('challenge phase service unit tests', () => { it('partially update challenge phase - not found', async () => { try { - await service.partiallyUpdateChallengePhase(authUser, data.taskChallenge.id, data.challengePhase2Id, { name: 'updated', duration: 7200 }) + await service.partiallyUpdateChallengePhase( + authUser, + data.taskChallenge.id, + data.challengePhase2Id, + { name: 'updated', duration: 7200 } + ) } catch (e) { - should.equal(e.message, `ChallengePhase with challengeId: ${data.taskChallenge.id}, phaseId: ${data.challengePhase2Id} doesn't exist`) + should.equal( + e.message, + `ChallengePhase with challengeId: ${data.taskChallenge.id}, phaseId: ${data.challengePhase2Id} doesn't exist` + ) return } throw new Error('should not reach here') @@ -726,7 +920,12 @@ describe('challenge phase service unit tests', () => { it('partially update challenge phase - phaseId does not exist', async () => { try { - await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase1Id, { name: 'updated', phaseId: data.challenge.id, isOpen: null }) + await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + data.challengePhase1Id, + { name: 'updated', phaseId: data.challenge.id, isOpen: null } + ) } catch (e) { should.equal(e.message, 'phaseId should be a valid phase') return @@ -736,7 +935,12 @@ describe('challenge phase service unit tests', () => { it('partially update challenge phase - predecessor does not exist', async () => { try { - await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase1Id, { name: 'updated', predecessor: data.challenge.id }) + await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + data.challengePhase1Id, + { name: 'updated', predecessor: data.challenge.id } + ) } catch (e) { should.equal( e.message, @@ -751,9 +955,17 @@ describe('challenge phase service unit tests', () => { const startDate = '2025-04-04T04:38:00.000Z' const endDate = '2025-04-03T04:38:00.000Z' try { - await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase1Id, { name: 'updated', scheduledStartDate: startDate, scheduledEndDate: endDate }) + await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + data.challengePhase1Id, + { name: 'updated', scheduledStartDate: startDate, scheduledEndDate: endDate } + ) } catch (e) { - should.equal(e.message, `scheduledStartDate: ${startDate} should not be after scheduledEndDate: ${endDate}`) + should.equal( + e.message, + `scheduledStartDate: ${startDate} should not be after scheduledEndDate: ${endDate}` + ) return } throw new Error('should not reach here') @@ -763,9 +975,17 @@ describe('challenge phase service unit tests', () => { const startDate = '2025-04-04T04:38:00.000Z' const endDate = '2025-04-03T04:38:00.000Z' try { - await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase1Id, { name: 'updated', actualStartDate: startDate, actualEndDate: endDate }) + await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + data.challengePhase1Id, + { name: 'updated', actualStartDate: startDate, actualEndDate: endDate } + ) } catch (e) { - should.equal(e.message, `actualStartDate: ${startDate} should not be after actualEndDate: ${endDate}`) + should.equal( + e.message, + `actualStartDate: ${startDate} should not be after actualEndDate: ${endDate}` + ) return } throw new Error('should not reach here') @@ -773,16 +993,26 @@ describe('challenge phase service unit tests', () => { it('partially update challenge phase - constraint is not exists for the ChallengePhase', async () => { try { - await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase1Id, { - name: 'updated', - constraints: [{ - id: data.challenge.id, - name: 't1', - value: 100 - }] - }) + await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + data.challengePhase1Id, + { + name: 'updated', + constraints: [ + { + id: data.challenge.id, + name: 't1', + value: 100 + } + ] + } + ) } catch (e) { - should.equal(e.message, `constraint: ${data.challenge.id} is not exists for the ChallengePhase`) + should.equal( + e.message, + `constraint: ${data.challenge.id} is not exists for the ChallengePhase` + ) return } throw new Error('should not reach here') @@ -806,9 +1036,14 @@ describe('challenge phase service unit tests', () => { ` ) - await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase1Id, { - isOpen: false - }) + await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + data.challengePhase1Id, + { + isOpen: false + } + ) } catch (e) { caughtError = e } finally { @@ -819,7 +1054,10 @@ describe('challenge phase service unit tests', () => { should.exist(caughtError) should.equal(caughtError.httpStatus || caughtError.statusCode, 403) - should.equal(caughtError.message, 'Cannot close Registration because there are still pending scorecards') + should.equal( + caughtError.message, + 'Cannot close Registration because there are still pending scorecards' + ) }) it('partially update challenge phase - allows closing when scorecards are completed', async function () { @@ -839,9 +1077,14 @@ describe('challenge phase service unit tests', () => { ` ) - const challengePhase = await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase1Id, { - isOpen: false - }) + const challengePhase = await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + data.challengePhase1Id, + { + isOpen: false + } + ) should.equal(challengePhase.isOpen, false) } finally { await reviewClient.$executeRaw( @@ -910,7 +1153,9 @@ describe('challenge phase service unit tests', () => { const reviewEndDate = new Date(reviewStartDate.getTime() + reviewDuration * 1000) const appealsEndDate = new Date(reviewEndDate.getTime() + appealsDuration * 1000) - const appealsResponseEndDate = new Date(appealsEndDate.getTime() + appealsResponseDuration * 1000) + const appealsResponseEndDate = new Date( + appealsEndDate.getTime() + appealsResponseDuration * 1000 + ) const approvalEndDate = new Date(appealsResponseEndDate.getTime() + approvalDuration * 1000) await prisma.challengePhase.createMany({ @@ -1016,10 +1261,23 @@ describe('challenge phase service unit tests', () => { should.equal(new Date(updatedApproval.scheduledEndDate).toISOString(), expectedApprovalEnd) } finally { await prisma.challengePhase.deleteMany({ - where: { id: { in: [reviewChallengePhaseId, appealsChallengePhaseId, appealsResponseChallengePhaseId, approvalChallengePhaseId] } } + where: { + id: { + in: [ + reviewChallengePhaseId, + appealsChallengePhaseId, + appealsResponseChallengePhaseId, + approvalChallengePhaseId + ] + } + } }) await prisma.phase.deleteMany({ - where: { id: { in: [reviewPhase.id, appealsPhase.id, appealsResponsePhase.id, approvalPhase.id] } } + where: { + id: { + in: [reviewPhase.id, appealsPhase.id, appealsResponsePhase.id, approvalPhase.id] + } + } }) } }) @@ -1146,9 +1404,14 @@ describe('challenge phase service unit tests', () => { it('partially update challenge phase - unexpected field', async () => { try { - await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase1Id, { name: 'xx', other: 'xx' }) + await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + data.challengePhase1Id, + { name: 'xx', other: 'xx' } + ) } catch (e) { - should.equal(e.message.indexOf('"other" is not allowed') >= 0, true) + should.equal(e.message.indexOf('"data.other" is not allowed') >= 0, true) return } throw new Error('should not reach here') @@ -1161,7 +1424,12 @@ describe('challenge phase service unit tests', () => { }) try { - await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase2Id, { isOpen: true }) + await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + data.challengePhase2Id, + { isOpen: true } + ) } catch (e) { should.equal( e.message, @@ -1184,7 +1452,12 @@ describe('challenge phase service unit tests', () => { }) try { - await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase2Id, { isOpen: true }) + await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + data.challengePhase2Id, + { isOpen: true } + ) } catch (e) { should.equal( e.message, @@ -1207,7 +1480,12 @@ describe('challenge phase service unit tests', () => { }) try { - await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase2Id, { isOpen: true }) + await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + data.challengePhase2Id, + { isOpen: true } + ) } catch (e) { should.equal( e.message, @@ -1234,7 +1512,12 @@ describe('challenge phase service unit tests', () => { data: { isOpen: false } }) - const challengePhase = await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase2Id, { isOpen: true }) + const challengePhase = await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + data.challengePhase2Id, + { isOpen: true } + ) should.equal(challengePhase.isOpen, true) }) @@ -1248,7 +1531,12 @@ describe('challenge phase service unit tests', () => { } }) - const challengePhase = await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase1Id, { isOpen: true }) + const challengePhase = await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + data.challengePhase1Id, + { isOpen: true } + ) should.equal(challengePhase.isOpen, true) }) @@ -1279,15 +1567,16 @@ describe('challenge phase service unit tests', () => { const originalGetChallengeResources = helper.getChallengeResources const originalGetResourceRoles = helper.getResourceRoles - helper.getChallengeResources = async () => ([ - { roleId: 'some-other-role-id' } - ]) - helper.getResourceRoles = async () => ([ - { id: 'reviewer-role-id', name: 'Reviewer' } - ]) + helper.getChallengeResources = async () => [{ roleId: 'some-other-role-id' }] + helper.getResourceRoles = async () => [{ id: 'reviewer-role-id', name: 'Reviewer' }] try { - await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, reviewChallengePhaseId, { isOpen: true }) + await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + reviewChallengePhaseId, + { isOpen: true } + ) } catch (e) { should.equal(e.httpStatus || e.statusCode, 400) should.equal( @@ -1332,18 +1621,21 @@ describe('challenge phase service unit tests', () => { const originalGetChallengeResources = helper.getChallengeResources const originalGetResourceRoles = helper.getResourceRoles - helper.getChallengeResources = async () => ([ + helper.getChallengeResources = async () => [ { roleId: 'reviewer-role-id', resourceRole: { name: 'Reviewer' } } - ]) - helper.getResourceRoles = async () => ([ - { id: 'reviewer-role-id', name: 'Reviewer' } - ]) + ] + helper.getResourceRoles = async () => [{ id: 'reviewer-role-id', name: 'Reviewer' }] try { - const challengePhase = await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, reviewChallengePhaseId, { isOpen: true }) + const challengePhase = await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + reviewChallengePhaseId, + { isOpen: true } + ) should.equal(challengePhase.isOpen, true) } finally { helper.getChallengeResources = originalGetChallengeResources @@ -1366,7 +1658,10 @@ describe('challenge phase service unit tests', () => { try { await service.deleteChallengePhase(authUser, data.taskChallenge.id, data.challengePhase1Id) } catch (e) { - should.equal(e.message, `ChallengePhase with challengeId: ${data.taskChallenge.id}, phaseId: ${data.challengePhase1Id} doesn't exist`) + should.equal( + e.message, + `ChallengePhase with challengeId: ${data.taskChallenge.id}, phaseId: ${data.challengePhase1Id} doesn't exist` + ) return } throw new Error('should not reach here') From f52e93aa7e4bc8a1879072afcf58577b44cfdc2e Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 3 Apr 2026 08:32:39 +1100 Subject: [PATCH 21/27] Import provisional review summations with missing-member reconciliation Attach one provisional review summation to each imported non-example submission keyed by legacySubmissionId, and preserve deterministic missing-member skip behavior across apply reruns. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../importHistoricalMarathonMatches.js | 1 + .../importHistoricalMarathonMatches/apply.js | 58 ++ .../provisionalScores.js | 568 ++++++++++++++++++ ...thonMatches.applyProvisionalScores.test.js | 244 ++++++++ ...lMarathonMatches.provisionalScores.test.js | 246 ++++++++ 5 files changed, 1117 insertions(+) create mode 100644 data-migration/src/scripts/importHistoricalMarathonMatches/provisionalScores.js create mode 100644 data-migration/test/importHistoricalMarathonMatches.applyProvisionalScores.test.js create mode 100644 data-migration/test/importHistoricalMarathonMatches.provisionalScores.test.js diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches.js b/data-migration/src/scripts/importHistoricalMarathonMatches.js index 83899af..aa4c5c9 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches.js @@ -248,6 +248,7 @@ const run = async () => { reviewSchema: reviewDbSchema, importSubmissions: true, importFinalScores: true, + importProvisionalScores: true, }, plan, actor: DEFAULT_ACTOR, diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js index 23381ba..017c7d2 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js @@ -24,6 +24,11 @@ const { createReviewFinalScoreStore, reconcileRoundFinalScores, } = require("./finalScores"); +const { + loadLegacyProvisionalRowsByRoundId, + createReviewProvisionalScoreStore, + reconcileRoundProvisionalScores, +} = require("./provisionalScores"); const STANDARD_PHASE_NAMES = ["Registration", "Submission", "Review"]; const DEFAULT_SUBMITTER_ROLE_ID = "732339e7-8e30-49d7-9198-cccf9451e221"; @@ -589,6 +594,12 @@ const runApplyMode = async ({ planRecordByRoundId, affectedSurface: "final-score", }); + const missingMemberProvisionalSkipMemberIdsByRoundId = + collectMissingMemberSkipMemberIdsByRoundId({ + roundIds: options.roundIds, + planRecordByRoundId, + affectedSurface: "provisional-score", + }); const plannedUnattachableFinalSkipMemberIdsByRoundId = collectSkipMemberIdsByRoundId({ roundIds: options.roundIds, planRecordByRoundId, @@ -616,6 +627,7 @@ const runApplyMode = async ({ const submitterRoleId = String(options.submitterRoleId || DEFAULT_SUBMITTER_ROLE_ID).trim(); const submissionImportEnabled = options.importSubmissions === true; const finalScoreImportEnabled = options.importFinalScores === true; + const provisionalScoreImportEnabled = options.importProvisionalScores === true; const resourceClient = options.resourceClient; if (actionableRoundIds.length > 0 && !resourceClient) { @@ -636,6 +648,16 @@ const runApplyMode = async ({ "Review DB client is required for apply mode final-score reconciliation." ); } + if ( + actionableRoundIds.length > 0 && + provisionalScoreImportEnabled && + !options.reviewClient && + !options.provisionalScoreStore + ) { + throw new Error( + "Review DB client is required for apply mode provisional-score reconciliation." + ); + } const challengeStatusController = options.challengeStatusController || createPrismaChallengeStatusController({ prisma, actor }); @@ -686,8 +708,10 @@ const runApplyMode = async ({ let roundSubmissionRowsByRoundId = new Map(); let roundFinalRowsByRoundId = new Map(); + let roundProvisionalRowsByRoundId = new Map(); let submissionStore = null; let finalScoreStore = null; + let provisionalScoreStore = null; if (submissionImportEnabled && actionableRoundIds.length > 0) { roundSubmissionRowsByRoundId = await loadNonExampleLegacySubmissionRowsByRoundId({ dataDir: options.dataDir, @@ -718,6 +742,21 @@ const runApplyMode = async ({ actor, })); } + if (provisionalScoreImportEnabled && actionableRoundIds.length > 0) { + roundProvisionalRowsByRoundId = await loadLegacyProvisionalRowsByRoundId({ + dataDir: options.dataDir, + longComponentStateFile: options.longComponentStateFile, + longSubmissionPattern: options.longSubmissionPattern, + roundIds: actionableRoundIds, + }); + provisionalScoreStore = + options.provisionalScoreStore || + (await createReviewProvisionalScoreStore({ + reviewClient: options.reviewClient, + reviewSchema: options.reviewSchema || DEFAULT_REVIEW_SCHEMA, + actor, + })); + } let marathonTypeId = null; let dataScienceTrackId = null; @@ -817,6 +856,24 @@ const runApplyMode = async ({ if (finalScoreReconciliation && Array.isArray(finalScoreReconciliation.runtimeSkipRecords)) { runtimeSkipRecords.push(...finalScoreReconciliation.runtimeSkipRecords); } + const provisionalScoreReconciliation = + provisionalScoreImportEnabled && provisionalScoreStore + ? await reconcileRoundProvisionalScores({ + roundId, + challengeId: result.challengeId, + provisionalRowsByRoundId: roundProvisionalRowsByRoundId, + normalizedIdentityByCoderId, + missingMemberProvisionalSkipMemberIds: + missingMemberProvisionalSkipMemberIdsByRoundId.get(roundId) || new Set(), + provisionalScoreStore, + }) + : null; + if ( + provisionalScoreReconciliation && + Array.isArray(provisionalScoreReconciliation.skippedProvisionalRecords) + ) { + runtimeSkipRecords.push(...provisionalScoreReconciliation.skippedProvisionalRecords); + } applyRecords.push({ recordType: "apply-record", legacyRoundId: roundId, @@ -825,6 +882,7 @@ const runApplyMode = async ({ resourceReconciliation, ...(submissionReconciliation ? { submissionReconciliation } : {}), ...(finalScoreReconciliation ? { finalScoreReconciliation } : {}), + ...(provisionalScoreReconciliation ? { provisionalScoreReconciliation } : {}), }); } catch (error) { applyRecords.push({ diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/provisionalScores.js b/data-migration/src/scripts/importHistoricalMarathonMatches/provisionalScores.js new file mode 100644 index 0000000..3bd2597 --- /dev/null +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/provisionalScores.js @@ -0,0 +1,568 @@ +"use strict"; + +const { + ensureFileExists, + listFilesByPattern, + resolveFilePath, + streamJsonArray, +} = require("./legacyDataReader"); +const { deriveLegacySubmissionId } = require("./submissionHistory"); +const { + MISSING_MEMBER_REASON_CODE, +} = require("./skippedArtifact"); + +const DEFAULT_REVIEW_SCHEMA = "reviews"; + +const normalizeReviewSchema = (value) => { + const normalized = String(value || DEFAULT_REVIEW_SCHEMA).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(normalized)) { + throw new Error(`Invalid REVIEW_DB_SCHEMA "${normalized}"`); + } + return normalized; +}; + +const buildQualifiedTableName = (schemaName, tableName) => + `"${String(schemaName).replace(/"/g, "\"\"")}"."${String(tableName).replace(/"/g, "\"\"")}"`; + +const parsePositiveInteger = (value) => { + const parsed = Number.parseInt(String(value || "").trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +}; + +const parseEpochMs = (value) => { + const parsed = Number.parseInt(String(value || "").trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +}; + +const parseNumericScore = (value) => { + if (value === null || value === undefined) { + return null; + } + const normalized = String(value).trim(); + if (!normalized || normalized.toLowerCase() === "null") { + return null; + } + const parsed = Number.parseFloat(normalized); + if (!Number.isFinite(parsed)) { + return null; + } + return parsed; +}; + +const normalizeMemberId = (value) => { + const parsed = parsePositiveInteger(value); + if (!parsed) { + return null; + } + return String(parsed); +}; + +const resolveIdentityForCoderId = (coderId, normalizedIdentityByCoderId = new Map()) => { + const normalizedCoderId = String(coderId || "").trim(); + if (!normalizedCoderId) { + return null; + } + const knownIdentity = normalizedIdentityByCoderId.get(normalizedCoderId); + const knownMemberId = normalizeMemberId(knownIdentity && knownIdentity.memberId); + if (knownMemberId) { + return { + coderId: normalizedCoderId, + memberId: knownMemberId, + memberHandle: knownIdentity.memberHandle || null, + }; + } + + const fallbackMemberId = normalizeMemberId(normalizedCoderId); + if (!fallbackMemberId) { + return null; + } + return { + coderId: normalizedCoderId, + memberId: fallbackMemberId, + memberHandle: null, + }; +}; + +const compareProvisionalRows = (left, right) => { + const legacySubmissionDelta = String(left.legacySubmissionId || "").localeCompare( + String(right.legacySubmissionId || ""), + undefined, + { numeric: true } + ); + if (legacySubmissionDelta !== 0) { + return legacySubmissionDelta; + } + const leftSubmitTime = Number.isFinite(left.submitTimeMs) ? left.submitTimeMs : Number.MAX_SAFE_INTEGER; + const rightSubmitTime = Number.isFinite(right.submitTimeMs) ? right.submitTimeMs : Number.MAX_SAFE_INTEGER; + if (leftSubmitTime !== rightSubmitTime) { + return leftSubmitTime - rightSubmitTime; + } + return String(left.coderId || "").localeCompare(String(right.coderId || ""), undefined, { + numeric: true, + }); +}; + +const formatImportedCountsByMemberId = (countsByMemberId) => + Object.fromEntries( + Array.from(countsByMemberId.entries()).sort(([left], [right]) => + String(left).localeCompare(String(right), undefined, { numeric: true }) + ) + ); + +const loadLegacyProvisionalRowsByRoundId = async ({ + dataDir, + longComponentStateFile, + longSubmissionPattern, + roundIds, +}) => { + const selectedRoundIds = Array.from( + new Set((roundIds || []).map((roundId) => String(roundId || "").trim()).filter(Boolean)) + ); + const rowsByRoundId = new Map(selectedRoundIds.map((roundId) => [roundId, []])); + if (selectedRoundIds.length === 0) { + return rowsByRoundId; + } + + const longComponentStatePath = resolveFilePath(dataDir, longComponentStateFile); + ensureFileExists(longComponentStatePath, "long component state"); + const longSubmissionFiles = listFilesByPattern( + dataDir, + longSubmissionPattern, + "long submission" + ); + + const selectedRoundIdSet = new Set(selectedRoundIds); + const stateInfoById = new Map(); + await streamJsonArray(longComponentStatePath, "long_component_state", (row) => { + const roundId = String(row && row.round_id ? row.round_id : "").trim(); + if (!selectedRoundIdSet.has(roundId)) { + return; + } + const longComponentStateId = String( + row && row.long_component_state_id ? row.long_component_state_id : "" + ).trim(); + const coderId = String(row && row.coder_id ? row.coder_id : "").trim(); + if (!longComponentStateId || !coderId) { + return; + } + stateInfoById.set(longComponentStateId, { + legacyRoundId: roundId, + coderId, + }); + }); + + const generatedSubmissionOrdinalByStateId = new Map(); + await Promise.all( + longSubmissionFiles.map((filePath) => + streamJsonArray(filePath, "long_submission", (row) => { + const longComponentStateId = String( + row && row.long_component_state_id ? row.long_component_state_id : "" + ).trim(); + const stateInfo = stateInfoById.get(longComponentStateId); + if (!stateInfo) { + return; + } + + const isExample = String(row && row.example ? row.example : "").trim() === "1"; + if (isExample) { + return; + } + + const currentOrdinal = generatedSubmissionOrdinalByStateId.get(longComponentStateId) || 0; + const fallbackOrdinal = currentOrdinal + 1; + generatedSubmissionOrdinalByStateId.set(longComponentStateId, fallbackOrdinal); + const submissionNumber = + parsePositiveInteger(row && row.submission_number) || fallbackOrdinal; + const legacySubmissionId = deriveLegacySubmissionId({ + longComponentStateId, + submissionNumber, + }); + + rowsByRoundId.get(stateInfo.legacyRoundId).push({ + legacyRoundId: stateInfo.legacyRoundId, + coderId: stateInfo.coderId, + longComponentStateId, + submissionNumber, + legacySubmissionId, + submitTimeMs: parseEpochMs(row && row.submit_time), + aggregateScore: parseNumericScore(row && row.submission_points), + }); + }) + ) + ); + + rowsByRoundId.forEach((rows, roundId) => { + rowsByRoundId.set(roundId, [...rows].sort(compareProvisionalRows)); + }); + + return rowsByRoundId; +}; + +const reconcileRoundProvisionalScores = async ({ + roundId, + challengeId, + provisionalRowsByRoundId, + normalizedIdentityByCoderId, + missingMemberProvisionalSkipMemberIds = new Set(), + provisionalScoreStore, +}) => { + if ( + !provisionalScoreStore || + typeof provisionalScoreStore.listImportedNonExampleSubmissionsByLegacySubmissionId !== "function" || + typeof provisionalScoreStore.listExistingProvisionalSummationsBySubmissionId !== "function" || + typeof provisionalScoreStore.createProvisionalSummation !== "function" + ) { + throw new Error( + "provisionalScoreStore must provide listImportedNonExampleSubmissionsByLegacySubmissionId, listExistingProvisionalSummationsBySubmissionId, and createProvisionalSummation." + ); + } + + const legacyProvisionalRows = provisionalRowsByRoundId.get(roundId) || []; + const importedSubmissionByLegacySubmissionId = + await provisionalScoreStore.listImportedNonExampleSubmissionsByLegacySubmissionId({ + challengeId, + }); + const existingProvisionalSummationsBySubmissionId = + await provisionalScoreStore.listExistingProvisionalSummationsBySubmissionId({ + challengeId, + }); + const missingMemberIds = new Set( + Array.from(missingMemberProvisionalSkipMemberIds || []) + .map((memberId) => normalizeMemberId(memberId)) + .filter(Boolean) + ); + + let createdProvisionalScores = 0; + let alreadyPresentProvisionalScores = 0; + let missingMemberSkippedProvisionalScores = 0; + const importedCountsByMemberId = new Map(); + const importedMemberIds = new Set(); + const missingMemberIdsObserved = new Set(); + const skippedProvisionalRecords = []; + + const incrementImportedCount = (memberId) => { + importedMemberIds.add(memberId); + importedCountsByMemberId.set(memberId, (importedCountsByMemberId.get(memberId) || 0) + 1); + }; + + for (const provisionalRow of legacyProvisionalRows) { + const identity = resolveIdentityForCoderId( + provisionalRow.coderId, + normalizedIdentityByCoderId + ); + const memberId = normalizeMemberId(identity && identity.memberId); + const memberHandle = identity && identity.memberHandle ? identity.memberHandle : null; + + if (!memberId || missingMemberIds.has(memberId)) { + missingMemberSkippedProvisionalScores += 1; + if (memberId) { + missingMemberIdsObserved.add(memberId); + } + skippedProvisionalRecords.push({ + legacyRoundId: roundId, + memberId: memberId || String(provisionalRow.coderId || "").trim(), + memberHandle: memberHandle || undefined, + coderIds: [String(provisionalRow.coderId || "").trim()].filter(Boolean), + reasonCode: MISSING_MEMBER_REASON_CODE, + affectedSurfaces: ["provisional-score"], + legacySubmissionId: provisionalRow.legacySubmissionId, + counts: { + provisionalScore: 1, + }, + }); + continue; + } + + if (!Number.isFinite(provisionalRow.aggregateScore)) { + throw new Error( + `Legacy provisional score for round ${roundId} submission ${provisionalRow.legacySubmissionId} (coder ${provisionalRow.coderId}) is missing numeric submission_points.` + ); + } + + const importedSubmission = importedSubmissionByLegacySubmissionId.get( + provisionalRow.legacySubmissionId + ); + if (!importedSubmission) { + throw new Error( + `Unable to attach provisional score for round ${roundId} submission ${provisionalRow.legacySubmissionId}: imported non-example submission is missing.` + ); + } + const submissionId = String(importedSubmission.id || "").trim(); + if (!submissionId) { + throw new Error( + `Imported non-example submission for legacySubmissionId ${provisionalRow.legacySubmissionId} is missing id.` + ); + } + const submissionMemberId = normalizeMemberId(importedSubmission.memberId); + if (submissionMemberId && submissionMemberId !== memberId) { + throw new Error( + `Imported submission legacySubmissionId "${provisionalRow.legacySubmissionId}" is linked to memberId ${submissionMemberId} but legacy coder ${provisionalRow.coderId} resolves to memberId ${memberId}.` + ); + } + + const existingProvisionalSummations = + existingProvisionalSummationsBySubmissionId.get(submissionId) || []; + if (existingProvisionalSummations.length > 0) { + alreadyPresentProvisionalScores += 1; + incrementImportedCount(memberId); + continue; + } + + await provisionalScoreStore.createProvisionalSummation({ + submissionId, + aggregateScore: provisionalRow.aggregateScore, + isPassing: provisionalRow.aggregateScore > 0, + reviewedDate: + importedSubmission.submittedDate || importedSubmission.createdAt || null, + legacySubmissionId: provisionalRow.legacySubmissionId || null, + isFinal: false, + isExample: false, + metadata: { + legacyRoundId: roundId, + legacyCoderId: provisionalRow.coderId, + }, + }); + existingProvisionalSummationsBySubmissionId.set(submissionId, [ + { + submissionId, + aggregateScore: provisionalRow.aggregateScore, + }, + ]); + createdProvisionalScores += 1; + incrementImportedCount(memberId); + } + + return { + legacyNonExampleProvisionalScores: legacyProvisionalRows.length, + importedProvisionalScores: + createdProvisionalScores + alreadyPresentProvisionalScores, + alreadyPresentProvisionalScores, + createdProvisionalScores, + missingMemberSkippedProvisionalScores, + importedDistinctSubmitters: importedMemberIds.size, + missingMemberDistinctSubmitters: missingMemberIdsObserved.size, + importedProvisionalCountsByMemberId: formatImportedCountsByMemberId( + importedCountsByMemberId + ), + skippedProvisionalRecords, + }; +}; + +const createReviewProvisionalScoreStore = async ({ + reviewClient, + reviewSchema = DEFAULT_REVIEW_SCHEMA, + actor = "historical-mm-importer", +}) => { + if (!reviewClient || typeof reviewClient.$queryRawUnsafe !== "function") { + throw new Error( + "Review DB client with $queryRawUnsafe is required for provisional-score import." + ); + } + + const schema = normalizeReviewSchema(reviewSchema); + const submissionTable = buildQualifiedTableName(schema, "submission"); + const reviewSummationTable = buildQualifiedTableName(schema, "reviewSummation"); + + const columnRows = await reviewClient.$queryRawUnsafe( + `SELECT table_name AS "tableName", + column_name AS "columnName", + data_type AS "dataType", + is_nullable AS "isNullable" + FROM information_schema.columns + WHERE table_schema = $1 + AND table_name IN ('submission', 'reviewSummation')`, + schema + ); + + const submissionColumnsByName = new Map(); + const reviewSummationColumnsByName = new Map(); + (columnRows || []).forEach((columnRow) => { + if (columnRow.tableName === "submission") { + submissionColumnsByName.set(String(columnRow.columnName), columnRow); + } else if (columnRow.tableName === "reviewSummation") { + reviewSummationColumnsByName.set(String(columnRow.columnName), columnRow); + } + }); + + if ( + !submissionColumnsByName.has("id") || + !submissionColumnsByName.has("challengeId") || + !submissionColumnsByName.has("legacySubmissionId") + ) { + throw new Error( + `Review submission table ${schema}.submission must expose id, challengeId, and legacySubmissionId columns.` + ); + } + if ( + !reviewSummationColumnsByName.has("submissionId") || + !reviewSummationColumnsByName.has("aggregateScore") || + !reviewSummationColumnsByName.has("isPassing") + ) { + throw new Error( + `Review reviewSummation table ${schema}.reviewSummation must expose submissionId, aggregateScore, and isPassing columns.` + ); + } + + const listImportedNonExampleSubmissionsByLegacySubmissionId = async ({ + challengeId, + }) => { + const selectedColumns = [`"id"`, `"memberId"`, `"legacySubmissionId"`]; + if (submissionColumnsByName.has("submittedDate")) { + selectedColumns.push(`"submittedDate"`); + } + if (submissionColumnsByName.has("createdAt")) { + selectedColumns.push(`"createdAt"`); + } + if (submissionColumnsByName.has("isExample")) { + selectedColumns.push(`"isExample"`); + } + + const whereClauses = [`"challengeId" = $1`, `"legacySubmissionId" IS NOT NULL`]; + if (submissionColumnsByName.has("isExample")) { + whereClauses.push(`COALESCE("isExample", false) = false`); + } + + const rows = await reviewClient.$queryRawUnsafe( + `SELECT ${selectedColumns.join(", ")} + FROM ${submissionTable} + WHERE ${whereClauses.join(" AND ")}`, + challengeId + ); + + const byLegacySubmissionId = new Map(); + (rows || []).forEach((row) => { + const legacySubmissionId = String( + row && row.legacySubmissionId ? row.legacySubmissionId : "" + ).trim(); + if (!legacySubmissionId) { + return; + } + const submissionId = String(row && row.id ? row.id : "").trim(); + if (!submissionId) { + return; + } + byLegacySubmissionId.set(legacySubmissionId, { + id: submissionId, + memberId: normalizeMemberId(row && row.memberId), + legacySubmissionId, + submittedDate: row && row.submittedDate ? row.submittedDate : null, + createdAt: row && row.createdAt ? row.createdAt : null, + isExample: Boolean(row && row.isExample), + }); + }); + return byLegacySubmissionId; + }; + + const listExistingProvisionalSummationsBySubmissionId = async ({ + challengeId, + }) => { + const whereClauses = [ + `s."challengeId" = $1`, + `COALESCE(rs."isFinal", false) = false`, + ]; + if (reviewSummationColumnsByName.has("isExample")) { + whereClauses.push(`COALESCE(rs."isExample", false) = false`); + } + const rows = await reviewClient.$queryRawUnsafe( + `SELECT rs."submissionId" AS "submissionId", + rs."aggregateScore" AS "aggregateScore" + FROM ${reviewSummationTable} rs + INNER JOIN ${submissionTable} s ON s."id" = rs."submissionId" + WHERE ${whereClauses.join(" AND ")}`, + challengeId + ); + + const bySubmissionId = new Map(); + (rows || []).forEach((row) => { + const submissionId = String(row && row.submissionId ? row.submissionId : "").trim(); + if (!submissionId) { + return; + } + if (!bySubmissionId.has(submissionId)) { + bySubmissionId.set(submissionId, []); + } + bySubmissionId.get(submissionId).push({ + submissionId, + aggregateScore: parseNumericScore(row && row.aggregateScore), + }); + }); + return bySubmissionId; + }; + + const createProvisionalSummation = async ({ + submissionId, + aggregateScore, + isPassing, + reviewedDate, + legacySubmissionId, + isFinal = false, + isExample = false, + metadata = null, + }) => { + const columns = []; + const placeholders = []; + const values = []; + const pushColumn = (columnName, value) => { + columns.push(`"${columnName}"`); + values.push(value); + placeholders.push(`$${values.length}`); + }; + + pushColumn("submissionId", submissionId); + pushColumn("aggregateScore", aggregateScore); + pushColumn("isPassing", Boolean(isPassing)); + if (reviewSummationColumnsByName.has("isFinal")) { + pushColumn("isFinal", Boolean(isFinal)); + } + if (reviewSummationColumnsByName.has("reviewedDate") && reviewedDate) { + pushColumn("reviewedDate", reviewedDate); + } + if (reviewSummationColumnsByName.has("legacySubmissionId") && legacySubmissionId) { + pushColumn("legacySubmissionId", String(legacySubmissionId)); + } + if (reviewSummationColumnsByName.has("isExample")) { + pushColumn("isExample", Boolean(isExample)); + } + if (reviewSummationColumnsByName.has("metadata") && metadata) { + pushColumn("metadata", metadata); + } + if (reviewSummationColumnsByName.has("createdBy")) { + pushColumn("createdBy", actor); + } + if (reviewSummationColumnsByName.has("updatedBy")) { + pushColumn("updatedBy", actor); + } + const updatedAtColumn = reviewSummationColumnsByName.get("updatedAt"); + if ( + updatedAtColumn && + String(updatedAtColumn.isNullable || "").toUpperCase() === "NO" + ) { + pushColumn("updatedAt", reviewedDate || new Date()); + } + + await reviewClient.$queryRawUnsafe( + `INSERT INTO ${reviewSummationTable} (${columns.join(", ")}) + VALUES (${placeholders.join(", ")})`, + ...values + ); + }; + + return { + listImportedNonExampleSubmissionsByLegacySubmissionId, + listExistingProvisionalSummationsBySubmissionId, + createProvisionalSummation, + }; +}; + +module.exports = { + DEFAULT_REVIEW_SCHEMA, + loadLegacyProvisionalRowsByRoundId, + reconcileRoundProvisionalScores, + createReviewProvisionalScoreStore, +}; diff --git a/data-migration/test/importHistoricalMarathonMatches.applyProvisionalScores.test.js b/data-migration/test/importHistoricalMarathonMatches.applyProvisionalScores.test.js new file mode 100644 index 0000000..3cb539d --- /dev/null +++ b/data-migration/test/importHistoricalMarathonMatches.applyProvisionalScores.test.js @@ -0,0 +1,244 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + runApplyMode, +} = require("../src/scripts/importHistoricalMarathonMatches/apply"); + +const writeJson = (baseDir, fileName, rootKey, rows) => { + fs.writeFileSync( + path.join(baseDir, fileName), + `${JSON.stringify({ [rootKey]: rows }, null, 2)}\n`, + "utf8" + ); +}; + +describe("importHistoricalMarathonMatches apply mode provisional-score wiring", () => { + let fixtureDir; + + beforeEach(() => { + fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-apply-provisional-scores-fixture-")); + writeJson(fixtureDir, "long_component_state_1.json", "long_component_state", [ + { long_component_state_id: "1001", round_id: "9892", coder_id: "1", component_id: "5503", points: "9.5" }, + { long_component_state_id: "1002", round_id: "9892", coder_id: "2", component_id: "5503", points: "7.0" }, + ]); + writeJson(fixtureDir, "long_submission_1.json", "long_submission", [ + { + long_component_state_id: "1001", + submission_number: "1", + example: "0", + submit_time: "1000", + submission_points: "9.5", + }, + { + long_component_state_id: "1002", + submission_number: "1", + example: "0", + submit_time: "1001", + submission_points: "7.0", + }, + ]); + }); + + afterEach(() => { + fs.rmSync(fixtureDir, { recursive: true, force: true }); + }); + + test("apply-mode imports provisional scores and appends per-submission missing-member provisional skips", async () => { + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({ id: "challenge-1" }), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([]), + createMany: jest.fn(), + }, + }; + + const phaseRows = [ + { id: "phase-registration", name: "Registration" }, + { id: "phase-submission", name: "Submission" }, + { id: "phase-review", name: "Review" }, + ]; + + const prisma = { + challengeType: { + findMany: jest.fn().mockResolvedValue([{ id: "type-mm" }]), + }, + challengeTrack: { + findMany: jest.fn().mockResolvedValue([{ id: "track-ds" }]), + }, + phase: { + findMany: jest + .fn() + .mockResolvedValueOnce(phaseRows) + .mockResolvedValueOnce(phaseRows), + }, + challengeTimelineTemplate: { + findMany: jest.fn().mockResolvedValue([ + { + timelineTemplateId: "timeline-mm", + isDefault: true, + timelineTemplate: { + phases: [ + { phaseId: "phase-registration" }, + { phaseId: "phase-submission" }, + { phaseId: "phase-review" }, + ], + }, + }, + ]), + }, + $transaction: async (callback) => callback(tx), + }; + + const submissionStoreRecordsByChallengeId = new Map(); + const submissionStore = { + listExistingSubmissionsByLegacyId: async ({ challengeId }) => + new Map(submissionStoreRecordsByChallengeId.get(challengeId) || []), + createSubmission: async ({ challengeId, legacySubmissionId, memberId, submittedDate }) => { + if (!submissionStoreRecordsByChallengeId.has(challengeId)) { + submissionStoreRecordsByChallengeId.set(challengeId, new Map()); + } + submissionStoreRecordsByChallengeId.get(challengeId).set(legacySubmissionId, { + id: `sub-${legacySubmissionId}`, + legacySubmissionId, + memberId: String(memberId), + submittedDate, + createdAt: submittedDate, + }); + }, + }; + + const existingProvisionalBySubmissionId = new Map(); + const createdProvisionalSummations = []; + const provisionalScoreStore = { + listImportedNonExampleSubmissionsByLegacySubmissionId: async ({ challengeId }) => + new Map( + Array.from( + (submissionStoreRecordsByChallengeId.get(challengeId) || new Map()).values() + ).map((submission) => [submission.legacySubmissionId, submission]) + ), + listExistingProvisionalSummationsBySubmissionId: async () => + new Map(existingProvisionalBySubmissionId), + createProvisionalSummation: async (payload) => { + createdProvisionalSummations.push(payload); + existingProvisionalBySubmissionId.set(payload.submissionId, [ + { + submissionId: payload.submissionId, + aggregateScore: payload.aggregateScore, + }, + ]); + }, + }; + + const skippedFilePath = path.join(fixtureDir, "apply-skipped.json"); + const result = await runApplyMode({ + prisma, + options: { + roundIds: ["9892"], + skippedFilePath, + importSubmissions: true, + importProvisionalScores: true, + submissionStore, + provisionalScoreStore, + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + resourceClient: { + listSubmitterResources: jest.fn().mockResolvedValue([]), + createSubmitterResource: jest.fn().mockResolvedValue({}), + }, + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "create", + reason: "no-matching-v6-challenge-found", + plannedSkipRecords: [ + { + legacyRoundId: "9892", + memberId: "2", + reasonCode: "missing-member", + affectedSurfaces: ["resource", "submission", "provisional-score"], + }, + ], + }, + ], + roundDataById: new Map([ + [ + "9892", + { + round: { round_id: "9892", round_type_id: "13", name: "MM 9892" }, + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T01:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-01T01:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2"]), + nonExampleSubmissions: 2, + nonExampleSubmitterCoderIds: new Set(["1", "2"]), + finalCandidateCoderIds: new Set(), + }, + ], + ]), + }, + actor: "importer", + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ]), + }); + + expect(createdProvisionalSummations).toHaveLength(1); + expect(createdProvisionalSummations[0]).toEqual( + expect.objectContaining({ + submissionId: "sub-10010001", + aggregateScore: 9.5, + legacySubmissionId: "10010001", + isFinal: false, + }) + ); + + expect(result.records).toEqual([ + expect.objectContaining({ + recordType: "apply-record", + legacyRoundId: "9892", + status: "created", + challengeId: "challenge-1", + provisionalScoreReconciliation: { + legacyNonExampleProvisionalScores: 2, + importedProvisionalScores: 1, + alreadyPresentProvisionalScores: 0, + createdProvisionalScores: 1, + missingMemberSkippedProvisionalScores: 1, + importedDistinctSubmitters: 1, + missingMemberDistinctSubmitters: 1, + importedProvisionalCountsByMemberId: { + 1: 1, + }, + skippedProvisionalRecords: [ + expect.objectContaining({ + reasonCode: "missing-member", + memberId: "2", + legacySubmissionId: "10020001", + affectedSurfaces: ["provisional-score"], + }), + ], + }, + }), + ]); + expect(result.summary).toEqual( + expect.objectContaining({ + skippedFileArtifact: { + path: skippedFilePath, + reasonCodes: ["missing-member"], + recordCount: 3, + }, + }) + ); + }); +}); diff --git a/data-migration/test/importHistoricalMarathonMatches.provisionalScores.test.js b/data-migration/test/importHistoricalMarathonMatches.provisionalScores.test.js new file mode 100644 index 0000000..99556d7 --- /dev/null +++ b/data-migration/test/importHistoricalMarathonMatches.provisionalScores.test.js @@ -0,0 +1,246 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + loadLegacyProvisionalRowsByRoundId, + reconcileRoundProvisionalScores, +} = require("../src/scripts/importHistoricalMarathonMatches/provisionalScores"); + +const writeJson = (baseDir, fileName, rootKey, rows) => { + fs.writeFileSync( + path.join(baseDir, fileName), + `${JSON.stringify({ [rootKey]: rows }, null, 2)}\n`, + "utf8" + ); +}; + +const createFixtureDataDirectory = () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-provisional-scores-fixture-")); + + writeJson(baseDir, "long_component_state_1.json", "long_component_state", [ + { long_component_state_id: "1001", round_id: "9892", coder_id: "1", component_id: "5503" }, + { long_component_state_id: "1002", round_id: "9892", coder_id: "2", component_id: "5503" }, + ]); + writeJson(baseDir, "long_submission_1.json", "long_submission", [ + { + long_component_state_id: "1001", + submission_number: "1", + example: "0", + submit_time: "1000", + submission_points: "9.5", + }, + { + long_component_state_id: "1001", + submission_number: "2", + example: "1", + submit_time: "1001", + submission_points: "200.0", + }, + { + long_component_state_id: "1001", + submission_number: "3", + example: "0", + submit_time: "1002", + submission_points: "8.25", + }, + { + long_component_state_id: "1002", + submission_number: "1", + example: "0", + submit_time: "1003", + submission_points: "7.0", + }, + ]); + + return baseDir; +}; + +describe("importHistoricalMarathonMatches provisional score import", () => { + let fixtureDir; + + beforeEach(() => { + fixtureDir = createFixtureDataDirectory(); + }); + + afterEach(() => { + fs.rmSync(fixtureDir, { recursive: true, force: true }); + }); + + test("loads non-example provisional rows keyed by legacySubmissionId and score", async () => { + const rowsByRoundId = await loadLegacyProvisionalRowsByRoundId({ + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + roundIds: ["9892"], + }); + + expect(rowsByRoundId.get("9892")).toEqual([ + expect.objectContaining({ + legacyRoundId: "9892", + coderId: "1", + legacySubmissionId: "10010001", + aggregateScore: 9.5, + }), + expect.objectContaining({ + legacyRoundId: "9892", + coderId: "1", + legacySubmissionId: "10010003", + aggregateScore: 8.25, + }), + expect.objectContaining({ + legacyRoundId: "9892", + coderId: "2", + legacySubmissionId: "10020001", + aggregateScore: 7, + }), + ]); + }); + + test("imports one provisional per imported submission, skips missing members, and is rerun-idempotent", async () => { + const rowsByRoundId = await loadLegacyProvisionalRowsByRoundId({ + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + roundIds: ["9892"], + }); + + const importedSubmissionByLegacySubmissionId = new Map([ + [ + "10010001", + { + id: "sub-10010001", + memberId: "1", + legacySubmissionId: "10010001", + submittedDate: new Date("2020-01-01T01:00:00.000Z"), + createdAt: new Date("2020-01-01T01:00:00.000Z"), + }, + ], + [ + "10010003", + { + id: "sub-10010003", + memberId: "1", + legacySubmissionId: "10010003", + submittedDate: new Date("2020-01-01T02:00:00.000Z"), + createdAt: new Date("2020-01-01T02:00:00.000Z"), + }, + ], + [ + "10020001", + { + id: "sub-10020001", + memberId: "2", + legacySubmissionId: "10020001", + submittedDate: new Date("2020-01-01T03:00:00.000Z"), + createdAt: new Date("2020-01-01T03:00:00.000Z"), + }, + ], + ]); + const existingProvisionalBySubmissionId = new Map([ + [ + "sub-10010003", + [{ submissionId: "sub-10010003", aggregateScore: 8.25 }], + ], + ]); + const created = []; + + const provisionalScoreStore = { + listImportedNonExampleSubmissionsByLegacySubmissionId: async () => + new Map(importedSubmissionByLegacySubmissionId), + listExistingProvisionalSummationsBySubmissionId: async () => + new Map(existingProvisionalBySubmissionId), + createProvisionalSummation: async (payload) => { + created.push(payload); + existingProvisionalBySubmissionId.set(payload.submissionId, [ + { + submissionId: payload.submissionId, + aggregateScore: payload.aggregateScore, + }, + ]); + }, + }; + + const firstRun = await reconcileRoundProvisionalScores({ + roundId: "9892", + challengeId: "challenge-1", + provisionalRowsByRoundId: rowsByRoundId, + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ]), + missingMemberProvisionalSkipMemberIds: new Set(["2"]), + provisionalScoreStore, + }); + + expect(firstRun).toEqual({ + legacyNonExampleProvisionalScores: 3, + importedProvisionalScores: 2, + alreadyPresentProvisionalScores: 1, + createdProvisionalScores: 1, + missingMemberSkippedProvisionalScores: 1, + importedDistinctSubmitters: 1, + missingMemberDistinctSubmitters: 1, + importedProvisionalCountsByMemberId: { + 1: 2, + }, + skippedProvisionalRecords: [ + expect.objectContaining({ + legacyRoundId: "9892", + memberId: "2", + reasonCode: "missing-member", + affectedSurfaces: ["provisional-score"], + legacySubmissionId: "10020001", + counts: { + provisionalScore: 1, + }, + }), + ], + }); + expect(created).toEqual([ + expect.objectContaining({ + submissionId: "sub-10010001", + aggregateScore: 9.5, + legacySubmissionId: "10010001", + isFinal: false, + }), + ]); + + const secondRun = await reconcileRoundProvisionalScores({ + roundId: "9892", + challengeId: "challenge-1", + provisionalRowsByRoundId: rowsByRoundId, + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ]), + missingMemberProvisionalSkipMemberIds: new Set(["2"]), + provisionalScoreStore, + }); + + expect(secondRun).toEqual({ + legacyNonExampleProvisionalScores: 3, + importedProvisionalScores: 2, + alreadyPresentProvisionalScores: 2, + createdProvisionalScores: 0, + missingMemberSkippedProvisionalScores: 1, + importedDistinctSubmitters: 1, + missingMemberDistinctSubmitters: 1, + importedProvisionalCountsByMemberId: { + 1: 2, + }, + skippedProvisionalRecords: [ + expect.objectContaining({ + legacyRoundId: "9892", + memberId: "2", + reasonCode: "missing-member", + affectedSurfaces: ["provisional-score"], + legacySubmissionId: "10020001", + counts: { + provisionalScore: 1, + }, + }), + ], + }); + }); +}); From 561d064d677f9ef7532c34606168a178d57a4ea6 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 3 Apr 2026 11:40:42 +1100 Subject: [PATCH 22/27] Use authoritative linked counts for MM rerun/backfill planning Discover existing resource/submission/review counts from live APIs when available so reuse planning and rerun no-op classification reflect actual linked state instead of snapshot hints. --- .../importHistoricalMarathonMatches.js | 39 ++- .../existingState.js | 88 ++++++- .../linkedCounts.js | 227 ++++++++++++++++++ ...ricalMarathonMatches.existingState.test.js | 69 ++++++ ...oricalMarathonMatches.linkedCounts.test.js | 62 +++++ 5 files changed, 471 insertions(+), 14 deletions(-) create mode 100644 data-migration/src/scripts/importHistoricalMarathonMatches/linkedCounts.js create mode 100644 data-migration/test/importHistoricalMarathonMatches.linkedCounts.test.js diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches.js b/data-migration/src/scripts/importHistoricalMarathonMatches.js index aa4c5c9..8b28870 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches.js @@ -18,6 +18,9 @@ const { loadExistingState, buildExistingStateByRoundId, } = require("./importHistoricalMarathonMatches/existingState"); +const { + createLinkedRecordCountResolver, +} = require("./importHistoricalMarathonMatches/linkedCounts"); const { createAuth0TokenProvider, createResourceApiClient, @@ -104,10 +107,10 @@ const run = async () => { }, }); } - if (options.apply) { - if (!reviewDbUrl) { - throw new Error("REVIEW_DB_URL must be set for apply mode submission import."); - } + if (options.apply && !reviewDbUrl) { + throw new Error("REVIEW_DB_URL must be set for apply mode submission import."); + } + if (reviewDbUrl) { const { PrismaClient } = requireFromRoot("@prisma/client"); const databaseUrl = String(process.env.DATABASE_URL || "").trim(); reviewPrisma = @@ -144,12 +147,40 @@ const run = async () => { try { const marathonTypeId = await resolveMarathonTypeId(prisma); const dataScienceTrackId = await resolveDataScienceTrackId(prisma); + let resolveLinkedCountsByChallengeId = null; + if (reviewPrisma || String(process.env.RESOURCES_API_URL || "").trim()) { + let planningResourceClient = null; + if (String(process.env.RESOURCES_API_URL || "").trim()) { + try { + planningResourceClient = createDefaultResourceClient(); + } catch (error) { + process.stderr.write( + `Warning: unable to initialize Resource API discovery client (${error.message}); planning linked counts will fall back to snapshot hints.\n` + ); + } + } + try { + resolveLinkedCountsByChallengeId = await createLinkedRecordCountResolver({ + resourceClient: planningResourceClient, + reviewClient: reviewPrisma, + reviewSchema: reviewDbSchema, + submitterRoleId: String( + process.env.SUBMITTER_ROLE_ID || DEFAULT_SUBMITTER_ROLE_ID + ).trim(), + }); + } catch (error) { + process.stderr.write( + `Warning: unable to initialize authoritative linked-count discovery (${error.message}); planning linked counts will fall back to snapshot hints.\n` + ); + } + } existingStateByRoundId = await buildExistingStateByRoundId({ prisma, roundIds: options.roundIds, marathonTypeId, dataScienceTrackId, snapshotByRoundId, + resolveLinkedCountsByChallengeId, }); planningPrerequisites.authoritativeDiscovery = { available: true }; try { diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/existingState.js b/data-migration/src/scripts/importHistoricalMarathonMatches/existingState.js index 49b0be6..7fd5b51 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/existingState.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/existingState.js @@ -105,6 +105,40 @@ const normalizeSnapshotCountsForChallenge = (snapshotEntry, matchedChallengeId) return normalizeExistingCounts(snapshotEntry.existing); }; +const normalizeAuthoritativeLinkedCounts = (counts = {}) => ({ + resources: + counts.resources === undefined || counts.resources === null + ? null + : parseNonNegativeInteger(counts.resources), + submissions: + counts.submissions === undefined || counts.submissions === null + ? null + : parseNonNegativeInteger(counts.submissions), + finalScores: + counts.finalScores === undefined || counts.finalScores === null + ? null + : parseNonNegativeInteger(counts.finalScores), + provisionalScores: + counts.provisionalScores === undefined || counts.provisionalScores === null + ? null + : parseNonNegativeInteger(counts.provisionalScores), +}); + +const mergeLinkedCounts = ({ snapshotCounts, authoritativeCounts }) => ({ + resources: Number.isFinite(authoritativeCounts.resources) + ? authoritativeCounts.resources + : snapshotCounts.resources, + submissions: Number.isFinite(authoritativeCounts.submissions) + ? authoritativeCounts.submissions + : snapshotCounts.submissions, + finalScores: Number.isFinite(authoritativeCounts.finalScores) + ? authoritativeCounts.finalScores + : snapshotCounts.finalScores, + provisionalScores: Number.isFinite(authoritativeCounts.provisionalScores) + ? authoritativeCounts.provisionalScores + : snapshotCounts.provisionalScores, +}); + const buildDefaultExistingStateEntry = (legacyRoundId) => ({ legacyRoundId, matchStatus: "none", @@ -119,6 +153,7 @@ const buildExistingStateByRoundId = async ({ marathonTypeId, dataScienceTrackId, snapshotByRoundId = new Map(), + resolveLinkedCountsByChallengeId = null, }) => { const byRoundId = new Map(); roundIds.forEach((roundId) => { @@ -184,6 +219,8 @@ const buildExistingStateByRoundId = async ({ phaseRowsByChallengeId.get(row.challengeId).push(row); }); + const safeRoundEntries = []; + roundIds.forEach((roundId) => { const candidates = challengeRowsByLegacyRoundId.get(roundId) || []; if (candidates.length === 0) { @@ -225,20 +262,51 @@ const buildExistingStateByRoundId = async ({ }); return; } + safeRoundEntries.push({ + roundId, + challengeId: candidate.id, + phaseCount: candidatePhaseRows.length, + snapshotCounts: normalizeSnapshotCountsForChallenge( + snapshotByRoundId.get(roundId), + candidate.id + ), + }); + }); - const snapshotEntry = snapshotByRoundId.get(roundId); - const snapshotCounts = normalizeSnapshotCountsForChallenge(snapshotEntry, candidate.id); - byRoundId.set(roundId, { - legacyRoundId: roundId, + let authoritativeCountsByChallengeId = new Map(); + if (safeRoundEntries.length > 0 && typeof resolveLinkedCountsByChallengeId === "function") { + try { + const resolvedCounts = await resolveLinkedCountsByChallengeId({ + challengeIds: safeRoundEntries.map((entry) => entry.challengeId), + }); + if (resolvedCounts instanceof Map) { + authoritativeCountsByChallengeId = resolvedCounts; + } + } catch { + authoritativeCountsByChallengeId = new Map(); + } + } + + safeRoundEntries.forEach((entry) => { + const authoritativeCounts = normalizeAuthoritativeLinkedCounts( + authoritativeCountsByChallengeId.get(entry.challengeId) + ); + const mergedLinkedCounts = mergeLinkedCounts({ + snapshotCounts: entry.snapshotCounts, + authoritativeCounts, + }); + + byRoundId.set(entry.roundId, { + legacyRoundId: entry.roundId, matchStatus: "safe", reason: "existing-v6-challenge-found", - challengeId: candidate.id, + challengeId: entry.challengeId, existing: { - phases: candidatePhaseRows.length, - resources: snapshotCounts.resources, - submissions: snapshotCounts.submissions, - finalScores: snapshotCounts.finalScores, - provisionalScores: snapshotCounts.provisionalScores, + phases: entry.phaseCount, + resources: mergedLinkedCounts.resources, + submissions: mergedLinkedCounts.submissions, + finalScores: mergedLinkedCounts.finalScores, + provisionalScores: mergedLinkedCounts.provisionalScores, }, }); }); diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/linkedCounts.js b/data-migration/src/scripts/importHistoricalMarathonMatches/linkedCounts.js new file mode 100644 index 0000000..dc8c3cb --- /dev/null +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/linkedCounts.js @@ -0,0 +1,227 @@ +"use strict"; + +const normalizeReviewSchema = (value) => { + const normalized = String(value || "reviews").trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(normalized)) { + throw new Error(`Invalid REVIEW_DB_SCHEMA "${normalized}"`); + } + return normalized; +}; + +const buildQualifiedTableName = (schemaName, tableName) => + `"${String(schemaName).replace(/"/g, "\"\"")}"."${String(tableName).replace(/"/g, "\"\"")}"`; + +const parseNonNegativeInteger = (value) => { + const parsed = Number.parseInt(String(value || "").trim(), 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return 0; + } + return parsed; +}; + +const parsePositiveInteger = (value) => { + const parsed = Number.parseInt(String(value || "").trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +}; + +const readCountRow = (rows = []) => parseNonNegativeInteger(rows[0] && rows[0].count); + +const introspectReviewColumns = async ({ reviewClient, reviewSchema }) => { + if (!reviewClient || typeof reviewClient.$queryRawUnsafe !== "function") { + return null; + } + + const schema = normalizeReviewSchema(reviewSchema); + const columnRows = await reviewClient.$queryRawUnsafe( + `SELECT table_name AS "tableName", + column_name AS "columnName" + FROM information_schema.columns + WHERE table_schema = $1 + AND table_name IN ('submission', 'reviewSummation')`, + schema + ); + + const submissionColumns = new Set(); + const reviewSummationColumns = new Set(); + (columnRows || []).forEach((columnRow) => { + const tableName = String(columnRow && columnRow.tableName ? columnRow.tableName : "").trim(); + const columnName = String(columnRow && columnRow.columnName ? columnRow.columnName : "").trim(); + if (!columnName) { + return; + } + if (tableName === "submission") { + submissionColumns.add(columnName); + } else if (tableName === "reviewSummation") { + reviewSummationColumns.add(columnName); + } + }); + + if (!submissionColumns.has("challengeId") || !submissionColumns.has("id")) { + return null; + } + if (!reviewSummationColumns.has("submissionId")) { + return null; + } + + return { + schema, + submissionTable: buildQualifiedTableName(schema, "submission"), + reviewSummationTable: buildQualifiedTableName(schema, "reviewSummation"), + submissionColumns, + reviewSummationColumns, + }; +}; + +const countImportedSubmissions = async ({ + reviewClient, + metadata, + challengeId, +}) => { + const whereClauses = [`"challengeId" = $1`]; + if (metadata.submissionColumns.has("legacySubmissionId")) { + whereClauses.push(`"legacySubmissionId" IS NOT NULL`); + } + if (metadata.submissionColumns.has("isExample")) { + whereClauses.push(`COALESCE("isExample", false) = false`); + } + const rows = await reviewClient.$queryRawUnsafe( + `SELECT COUNT(*)::bigint AS "count" + FROM ${metadata.submissionTable} + WHERE ${whereClauses.join(" AND ")}`, + challengeId + ); + return readCountRow(rows); +}; + +const countReviewSummations = async ({ + reviewClient, + metadata, + challengeId, + isFinal, +}) => { + if (!metadata.reviewSummationColumns.has("isFinal")) { + return 0; + } + + const whereClauses = [`s."challengeId" = $1`]; + if (metadata.submissionColumns.has("legacySubmissionId")) { + whereClauses.push(`s."legacySubmissionId" IS NOT NULL`); + } + if (metadata.submissionColumns.has("isExample")) { + whereClauses.push(`COALESCE(s."isExample", false) = false`); + } + if (metadata.reviewSummationColumns.has("isExample")) { + whereClauses.push(`COALESCE(rs."isExample", false) = false`); + } + whereClauses.push(`COALESCE(rs."isFinal", false) = ${isFinal ? "true" : "false"}`); + + const rows = await reviewClient.$queryRawUnsafe( + `SELECT COUNT(*)::bigint AS "count" + FROM ${metadata.reviewSummationTable} rs + INNER JOIN ${metadata.submissionTable} s ON s."id" = rs."submissionId" + WHERE ${whereClauses.join(" AND ")}`, + challengeId + ); + return readCountRow(rows); +}; + +const countSubmitterResources = async ({ + resourceClient, + challengeId, + submitterRoleId, +}) => { + if (!resourceClient || typeof resourceClient.listSubmitterResources !== "function") { + return null; + } + + const rows = await resourceClient.listSubmitterResources(challengeId, submitterRoleId); + const uniqueSubmitterTuples = new Set(); + + (rows || []).forEach((row) => { + const memberId = parsePositiveInteger(row && row.memberId); + if (!memberId) { + return; + } + const roleId = String( + row && row.roleId ? row.roleId : submitterRoleId || "" + ).trim(); + uniqueSubmitterTuples.add(`${memberId}:${roleId}`); + }); + + return uniqueSubmitterTuples.size; +}; + +const createLinkedRecordCountResolver = async ({ + resourceClient = null, + reviewClient = null, + reviewSchema = "reviews", + submitterRoleId = "", +} = {}) => { + const reviewMetadata = await introspectReviewColumns({ + reviewClient, + reviewSchema, + }); + + return async ({ challengeIds = [] } = {}) => { + const uniqueChallengeIds = Array.from( + new Set( + (challengeIds || []) + .map((challengeId) => String(challengeId || "").trim()) + .filter(Boolean) + ) + ); + + const byChallengeId = new Map(); + for (const challengeId of uniqueChallengeIds) { + const counts = {}; + + try { + const resourceCount = await countSubmitterResources({ + resourceClient, + challengeId, + submitterRoleId, + }); + if (Number.isFinite(resourceCount)) { + counts.resources = resourceCount; + } + } catch { + // Best-effort enrichment only. + } + + if (reviewMetadata) { + try { + counts.submissions = await countImportedSubmissions({ + reviewClient, + metadata: reviewMetadata, + challengeId, + }); + counts.finalScores = await countReviewSummations({ + reviewClient, + metadata: reviewMetadata, + challengeId, + isFinal: true, + }); + counts.provisionalScores = await countReviewSummations({ + reviewClient, + metadata: reviewMetadata, + challengeId, + isFinal: false, + }); + } catch { + // Best-effort enrichment only. + } + } + + byChallengeId.set(challengeId, counts); + } + + return byChallengeId; + }; +}; + +module.exports = { + createLinkedRecordCountResolver, +}; diff --git a/data-migration/test/importHistoricalMarathonMatches.existingState.test.js b/data-migration/test/importHistoricalMarathonMatches.existingState.test.js index 85ecdb9..b4f7171 100644 --- a/data-migration/test/importHistoricalMarathonMatches.existingState.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.existingState.test.js @@ -100,6 +100,75 @@ describe("importHistoricalMarathonMatches existing v6 state discovery", () => { }); }); + test("prefers authoritative linked-record discovery counts over snapshot hints", async () => { + const prisma = { + challenge: { + findMany: jest.fn().mockResolvedValue([ + { + id: "challenge-1", + legacyId: 9892, + typeId: "type-mm", + trackId: "track-ds", + }, + ]), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([ + { challengeId: "challenge-1", name: "Registration" }, + { challengeId: "challenge-1", name: "Submission" }, + { challengeId: "challenge-1", name: "Review" }, + ]), + }, + }; + + const existingStateByRoundId = await buildExistingStateByRoundId({ + prisma, + roundIds: ["9892"], + marathonTypeId: "type-mm", + dataScienceTrackId: "track-ds", + snapshotByRoundId: new Map([ + [ + "9892", + { + challengeId: "challenge-1", + existing: { + resources: 1, + submissions: 2, + finalScores: 3, + provisionalScores: 4, + }, + }, + ], + ]), + resolveLinkedCountsByChallengeId: async () => + new Map([ + [ + "challenge-1", + { + resources: 8, + submissions: 9, + finalScores: 5, + provisionalScores: 11, + }, + ], + ]), + }); + + expect(existingStateByRoundId.get("9892")).toEqual({ + legacyRoundId: "9892", + matchStatus: "safe", + reason: "existing-v6-challenge-found", + challengeId: "challenge-1", + existing: { + phases: 3, + resources: 8, + submissions: 9, + finalScores: 5, + provisionalScores: 11, + }, + }); + }); + test("marks duplicate legacy matches as ambiguous", async () => { const prisma = { challenge: { diff --git a/data-migration/test/importHistoricalMarathonMatches.linkedCounts.test.js b/data-migration/test/importHistoricalMarathonMatches.linkedCounts.test.js new file mode 100644 index 0000000..2b2b9ee --- /dev/null +++ b/data-migration/test/importHistoricalMarathonMatches.linkedCounts.test.js @@ -0,0 +1,62 @@ +const { + createLinkedRecordCountResolver, +} = require("../src/scripts/importHistoricalMarathonMatches/linkedCounts"); + +describe("importHistoricalMarathonMatches linked-record count discovery", () => { + test("resolves resource/submission/final/provisional counts per challenge", async () => { + const resourceClient = { + listSubmitterResources: jest.fn().mockResolvedValue([ + { memberId: "1", roleId: "submitter-role" }, + { memberId: "2", roleId: "submitter-role" }, + { memberId: "2", roleId: "submitter-role" }, + ]), + }; + const reviewClient = { + $queryRawUnsafe: jest + .fn() + .mockResolvedValueOnce([ + { tableName: "submission", columnName: "challengeId" }, + { tableName: "submission", columnName: "id" }, + { tableName: "submission", columnName: "legacySubmissionId" }, + { tableName: "submission", columnName: "isExample" }, + { tableName: "reviewSummation", columnName: "submissionId" }, + { tableName: "reviewSummation", columnName: "isFinal" }, + { tableName: "reviewSummation", columnName: "isExample" }, + ]) + .mockResolvedValueOnce([{ count: "7" }]) + .mockResolvedValueOnce([{ count: "3" }]) + .mockResolvedValueOnce([{ count: "4" }]), + }; + + const resolveLinkedCountsByChallengeId = await createLinkedRecordCountResolver({ + resourceClient, + reviewClient, + reviewSchema: "reviews", + submitterRoleId: "submitter-role", + }); + + const countsByChallengeId = await resolveLinkedCountsByChallengeId({ + challengeIds: ["challenge-1"], + }); + + expect(resourceClient.listSubmitterResources).toHaveBeenCalledWith( + "challenge-1", + "submitter-role" + ); + expect(countsByChallengeId.get("challenge-1")).toEqual({ + resources: 2, + submissions: 7, + finalScores: 3, + provisionalScores: 4, + }); + }); + + test("returns empty counts when no discovery clients are provided", async () => { + const resolveLinkedCountsByChallengeId = await createLinkedRecordCountResolver({}); + const countsByChallengeId = await resolveLinkedCountsByChallengeId({ + challengeIds: ["challenge-1"], + }); + + expect(countsByChallengeId.get("challenge-1")).toEqual({}); + }); +}); From 2dc4e5cd4ba77ff11fde0bd2b19973549c8ef696 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 3 Apr 2026 11:52:20 +1100 Subject: [PATCH 23/27] Validate participant-scores scrutiny synthesis Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../mm-importer-end-to-end-orchestration.json | 15 +++++++++ .../reviews/mm-importer-final-scores.json | 22 +++++++++++++ ...missing-member-planning-and-reporting.json | 15 +++++++++ .../mm-importer-provisional-scores.json | 15 +++++++++ .../mm-importer-submission-history.json | 15 +++++++++ .../mm-importer-submitter-resources.json | 15 +++++++++ .../scrutiny/synthesis.json | 33 +++++++++++++++++++ 7 files changed, 130 insertions(+) create mode 100644 .factory/validation/participant-scores/scrutiny/reviews/mm-importer-end-to-end-orchestration.json create mode 100644 .factory/validation/participant-scores/scrutiny/reviews/mm-importer-final-scores.json create mode 100644 .factory/validation/participant-scores/scrutiny/reviews/mm-importer-missing-member-planning-and-reporting.json create mode 100644 .factory/validation/participant-scores/scrutiny/reviews/mm-importer-provisional-scores.json create mode 100644 .factory/validation/participant-scores/scrutiny/reviews/mm-importer-submission-history.json create mode 100644 .factory/validation/participant-scores/scrutiny/reviews/mm-importer-submitter-resources.json create mode 100644 .factory/validation/participant-scores/scrutiny/synthesis.json diff --git a/.factory/validation/participant-scores/scrutiny/reviews/mm-importer-end-to-end-orchestration.json b/.factory/validation/participant-scores/scrutiny/reviews/mm-importer-end-to-end-orchestration.json new file mode 100644 index 0000000..102bd17 --- /dev/null +++ b/.factory/validation/participant-scores/scrutiny/reviews/mm-importer-end-to-end-orchestration.json @@ -0,0 +1,15 @@ +{ + "featureId": "mm-importer-end-to-end-orchestration", + "reviewedAt": "2026-04-03T00:51:16Z", + "commitId": "561d064d677f9ef7532c34606168a178d57a4ea6", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "pass", + "codeReview": { + "summary": "The orchestration layer now resolves authoritative linked counts during planning, prefers those counts over supplemental snapshots for safe reused rounds, and keeps the no-op/rerun classification aligned with actual linked state discovered from review/resource surfaces.", + "issues": [] + }, + "sharedStateObservations": [], + "addressesFailureFrom": null, + "summary": "Reviewed the worker handoff, transcript skeleton, and end-to-end orchestration changes for `mm-importer-end-to-end-orchestration` at commit `561d064d677f9ef7532c34606168a178d57a4ea6`. Result: pass. The linked-count resolver and existing-state merge logic improve dry-run/apply convergence without overriding authoritative reuse matching." +} diff --git a/.factory/validation/participant-scores/scrutiny/reviews/mm-importer-final-scores.json b/.factory/validation/participant-scores/scrutiny/reviews/mm-importer-final-scores.json new file mode 100644 index 0000000..7c0fa35 --- /dev/null +++ b/.factory/validation/participant-scores/scrutiny/reviews/mm-importer-final-scores.json @@ -0,0 +1,22 @@ +{ + "featureId": "mm-importer-final-scores", + "reviewedAt": "2026-04-03T00:51:16Z", + "commitId": "dde7317", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "pass", + "codeReview": { + "summary": "The final-score feature correctly attaches one final summation to each member's latest imported non-example submission, preserves the agreed fallback precedence, and distinguishes missing-member skips from finalist-without-attachable-submission skips.", + "issues": [ + { + "file": "data-migration/src/scripts/importHistoricalMarathonMatches/finalScores.js", + "line": 182, + "severity": "non_blocking", + "description": "`const rankingScore = rankingScoreByRoundCoder.get(`${roundId}:${coderId}`) || null;` treats a legitimate fallback ranking score of `0` as missing because `0 || null` returns `null`. If a future round needs the ranking-score fallback for a zero-point finalist, apply mode will throw `missing a numeric score` instead of importing `aggregateScore: 0`. Use nullish handling (`?? null`) so zero remains a valid fallback score." + } + ] + }, + "sharedStateObservations": [], + "addressesFailureFrom": null, + "summary": "Reviewed the worker handoff, transcript skeleton, and final-score reconciliation paths for `mm-importer-final-scores` at commit `dde7317`. Result: pass with one non-blocking edge-case note. The main logic is sound, but the ranking-score fallback currently drops legitimate zero values because it uses `|| null` instead of nullish handling." +} diff --git a/.factory/validation/participant-scores/scrutiny/reviews/mm-importer-missing-member-planning-and-reporting.json b/.factory/validation/participant-scores/scrutiny/reviews/mm-importer-missing-member-planning-and-reporting.json new file mode 100644 index 0000000..f574d96 --- /dev/null +++ b/.factory/validation/participant-scores/scrutiny/reviews/mm-importer-missing-member-planning-and-reporting.json @@ -0,0 +1,15 @@ +{ + "featureId": "mm-importer-missing-member-planning-and-reporting", + "reviewedAt": "2026-04-03T00:51:16Z", + "commitId": "0bb291a", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "pass", + "codeReview": { + "summary": "The missing-member planning/reporting implementation now fails closed when member-resolution prerequisites are unavailable, keeps `--existing-state-file` supplemental-only, and emits deterministic planned/apply skip artifacts with stable reason codes and affected surfaces.", + "issues": [] + }, + "sharedStateObservations": [], + "addressesFailureFrom": null, + "summary": "Reviewed the worker handoff, transcript skeleton, and resulting planning/apply code paths for `mm-importer-missing-member-planning-and-reporting` at commit `0bb291a`. Result: pass. The feature cleanly partitions resources/submissions/finals/provisionals into materialized vs. missing-member vs. explicit-skip buckets and preserves deterministic skipped-artifact output." +} diff --git a/.factory/validation/participant-scores/scrutiny/reviews/mm-importer-provisional-scores.json b/.factory/validation/participant-scores/scrutiny/reviews/mm-importer-provisional-scores.json new file mode 100644 index 0000000..89fef00 --- /dev/null +++ b/.factory/validation/participant-scores/scrutiny/reviews/mm-importer-provisional-scores.json @@ -0,0 +1,15 @@ +{ + "featureId": "mm-importer-provisional-scores", + "reviewedAt": "2026-04-03T00:51:16Z", + "commitId": "f52e93aa7e4bc8a1879072afcf58577b44cfdc2e", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "pass", + "codeReview": { + "summary": "Provisional-score import mirrors submission identity one-to-one via `legacySubmissionId`, rejects member mismatches against imported submissions, and preserves rerun-stable score identities while propagating missing-member skip records.", + "issues": [] + }, + "sharedStateObservations": [], + "addressesFailureFrom": null, + "summary": "Reviewed the worker handoff, transcript skeleton, and provisional-score reconciliation code for `mm-importer-provisional-scores` at commit `f52e93aa7e4bc8a1879072afcf58577b44cfdc2e`. Result: pass. The feature keeps provisional history aligned with imported non-example submissions and maintains deterministic rerun behavior for score identities and skipped entries." +} diff --git a/.factory/validation/participant-scores/scrutiny/reviews/mm-importer-submission-history.json b/.factory/validation/participant-scores/scrutiny/reviews/mm-importer-submission-history.json new file mode 100644 index 0000000..ff939f3 --- /dev/null +++ b/.factory/validation/participant-scores/scrutiny/reviews/mm-importer-submission-history.json @@ -0,0 +1,15 @@ +{ + "featureId": "mm-importer-submission-history", + "reviewedAt": "2026-04-03T00:51:16Z", + "commitId": "a0214d1fb6532ba4b8db525e144cc67f51a86591", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "pass", + "codeReview": { + "summary": "Submission-history import deterministically derives `legacySubmissionId`, excludes example runs at load time, rejects member-identity mismatches against already imported rows, and feeds per-submission missing-member skip records into apply-mode reporting.", + "issues": [] + }, + "sharedStateObservations": [], + "addressesFailureFrom": null, + "summary": "Reviewed the worker handoff, transcript skeleton, and submission import paths for `mm-importer-submission-history` at commit `a0214d1fb6532ba4b8db525e144cc67f51a86591`. Result: pass. The importer preserves non-example submission identity and distribution while guarding against cross-member legacySubmissionId reuse." +} diff --git a/.factory/validation/participant-scores/scrutiny/reviews/mm-importer-submitter-resources.json b/.factory/validation/participant-scores/scrutiny/reviews/mm-importer-submitter-resources.json new file mode 100644 index 0000000..069c66d --- /dev/null +++ b/.factory/validation/participant-scores/scrutiny/reviews/mm-importer-submitter-resources.json @@ -0,0 +1,15 @@ +{ + "featureId": "mm-importer-submitter-resources", + "reviewedAt": "2026-04-03T00:51:16Z", + "commitId": "682e77eba961e8f05d4569b93a45fb1746d491cc", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "pass", + "codeReview": { + "summary": "Apply-mode resource reconciliation correctly filters members already classified as `missing-member` before Resource API creates, deduplicates against existing submitter tuples, and restores challenge status after the temporary writable transition path.", + "issues": [] + }, + "sharedStateObservations": [], + "addressesFailureFrom": null, + "summary": "Reviewed the worker handoff, transcript skeleton, and current apply/resource API integration for `mm-importer-submitter-resources` at commit `682e77eba961e8f05d4569b93a45fb1746d491cc`. Result: pass. The feature now skips planned missing members during resource creation and keeps the completed-challenge restoration logic bounded to the approved exception path." +} diff --git a/.factory/validation/participant-scores/scrutiny/synthesis.json b/.factory/validation/participant-scores/scrutiny/synthesis.json new file mode 100644 index 0000000..62cdc56 --- /dev/null +++ b/.factory/validation/participant-scores/scrutiny/synthesis.json @@ -0,0 +1,33 @@ +{ + "milestone": "participant-scores", + "round": 1, + "status": "pass", + "validatorsRun": { + "test": { + "passed": true, + "command": "source \"$HOME/.config/nvm/nvm.sh\" && (cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration\" && nvm use 18.19.0 >/dev/null && pnpm test --maxWorkers=16 --testPathIgnorePatterns test/challenge-migration.test.js)", + "exitCode": 0 + }, + "typecheck": { + "passed": true, + "command": "echo \"No dedicated typecheck command for this JavaScript importer surface\"", + "exitCode": 0 + }, + "lint": { + "passed": true, + "command": "source \"$HOME/.config/nvm/nvm.sh\" && (cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration\" && nvm use 18.19.0 >/dev/null && pnpm lint)", + "exitCode": 0 + } + }, + "reviewsSummary": { + "total": 6, + "passed": 6, + "failed": 0, + "failedFeatures": [] + }, + "blockingIssues": [], + "appliedUpdates": [], + "suggestedGuidanceUpdates": [], + "rejectedObservations": [], + "previousRound": null +} From 2ccc4f3d028ab5a35d28f39469a60fe6f16c5e39 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 3 Apr 2026 12:04:55 +1100 Subject: [PATCH 24/27] Preserve zero-valued ranking fallback scores in final-score import Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../finalScores.js | 2 +- ...toricalMarathonMatches.finalScores.test.js | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/finalScores.js b/data-migration/src/scripts/importHistoricalMarathonMatches/finalScores.js index af4efb5..8012ba9 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/finalScores.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/finalScores.js @@ -179,7 +179,7 @@ const loadLegacyFinalRowsByRoundId = async ({ const systemPointTotal = parseNumericScore(row && row.system_point_total); const pointTotal = parseNumericScore(row && row.point_total); - const rankingScore = rankingScoreByRoundCoder.get(`${roundId}:${coderId}`) || null; + const rankingScore = rankingScoreByRoundCoder.get(`${roundId}:${coderId}`) ?? null; const { aggregateScore, scoreSource } = deriveFinalScore({ systemPointTotal, pointTotal, diff --git a/data-migration/test/importHistoricalMarathonMatches.finalScores.test.js b/data-migration/test/importHistoricalMarathonMatches.finalScores.test.js index 560a2c0..ad489c3 100644 --- a/data-migration/test/importHistoricalMarathonMatches.finalScores.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.finalScores.test.js @@ -98,6 +98,47 @@ describe("importHistoricalMarathonMatches final score import", () => { ]); }); + test("preserves ranking-score fallback value of zero as a valid final score", async () => { + const zeroFallbackFixtureDir = fs.mkdtempSync( + path.join(os.tmpdir(), "mm-final-scores-zero-fallback-fixture-") + ); + try { + writeJson( + zeroFallbackFixtureDir, + "long_component_state_1.json", + "long_component_state", + [{ long_component_state_id: "3001", round_id: "9999", coder_id: "6", points: "0" }] + ); + writeJson( + zeroFallbackFixtureDir, + "long_comp_result_1.json", + "long_comp_result", + [{ round_id: "9999", coder_id: "6", system_point_total: null, point_total: null, placed: "1" }] + ); + + const rowsByRoundId = await loadLegacyFinalRowsByRoundId({ + dataDir: zeroFallbackFixtureDir, + longComponentStateFile: "long_component_state_1.json", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + roundIds: ["9999"], + }); + + const zeroFallbackRow = (rowsByRoundId.get("9999") || []).find( + (row) => row.coderId === "6" + ); + expect(zeroFallbackRow).toEqual( + expect.objectContaining({ + legacyRoundId: "9999", + coderId: "6", + scoreSource: "ranking_score", + aggregateScore: 0, + }) + ); + } finally { + fs.rmSync(zeroFallbackFixtureDir, { recursive: true, force: true }); + } + }); + test("attaches one final per member to latest imported non-example submission and tracks skips", async () => { const rowsByRoundId = await loadLegacyFinalRowsByRoundId({ dataDir: fixtureDir, From e1a794b1fa26e28f6a005da306180331625b9eba Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 5 Apr 2026 17:38:58 +1000 Subject: [PATCH 25/27] Persist participant-scores user-testing artifacts Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .factory/library/user-testing.md | 1 + .../flows/existing-v6-reruns.json | 72 ++++ .../user-testing/flows/plan-dry-run.json | 201 +++++++++++ .../flows/round-10815-participants.json | 207 ++++++++++++ .../flows/round-10815-scores-cross.json | 315 ++++++++++++++++++ .../flows/round-17948-finals.json | 176 ++++++++++ .../user-testing/synthesis.json | 62 ++++ 7 files changed, 1034 insertions(+) create mode 100644 .factory/validation/participant-scores/user-testing/flows/existing-v6-reruns.json create mode 100644 .factory/validation/participant-scores/user-testing/flows/plan-dry-run.json create mode 100644 .factory/validation/participant-scores/user-testing/flows/round-10815-participants.json create mode 100644 .factory/validation/participant-scores/user-testing/flows/round-10815-scores-cross.json create mode 100644 .factory/validation/participant-scores/user-testing/flows/round-17948-finals.json create mode 100644 .factory/validation/participant-scores/user-testing/synthesis.json diff --git a/.factory/library/user-testing.md b/.factory/library/user-testing.md index bde5983..f86b584 100644 --- a/.factory/library/user-testing.md +++ b/.factory/library/user-testing.md @@ -69,6 +69,7 @@ If the approved missing-member policy is exercised, validators should reconcile - In the current shared dev environment, `13897` is a partial-backfill fixture: the challenge and standard phases already exist, while linked resources/submissions/review-summation surfaces still read as empty. That means no-op rerun assertions for a fully imported round cannot be proven here without a separately completed fixture. - `GET https://api.topcoder-dev.com/v6/challenges` and `GET https://api.topcoder-dev.com/v6/challenges/` work without auth in this environment. `GET https://api.topcoder-dev.com/v6/resources?challengeId=` and `GET https://api.topcoder-dev.com/v6/submissions?challengeId=` are also readable without auth. - `GET https://api.topcoder-dev.com/v6/reviewSummations?challengeId=` requires an M2M bearer token. Source `.env.importer.local`, run `node get_token.js`, and use the final stdout line as the token value. +- Response shapes are mixed: challenge/resource lookups return arrays directly, while `submissions` and authenticated `reviewSummations` return paginated objects with `data` and `meta`. Validators should count rows from the `data` array and set a large `perPage` value (for example `1000` or higher) before reconciling totals. - When participant backfill encounters legacy members absent from the dev environment, validators should expect a skipped-file artifact and should confirm that the skipped member ids plus the imported member-owned records reconcile back to the legacy totals for the round. - Round `14272` currently dry-runs as `decision=unresolved` with reason `selected-round-round-type-is-not-marathon-match`; it remains useful for exact-filter and unresolved-path validation but should not be treated as an importable Marathon Match fixture. - Previously considered score candidates such as `10089` and `10722` should not be assumed valid Marathon Match fixtures in the current validation environment unless a later score-feature investigation reconfirms them. diff --git a/.factory/validation/participant-scores/user-testing/flows/existing-v6-reruns.json b/.factory/validation/participant-scores/user-testing/flows/existing-v6-reruns.json new file mode 100644 index 0000000..bb1446b --- /dev/null +++ b/.factory/validation/participant-scores/user-testing/flows/existing-v6-reruns.json @@ -0,0 +1,72 @@ +{ + "groupId": "existing-v6-reruns", + "testedAt": "2026-04-04T22:24:03Z", + "isolation": { + "surface": "importer CLI + API verification", + "roundIds": [ + 13897, + 17948 + ], + "environment": "shared existing dev environment", + "repoRoot": "/home/jmgasper/Documents/Git/v6/challenge-api-v6", + "missionDir": "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1" + }, + "toolsUsed": [ + "node", + "curl", + "python3" + ], + "assertions": [ + { + "id": "VAL-CROSS-002", + "title": "Existing v6 marathon matches are backfill-only and preserve prior linked records while skipping missing members", + "status": "pass", + "steps": [ + { + "action": "Review the archived existing-v6 apply comparator for round 13897.", + "expected": "Apply should preserve challenge-level and already-present linked-record snapshots while adding no duplicate work.", + "observed": "Round 13897 apply remained a pure no-op: `createdSubmitterResources=0`, `createdSubmissions=0`, `createdFinalScores=0`, and `createdProvisionalScores=0`; challenge field snapshot and resource/submission/review identity sets remained unchanged." + } + ], + "evidence": { + "files": [ + "participant-scores/user-testing-reruns/13897-pre-summary.json", + "participant-scores/user-testing-reruns/13897-post-summary.json", + "participant-scores/user-testing-reruns/13897-apply.stdout.txt", + "participant-scores/user-testing-reruns/13897-skipped.json" + ], + "consoleErrors": "n/a", + "network": "Existing-v6 challenge/resource/submission/review snapshots were compared pre/post apply." + }, + "issues": null + }, + { + "id": "VAL-CROSS-005", + "title": "A second apply run preserves the fully reconciled steady state across every writable surface", + "status": "pass", + "steps": [ + { + "action": "Compare pre/post summaries for fully imported rounds 13897 and 17948 after rerun apply validation.", + "expected": "Challenge/resource/submission/review identity sets should remain unchanged and new writes should be zero.", + "observed": "Round 13897 pre/post summaries matched exactly (`resourceCount=796`, `submissionCount=1784`, `reviewSummationCount=2009`), and round 17948 pre/post summaries also matched exactly (`resourceCount=47`, `submissionCount=370`, `reviewSummationCount=415`). Their apply outputs both reported zero created resources/submissions/finals/provisionals." + } + ], + "evidence": { + "files": [ + "participant-scores/user-testing-reruns/13897-pre-summary.json", + "participant-scores/user-testing-reruns/13897-post-summary.json", + "participant-scores/user-testing-reruns/17948-pre-summary.json", + "participant-scores/user-testing-reruns/17948-post-summary.json", + "participant-scores/user-testing-reruns/13897-apply.stdout.txt", + "participant-scores/user-testing-reruns/17948-apply.stdout.txt" + ], + "consoleErrors": "n/a", + "network": "Steady-state verification used shared-env rerun applies plus pre/post API summaries." + }, + "issues": null + } + ], + "frictions": [], + "blockers": [], + "summary": "Validated the 2 remaining existing-v6 rerun assertions: both passed. Shared-environment rerun evidence for 13897 and 17948 showed pure no-op apply behavior with unchanged challenge/resource/submission/review footprints." +} diff --git a/.factory/validation/participant-scores/user-testing/flows/plan-dry-run.json b/.factory/validation/participant-scores/user-testing/flows/plan-dry-run.json new file mode 100644 index 0000000..ab3ac31 --- /dev/null +++ b/.factory/validation/participant-scores/user-testing/flows/plan-dry-run.json @@ -0,0 +1,201 @@ +{ + "groupId": "plan-dry-run", + "testedAt": "2026-04-04T22:24:03Z", + "isolation": { + "surface": "importer CLI + API verification", + "roundIds": [ + 10815, + 17948, + 14272 + ], + "environment": "shared existing dev environment", + "repoRoot": "/home/jmgasper/Documents/Git/v6/challenge-api-v6", + "missionDir": "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1" + }, + "toolsUsed": [ + "node", + "curl", + "python3" + ], + "assertions": [ + { + "id": "VAL-PLAN-003", + "title": "Planning prerequisites are distinguished from apply-only prerequisites", + "status": "pass", + "steps": [ + { + "action": "Review the fail-closed dry-run captures with authoritative challenge discovery intentionally unavailable and with member resolution intentionally unavailable.", + "expected": "Dry-run should resolve to `unresolved` with an explicit prerequisite reason instead of fabricating zero skips or a safe create/reuse decision.", + "observed": "The authoritative-discovery run reported `decision=unresolved` with reason `authoritative-existing-v6-discovery-unavailable`; the member-resolution run reported `decision=unresolved` with reason `target-member-resolution-unavailable`." + }, + { + "action": "Compare those fail-closed captures to the restored-env dry-runs for rounds 10815 and 17948.", + "expected": "Once prerequisites are available, dry-run should emit populated partitions and stable decisions.", + "observed": "Current and archived dry-runs for 10815/17948 emitted populated resource/submission/final/provisional partitions, matched challenge ids, and stable skip counts instead of unresolved output." + } + ], + "evidence": { + "files": [ + "participant-scores/planning-reporting/dry-run-17948-authoritative-unavailable.stdout.txt", + "participant-scores/planning-reporting/dry-run-17948-authoritative-unavailable.stderr.txt", + "participant-scores/planning-reporting/dry-run-17948-member-resolution-unavailable.stdout.txt", + "participant-scores/planning-reporting/dry-run-17948-member-resolution-unavailable.stderr.txt", + "participant-scores/plan-dry-run/current-dry-run-10815.stdout.txt", + "participant-scores/plan-dry-run/current-dry-run-17948.stdout.txt" + ], + "consoleErrors": "Expected warning-only fail-closed messages for unreachable DB/member lookup in the negative-prerequisite runs.", + "network": "Dry-run CLI only for prerequisite-negative checks; no writes were performed." + }, + "issues": null + }, + { + "id": "VAL-PLAN-008", + "title": "Reused rounds expose entity-level deltas", + "status": "pass", + "steps": [ + { + "action": "Compare dry-run for an existing-v6 round with and without a supplemental `--existing-state-file` snapshot.", + "expected": "Decision and matched challenge id should remain authoritative while delta counts may be enriched by the snapshot.", + "observed": "For round 13897, the decision stayed `reuse/backfill-only` with the same matched challenge id; only supplemental alreadyPresent/toImport counts changed when the snapshot was supplied." + }, + { + "action": "Inspect current existing-v6 dry-runs for 10815 and 17948.", + "expected": "Entity-level deltas should be broken out by phases/resources/submissions/finalScores/provisionalScores.", + "observed": "Both current dry-runs exposed separate per-entity deltas and partitions rather than an opaque reuse result." + } + ], + "evidence": { + "files": [ + "participant-scores/planning-reporting/dry-run-10815.stdout.txt", + "participant-scores/planning-reporting/dry-run-17948.stdout.txt", + "participant-scores/plan-dry-run/current-dry-run-10815.stdout.txt", + "participant-scores/plan-dry-run/current-dry-run-17948.stdout.txt" + ], + "consoleErrors": "n/a (CLI/API validation surface)", + "network": "Validated using archived live dry-run evidence plus the current shared-env dry-runs." + }, + "issues": null + }, + { + "id": "VAL-PLAN-010", + "title": "Resource planning reconciles resolvable registrants and missing-member skips", + "status": "pass", + "steps": [ + { + "action": "Inspect the round-10815 dry-run resource partitions and planned skip records.", + "expected": "`toCreate + alreadyPresent + missing-member` should reconcile exactly to the eligible registrant total and expose per-member missing-member records for the resource surface.", + "observed": "Round 10815 reported `eligibleRegistrants=836` with resources `toCreate=1`, `alreadyPresent=827`, and `missingMember=8`; the planned skip records included per-member `missing-member` entries whose affected surfaces included `resource`." + } + ], + "evidence": { + "files": [ + "participant-scores/planning-reporting/dry-run-10815.stdout.txt", + "participant-scores/plan-dry-run/current-dry-run-10815.stdout.txt" + ], + "consoleErrors": "n/a", + "network": "Dry-run CLI only." + }, + "issues": null + }, + { + "id": "VAL-PLAN-011", + "title": "Submission planning excludes examples and quantifies missing-member skips", + "status": "pass", + "steps": [ + { + "action": "Inspect the round-10815 submission partitions in dry-run output.", + "expected": "The plan should separate example rows, imported/no-op rows, and missing-member submission skips so totals reconcile to legacy non-example submissions.", + "observed": "Round 10815 reported `legacyNonExample=1445`, `legacyExampleFiltered=2424`, `alreadyPresent=1444`, and `missingMember=1`, with a per-submission missing-member planned skip record for legacySubmissionId `35348310001`." + } + ], + "evidence": { + "files": [ + "participant-scores/planning-reporting/dry-run-10815.stdout.txt", + "participant-scores/plan-dry-run/current-dry-run-10815.stdout.txt" + ], + "consoleErrors": "n/a", + "network": "Dry-run CLI only." + }, + "issues": null + }, + { + "id": "VAL-PLAN-012", + "title": "Final and provisional score plans quantify imported and skipped subsets", + "status": "pass", + "steps": [ + { + "action": "Inspect 10815 dry-run output for fallback-heavy score partitions.", + "expected": "Final and provisional streams should separate imported/no-op, missing-member, and other explicit skip reasons.", + "observed": "Round 10815 reported final scores `legacyFinalCandidates=283`, `alreadyPresent=266`, `missingMember=2`, `explicitSkips=15` (`finalist-without-attachable-submission`), and provisional scores `legacyNonExample=1445`, `alreadyPresent=1444`, `missingMember=1`." + }, + { + "action": "Inspect the score-rich 17948 dry-run output.", + "expected": "The score-rich fixture should show the imported/no-op subset and missing-member subset without explicit unattachable-finalist skips.", + "observed": "Round 17948 reported finals `81 = 45 alreadyPresent + 36 missingMember + 0 explicitSkips` and provisional `700 = 370 alreadyPresent + 330 missingMember + 0 explicitSkips`." + } + ], + "evidence": { + "files": [ + "participant-scores/planning-reporting/dry-run-10815.stdout.txt", + "participant-scores/planning-reporting/dry-run-17948.stdout.txt", + "participant-scores/plan-dry-run/current-dry-run-10815.stdout.txt", + "participant-scores/plan-dry-run/current-dry-run-17948.stdout.txt" + ], + "consoleErrors": "n/a", + "network": "Dry-run CLI only." + }, + "issues": null + }, + { + "id": "VAL-PLAN-014", + "title": "Rerun planning classifies imported work as no-op and preserved skips as stable", + "status": "pass", + "steps": [ + { + "action": "Review the linked-count orchestration verification for existing-v6 round 13897.", + "expected": "A fully imported reused round should report no-op rerun classification with stable skip identities and zero toCreate deltas.", + "observed": "Two consecutive dry-runs for 13897 both reported `rerunClassification=no-op`, all entity `toCreate` deltas at 0, and identical planned skip identity/reason/surface sets (47 records)." + }, + { + "action": "Re-run current dry-run on score-rich round 17948.", + "expected": "Current live output should still classify the fully imported round as no-op.", + "observed": "Current round-17948 dry-run reported `decision=reuse/backfill-only` and `rerunClassification=no-op` with all `toCreate` values at 0." + } + ], + "evidence": { + "files": [ + "participant-scores/plan-dry-run/current-dry-run-17948.stdout.txt", + "participant-scores/user-testing-reruns/13897-pre-summary.json", + "participant-scores/user-testing-reruns/13897-post-summary.json" + ], + "consoleErrors": "n/a", + "network": "Current dry-run plus archived live no-op rerun evidence from the shared dev environment." + }, + "issues": null + }, + { + "id": "VAL-PLAN-015", + "title": "Missing-member skips are distinguished from other skip reasons", + "status": "pass", + "steps": [ + { + "action": "Inspect the dry-run skipped-file metadata and planned skip records for 10815.", + "expected": "The report should distinguish `missing-member` from non-missing-member skip reasons and identify the skipped-file artifact.", + "observed": "Round 10815 dry-run identified skipped artifact path(s) and reason codes `missing-member` plus `finalist-without-attachable-submission`; planned skip records included affected surfaces and per-member counts for each reason." + } + ], + "evidence": { + "files": [ + "participant-scores/planning-reporting/dry-run-10815.stdout.txt", + "participant-scores/round-10815-footprint/round-10815-apply-skipped.json" + ], + "consoleErrors": "n/a", + "network": "Dry-run CLI only for planning evidence; apply artifact inspected read-only." + }, + "issues": null + } + ], + "frictions": [], + "blockers": [], + "summary": "Validated 7 planning assertions for participant-scores: all passed. Evidence covered fail-closed prerequisite handling, authoritative existing-state classification, exact resource/submission/score partitions for 10815 and 17948, stable no-op rerun planning on fully imported rounds, and distinct skip reasons plus skipped-artifact metadata." +} diff --git a/.factory/validation/participant-scores/user-testing/flows/round-10815-participants.json b/.factory/validation/participant-scores/user-testing/flows/round-10815-participants.json new file mode 100644 index 0000000..5dbf3c0 --- /dev/null +++ b/.factory/validation/participant-scores/user-testing/flows/round-10815-participants.json @@ -0,0 +1,207 @@ +{ + "groupId": "round-10815-participants", + "testedAt": "2026-04-04T22:24:03Z", + "isolation": { + "surface": "importer CLI + API verification", + "roundId": 10815, + "challengeId": "5fa76bd9-da55-422d-8d4c-4f0155dc62c5", + "environment": "shared existing dev environment", + "repoRoot": "/home/jmgasper/Documents/Git/v6/challenge-api-v6", + "missionDir": "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1" + }, + "toolsUsed": [ + "node", + "curl", + "python3" + ], + "assertions": [ + { + "id": "VAL-PARTICIPANT-001", + "title": "Submitter resources reconcile imported registrants and missing-member skips", + "status": "pass", + "steps": [ + { + "action": "Run apply for round 10815 and inspect resource reconciliation plus skipped-artifact resource entries.", + "expected": "Resolvable eligible registrants should appear as submitter resources while missing members appear only in the skipped artifact.", + "observed": "Apply completed with `targetEligibleRegistrants=828` resolvable members and emitted 8 `missing-member` resource entries; together those reconciled to the 836 eligible legacy registrants. The run continued past missing member 2058745 instead of aborting." + }, + { + "action": "Check current API summary against the archived round-10815 footprint.", + "expected": "The imported resource surface should remain populated and contain no example data.", + "observed": "Current API summary still showed 828 resource rows for challenge 10815, matching the archived footprint. AGENTS.md documents one pre-existing duplicate submitter row in this shared fixture and instructs validators not to treat it as an importer regression." + } + ], + "evidence": { + "files": [ + "participant-scores/round-10815-footprint/importer-apply.stdout.txt", + "participant-scores/round-10815-footprint/round-10815-apply-skipped.json", + "participant-scores/round-10815-footprint/post-resources.json", + "participant-scores/plan-dry-run/current-api-summary.json" + ], + "consoleErrors": "n/a", + "network": "GET /v6/resources?challengeId=5fa76bd9-da55-422d-8d4c-4f0155dc62c5 -> 200" + }, + "issues": null + }, + { + "id": "VAL-PARTICIPANT-002", + "title": "Imported participant identities stay consistent across APIs", + "status": "pass", + "steps": [ + { + "action": "Run the archived round-10815 legacy/API reconciliation for resources and submissions.", + "expected": "The same memberId should not be split across conflicting handles or cross-API identities.", + "observed": "The reconciliation reported `cross-API handle conflicts=0` and `split handles across memberIds=0` for round 10815." + } + ], + "evidence": { + "files": [ + "participant-scores/round-10815-footprint/post-resources.json", + "participant-scores/round-10815-footprint/post-submissions.json" + ], + "consoleErrors": "n/a", + "network": "Resource API and submissions API snapshots were compared against legacy identity mappings." + }, + "issues": null + }, + { + "id": "VAL-PARTICIPANT-003", + "title": "Challenge-level submission import reconciles imported and missing-member volume", + "status": "pass", + "steps": [ + { + "action": "Inspect round-10815 apply submission reconciliation output and skipped artifact.", + "expected": "Imported submissions plus missing-member skipped submissions should reconcile to legacy non-example submission volume and distinct submitter volume.", + "observed": "Apply reported `legacyNonExampleSubmissions=1445`, `importedSubmissions=1444`, `missingMemberSkippedSubmissions=1`, `importedDistinctSubmitters=266`, and `missingMemberDistinctSubmitters=1`, which reconciled exactly to the legacy round totals." + } + ], + "evidence": { + "files": [ + "participant-scores/round-10815-footprint/importer-apply.stdout.txt", + "participant-scores/round-10815-footprint/round-10815-apply-skipped.json", + "participant-scores/round-10815-footprint/post-submissions.json" + ], + "consoleErrors": "n/a", + "network": "GET /v6/submissions?challengeId=5fa76bd9-da55-422d-8d4c-4f0155dc62c5&perPage=3000 -> 200" + }, + "issues": null + }, + { + "id": "VAL-PARTICIPANT-004", + "title": "Mixed-history members keep every non-example submission and exclude examples", + "status": "pass", + "steps": [ + { + "action": "Run the archived Marinov member-specific timestamp reconciliation.", + "expected": "Member 22664170 should have all 27 non-example submissions, exclude 55 example runs, and preserve the latest non-example timestamp.", + "observed": "Marinov imported count was 27, imported timestamps overlapped only legacy non-example timestamps, and the max imported timestamp matched `1180539064719`; none of the 55 example runs appeared in imported submissions." + } + ], + "evidence": { + "files": [ + "participant-scores/round-10815-footprint/post-submissions.json" + ], + "consoleErrors": "n/a", + "network": "Submission API output was compared to read-only Informix legacy rows for member 22664170." + }, + "issues": null + }, + { + "id": "VAL-PARTICIPANT-005", + "title": "Whole-round submission identity and per-member distribution reconcile through skips", + "status": "pass", + "steps": [ + { + "action": "Run the archived whole-round submission identity reconciliation for 10815.", + "expected": "Imported submission legacy ids plus skipped missing-member submission identities should match legacy one-for-one and preserve per-member counts.", + "observed": "The reconciliation reported `importedPlusSkippedEqualsLegacy=true`, `importedUnionSkippedEqualsLegacy=true`, `importedUnionSkipped submitter set=true`, and `resolvableMismatchCount=0`." + } + ], + "evidence": { + "files": [ + "participant-scores/round-10815-footprint/post-submissions.json", + "participant-scores/round-10815-footprint/round-10815-apply-skipped.json" + ], + "consoleErrors": "n/a", + "network": "Submission API identities were reconciled against legacy long_submission rows and skipped-artifact entries." + }, + "issues": null + }, + { + "id": "VAL-PARTICIPANT-006", + "title": "Participant reruns preserve stable imported identities and skipped-member reporting", + "status": "pass", + "steps": [ + { + "action": "Compare the first and second round-10815 apply runs and their skipped artifacts.", + "expected": "Imported submission identities and resource tuple sets should remain stable across reruns with deterministic skipped-member records.", + "observed": "Rerun stability checks returned true for the submission legacy-id set, resource tuple set, and canonicalized skipped-artifact records/reason codes. The documented shared-fixture duplicate submitter row did not change the tuple set." + } + ], + "evidence": { + "files": [ + "participant-scores/round-10815-footprint/importer-apply.stdout.txt", + "participant-scores/round-10815-footprint/round-10815-apply-skipped.json" + ], + "consoleErrors": "n/a", + "network": "Apply CLI plus API/legacy diffing across reruns." + }, + "issues": null + }, + { + "id": "VAL-PARTICIPANT-007", + "title": "Missing-member participant skips suppress the member's full participant footprint", + "status": "pass", + "steps": [ + { + "action": "Inspect missing-member participant entries in the round-10815 skipped artifact and compare to imported surfaces.", + "expected": "Missing members should create no resource or submission footprint while resolvable members continue importing normally.", + "observed": "The skipped artifact recorded missing members including 2058745 and 22669623 with affected surfaces covering `resource` and `submission`; imported resources/submissions reconciled fully for the remaining resolvable members." + } + ], + "evidence": { + "files": [ + "participant-scores/round-10815-footprint/round-10815-apply-skipped.json", + "participant-scores/round-10815-footprint/post-resources.json", + "participant-scores/round-10815-footprint/post-submissions.json" + ], + "consoleErrors": "n/a", + "network": "Resource and submission API snapshots were checked against skipped-artifact identities." + }, + "issues": null + }, + { + "id": "VAL-PARTICIPANT-008", + "title": "Participant skip artifacts are actionable and distinguish missing members", + "status": "pass", + "steps": [ + { + "action": "Inspect the skipped artifact schema and representative participant skip records for 10815.", + "expected": "Each missing-member participant skip should include the round id, member id, reason, and affected participant surfaces.", + "observed": "The artifact used `reasonCode=missing-member` and included `legacyRoundId`, `memberId`, `memberHandle`, `affectedSurfaces`, and per-surface counts; per-submission skips also preserved `legacySubmissionId` for later manual reconciliation." + } + ], + "evidence": { + "files": [ + "participant-scores/round-10815-footprint/round-10815-apply-skipped.json" + ], + "consoleErrors": "n/a", + "network": "Skipped-file artifact inspection only." + }, + "issues": null + } + ], + "frictions": [ + { + "description": "The shared dev fixture for round 10815 includes a documented pre-existing duplicate submitter row and reports challenge status ACTIVE instead of COMPLETED.", + "resolved": true, + "resolution": "Applied the AGENTS.md higher-precedence instruction not to treat this shared-fixture baseline as an importer regression and validated tuple-set / imported-plus-skipped reconciliation instead.", + "affectedAssertions": [ + "VAL-PARTICIPANT-001", + "VAL-PARTICIPANT-006" + ] + } + ], + "blockers": [], + "summary": "Validated all 8 participant assertions for round 10815: all passed. Evidence showed imported-plus-skipped reconciliation to legacy registration/submission totals, Marinov's full non-example history preservation, cross-API identity consistency, rerun-stable submission/resource identities, and actionable missing-member participant artifacts." +} diff --git a/.factory/validation/participant-scores/user-testing/flows/round-10815-scores-cross.json b/.factory/validation/participant-scores/user-testing/flows/round-10815-scores-cross.json new file mode 100644 index 0000000..9c7a4c7 --- /dev/null +++ b/.factory/validation/participant-scores/user-testing/flows/round-10815-scores-cross.json @@ -0,0 +1,315 @@ +{ + "groupId": "round-10815-scores-cross", + "testedAt": "2026-04-04T22:24:03Z", + "isolation": { + "surface": "importer CLI + API verification", + "roundIds": [ + 10815, + 14272 + ], + "challengeId": "5fa76bd9-da55-422d-8d4c-4f0155dc62c5", + "environment": "shared existing dev environment", + "repoRoot": "/home/jmgasper/Documents/Git/v6/challenge-api-v6", + "missionDir": "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1" + }, + "toolsUsed": [ + "node", + "curl", + "python3" + ], + "assertions": [ + { + "id": "VAL-SCORE-002", + "title": "Create-path finals match legacy fallback score sources or are explicitly skipped with distinct reasons", + "status": "pass", + "steps": [ + { + "action": "Review the round-10815 final-score apply reconciliation and API-vs-legacy comparison.", + "expected": "Each legacy finalist should either import with the agreed fallback precedence, be marked `missing-member`, or be explicitly skipped as unattachable.", + "observed": "For round 10815 the final-score reconciliation reported `imported=266`, `missingMemberSkipped=2`, and `explicitSkipped=15`; the API-vs-legacy checker reported `reconciles=true`, `scoreMatchesLegacy=true`, and `explicitUnattachableFinalSkips=15`." + } + ], + "evidence": { + "files": [ + "participant-scores/round-10815-footprint/importer-apply.stdout.txt", + "participant-scores/round-10815-footprint/round-10815-apply-skipped.json" + ], + "consoleErrors": "n/a", + "network": "Review API results were compared to legacy final-result rows through the archived validation script." + }, + "issues": null + }, + { + "id": "VAL-SCORE-004", + "title": "Whole-round provisional history maps one-to-one to imported non-example submissions", + "status": "pass", + "steps": [ + { + "action": "Run the provisional-score round-wide reconciliation for 10815.", + "expected": "Each imported non-example submission should have exactly one provisional summation and the imported provisional identity set should match imported submission identities one-to-one.", + "observed": "The provisional validator reported `checksPassed=true` with 1444 imported submissions and 1444 imported provisional summations; imported plus missing-member skipped provisional identities reconciled exactly to the 1445 legacy non-example rows." + } + ], + "evidence": { + "files": [ + "participant-scores/round-10815-footprint/post-submissions.json", + "participant-scores/round-10815-footprint/post-review-summations.json", + "participant-scores/round-10815-footprint/round-10815-apply-skipped.json" + ], + "consoleErrors": "n/a", + "network": "GET /v6/submissions and GET /v6/reviewSummations snapshots were reconciled against legacy provisional rows." + }, + "issues": null + }, + { + "id": "VAL-SCORE-005", + "title": "Mixed-history members keep the full provisional score sequence and exclude examples", + "status": "pass", + "steps": [ + { + "action": "Inspect the Marinov-specific provisional validation in the archived 10815 run.", + "expected": "Member 22664170 should retain the full 27-row provisional sequence ordered by legacySubmissionId and exclude example runs.", + "observed": "Marinov had 27 imported submissions and 27 provisional summations matching the 27-row legacy non-example sequence by `legacySubmissionId` with exact score parity; example runs remained excluded." + } + ], + "evidence": { + "files": [ + "participant-scores/round-10815-footprint/post-submissions.json", + "participant-scores/round-10815-footprint/post-review-summations.json" + ], + "consoleErrors": "n/a", + "network": "Member-scoped review and submission joins were compared against legacy provisional rows." + }, + "issues": null + }, + { + "id": "VAL-SCORE-007", + "title": "Imported legacy rounds create zero example review summations", + "status": "pass", + "steps": [ + { + "action": "Inspect current API summary and archived review-summation snapshots for imported rounds.", + "expected": "No example submissions or review summations should exist after import.", + "observed": "Current API summary showed `submissionExampleCount=0` and `reviewExampleCount=0` for 10815, 13897, and 17948; archived final/provisional validation also reported `noExampleFinalSummations=true`." + } + ], + "evidence": { + "files": [ + "participant-scores/plan-dry-run/current-api-summary.json", + "participant-scores/round-10815-footprint/post-review-summations.json" + ], + "consoleErrors": "n/a", + "network": "GET /v6/submissions and GET /v6/reviewSummations API summaries were inspected with auth for reviewSummations." + }, + "issues": null + }, + { + "id": "VAL-SCORE-008", + "title": "Finalists without non-example submissions are explicitly skipped with a non-missing-member reason", + "status": "pass", + "steps": [ + { + "action": "Inspect final-score reconciliation and skipped-artifact records for the unattachable-finalists fixture 10815.", + "expected": "Unattachable finalists should be recorded with a distinct non-missing-member reason and should not produce orphan finals.", + "observed": "Round 10815 recorded 15 `finalist-without-attachable-submission` skips and the score validator reported `noExampleFinalSummations=true` plus correct latest-submission attachment for imported finals." + } + ], + "evidence": { + "files": [ + "participant-scores/round-10815-footprint/round-10815-apply-skipped.json", + "participant-scores/round-10815-footprint/post-review-summations.json" + ], + "consoleErrors": "n/a", + "network": "Skipped artifact and Review API evidence were compared to legacy finalist rows." + }, + "issues": null + }, + { + "id": "VAL-SCORE-010", + "title": "Provisional history reconciles imported submissions and missing-member skips", + "status": "pass", + "steps": [ + { + "action": "Inspect round-10815 provisional reconciliation output and skipped artifact.", + "expected": "Imported provisional rows plus missing-member provisional skips should reconcile one-for-one to the legacy non-example submission set.", + "observed": "Apply reported `legacyNonExampleProvisionalScores=1445`, `importedProvisionalScores=1444`, and `missingMemberSkippedProvisionalScores=1`; the archived reconciliation confirmed exact identity parity after combining imported and skipped rows." + } + ], + "evidence": { + "files": [ + "participant-scores/round-10815-footprint/importer-apply.stdout.txt", + "participant-scores/round-10815-footprint/round-10815-apply-skipped.json", + "participant-scores/round-10815-footprint/post-review-summations.json" + ], + "consoleErrors": "n/a", + "network": "Review API provisional output was reconciled against legacy submission_points rows." + }, + "issues": null + }, + { + "id": "VAL-SCORE-011", + "title": "Missing-member score skips suppress the member's full score footprint", + "status": "pass", + "steps": [ + { + "action": "Inspect missing-member score entries for round 10815 and compare to review surfaces.", + "expected": "Missing members should not produce final or provisional review rows.", + "observed": "Member 22669623 was recorded in the skipped artifact for `final-score` and `provisional-score` (plus resource/submission) and did not appear as an imported final/provisional participant in the validated API-vs-legacy checks." + } + ], + "evidence": { + "files": [ + "participant-scores/round-10815-footprint/round-10815-apply-skipped.json", + "participant-scores/round-10815-footprint/post-review-summations.json" + ], + "consoleErrors": "n/a", + "network": "Review API and skipped-artifact comparison only." + }, + "issues": null + }, + { + "id": "VAL-CROSS-001", + "title": "Round 10815 reaches the fully reconciled expected footprint modulo missing-member skips", + "status": "pass", + "steps": [ + { + "action": "Review the archived 10815 reconciliation checker against challenge/resources/submissions/review surfaces and skipped artifact.", + "expected": "The visible footprint plus missing-member skips should reconcile to the expected legacy totals and every visible review row should attach to a visible imported submission.", + "observed": "The checker reported resources `828+8=836`, submissions `1444+1=1445`, and visible submitters `266+1=267`; all visible reviewSummations referenced visible imported submissions. AGENTS.md documents the shared-fixture challenge status `ACTIVE` as a known pre-existing baseline that should not be treated as an importer regression." + } + ], + "evidence": { + "files": [ + "participant-scores/round-10815-footprint/pre-summary.json", + "participant-scores/round-10815-footprint/post-summary.json", + "participant-scores/round-10815-footprint/round-10815-apply-skipped.json", + "participant-scores/plan-dry-run/current-api-summary.json" + ], + "consoleErrors": "n/a", + "network": "Challenge API, Resource API, submissions API, and reviewSummations API were all used in the archived reconciliation." + }, + "issues": null + }, + { + "id": "VAL-CROSS-003", + "title": "Registered non-submitters stay resources-only while missing members stay fully skipped", + "status": "pass", + "steps": [ + { + "action": "Inspect the archived participant and score reconciliation for 10815.", + "expected": "Registered non-submitters should appear only as resources; missing members should appear only in skipped artifacts and not on imported writable surfaces.", + "observed": "The reconciliation reported imported submission/final/provisional member sets plus skipped missing-member entries matching the legacy non-example submitter set; missing members were absent from imported resource/submission/score surfaces and present only in the skipped artifact." + } + ], + "evidence": { + "files": [ + "participant-scores/round-10815-footprint/post-resources.json", + "participant-scores/round-10815-footprint/post-submissions.json", + "participant-scores/round-10815-footprint/post-review-summations.json", + "participant-scores/round-10815-footprint/round-10815-apply-skipped.json" + ], + "consoleErrors": "n/a", + "network": "Resource, submissions, and review APIs were compared against legacy participant roles." + }, + "issues": null + }, + { + "id": "VAL-CROSS-004", + "title": "Score history stays attached to the correct non-example submission across surfaces", + "status": "pass", + "steps": [ + { + "action": "Review the archived Marinov cross-surface validation for 10815.", + "expected": "The latest imported non-example submission should carry the final score and provisional rows should attach only to imported non-example submissions.", + "observed": "Marinov's latest non-example submission timestamp `1180539064719` carried the attached final score, and the 27 provisional rows all joined to imported non-example submissions only." + } + ], + "evidence": { + "files": [ + "participant-scores/round-10815-footprint/post-submissions.json", + "participant-scores/round-10815-footprint/post-review-summations.json" + ], + "consoleErrors": "n/a", + "network": "Member-scoped submission/review joins were used for the archived validation." + }, + "issues": null + }, + { + "id": "VAL-CROSS-006", + "title": "Multi-round filters bound the entire import blast radius including skipped-member subsets", + "status": "pass", + "steps": [ + { + "action": "Review the archived multi-round apply comparator for 10815 and 14272 with control round 17948.", + "expected": "Writes and skipped entries should stay confined to selected round ids and the control round should remain unchanged.", + "observed": "The archived apply summary reported `existing=1 (10815)` and `unresolved=1 (14272)`; the skipped artifact referenced only round 10815, and the control round 17948 remained unchanged in pre/post snapshots." + } + ], + "evidence": { + "files": [ + "participant-scores/planning-reporting/dry-run-14272-control.stdout.txt", + "participant-scores/plan-dry-run/current-api-summary.json" + ], + "consoleErrors": "n/a", + "network": "Challenge/resource/submission/review snapshots were compared before and after the multi-round apply run." + }, + "issues": null + }, + { + "id": "VAL-CROSS-007", + "title": "Missing-member skips suppress the member's entire writable footprint across surfaces", + "status": "pass", + "steps": [ + { + "action": "Inspect the round-10815 missing-member artifact and the archived cross-surface consistency check.", + "expected": "Missing members should have no imported resource, submission, final, or provisional rows for the affected surfaces.", + "observed": "The consistency check found zero violations: missing-member entries corresponded to absent linked records on the affected surfaces, while resolvable members imported normally." + } + ], + "evidence": { + "files": [ + "participant-scores/round-10815-footprint/round-10815-apply-skipped.json", + "participant-scores/round-10815-footprint/post-resources.json", + "participant-scores/round-10815-footprint/post-submissions.json", + "participant-scores/round-10815-footprint/post-review-summations.json" + ], + "consoleErrors": "n/a", + "network": "Resource, submissions, and review APIs were checked against skipped-artifact identities." + }, + "issues": null + }, + { + "id": "VAL-CROSS-008", + "title": "Skipped-file reasons stay consistent with linked-record presence across surfaces", + "status": "pass", + "steps": [ + { + "action": "Run the archived skip-reason consistency checker for 10815.", + "expected": "Missing-member and non-missing-member reasons should align with the actual presence/absence of linked records on the affected surfaces.", + "observed": "The checker returned zero violations: `missing-member` entries mapped to absent linked records on the named surfaces, and `finalist-without-attachable-submission` entries remained distinct from missing-member cases." + } + ], + "evidence": { + "files": [ + "participant-scores/round-10815-footprint/round-10815-apply-skipped.json", + "participant-scores/round-10815-footprint/post-review-summations.json" + ], + "consoleErrors": "n/a", + "network": "Skipped-artifact and linked-record API comparison only." + }, + "issues": null + } + ], + "frictions": [ + { + "description": "The round-10815 shared fixture remains ACTIVE even though the original planning create-path validation created it as COMPLETED.", + "resolved": true, + "resolution": "Applied the higher-precedence AGENTS.md guidance that this is a shared-fixture baseline issue, then validated the participant/score footprint modulo that known non-regression.", + "affectedAssertions": [ + "VAL-CROSS-001" + ] + } + ], + "blockers": [], + "summary": "Validated 13 score/cross assertions centered on round 10815 and the 10815/14272 multi-round filter: all passed. Evidence confirmed fallback-heavy final-score reconciliation, one-to-one provisional history, zero example score artifacts, explicit unattachable-finalist skips, full imported-plus-skipped cross-surface reconciliation, and exact selected-round confinement." +} diff --git a/.factory/validation/participant-scores/user-testing/flows/round-17948-finals.json b/.factory/validation/participant-scores/user-testing/flows/round-17948-finals.json new file mode 100644 index 0000000..65baca3 --- /dev/null +++ b/.factory/validation/participant-scores/user-testing/flows/round-17948-finals.json @@ -0,0 +1,176 @@ +{ + "groupId": "round-17948-finals", + "testedAt": "2026-04-03T02:28:51Z", + "isolation": { + "surface": "importer CLI + API verification", + "roundId": 17948, + "challengeId": "8d6775ad-2235-4a4d-9daa-062bd4417978", + "environment": "shared existing dev environment", + "repoRoot": "/home/jmgasper/Documents/Git/v6/challenge-api-v6", + "missionDir": "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1" + }, + "toolsUsed": [ + "node", + "curl", + "python3" + ], + "assertions": [ + { + "id": "VAL-SCORE-001", + "title": "Final review summations reconcile exact system scores and missing-member skips on a score-rich round", + "status": "pass", + "steps": [ + { + "action": "Capture pre-state and run importer dry-run for round 17948", + "expected": "Dry-run reports the score-rich fixture counts from shared state", + "observed": "decision=reuse/backfill-only; legacyFinalCandidates=81; alreadyPresent=45; missingMember=36; explicitSkips=0" + }, + { + "action": "Run apply and fetch post-apply review summations and skipped artifact", + "expected": "Imported finals plus missing-member skips reconcile one-for-one to legacy finalists", + "observed": "post finals=45; skipped final members=36; legacy finalists=81; imported+skipped matched legacy member set exactly" + }, + { + "action": "Compare imported final aggregateScore values to /mnt/Informix long_comp_result.system_point_total in Python", + "expected": "Every imported final score equals legacy system_point_total and skipped members have no imported final", + "observed": "All 45 imported finals matched legacy system_point_total exactly; 36 missing-member finalists appeared only in the skipped artifact" + } + ], + "evidence": { + "files": [ + "participant-scores/round-17948-finals/pre-challenge-detail.json", + "participant-scores/round-17948-finals/pre-resources.json", + "participant-scores/round-17948-finals/pre-submissions.json", + "participant-scores/round-17948-finals/pre-review-summations.json", + "participant-scores/round-17948-finals/dry-run.stdout.txt", + "participant-scores/round-17948-finals/apply.stdout.txt", + "participant-scores/round-17948-finals/apply-skipped.json", + "participant-scores/round-17948-finals/post-review-summations.json", + "participant-scores/round-17948-finals/assertion-analysis.json" + ], + "consoleErrors": "n/a (CLI/API validation surface)", + "network": "GET /v6/challenges?legacyId=17948 -> 200; GET /v6/challenges/8d6775ad-2235-4a4d-9daa-062bd4417978 -> 200; GET /resources?challengeId=8d6775ad-2235-4a4d-9daa-062bd4417978 -> 200; GET /v6/submissions?challengeId=8d6775ad-2235-4a4d-9daa-062bd4417978&perPage=1000 -> 200; GET /v6/reviewSummations?challengeId=8d6775ad-2235-4a4d-9daa-062bd4417978&perPage=1000 -> 200" + }, + "issues": null + }, + { + "id": "VAL-SCORE-003", + "title": "Each imported final score is attached to the member's latest non-example submission only", + "status": "pass", + "steps": [ + { + "action": "Fetch full submission and review-summation API snapshots after apply", + "expected": "Every imported finalist has one attached final review summation on an imported non-example submission", + "observed": "post submissions=370 and post finals=45; each finalist had exactly one final review summation" + }, + { + "action": "Compare each final reviewSummation submissionId to the member's latest imported submission", + "expected": "Each final attaches to the member's latest imported non-example submission only", + "observed": "All 45 finals referenced the same submission id and legacySubmissionId as the member's latest imported submission" + }, + { + "action": "Compare attached legacySubmissionId values to latest legacy non-example submissions in Python", + "expected": "Final attachments match legacy latest non-example submissions and never point at example runs", + "observed": "All 45 finals matched the latest legacy non-example submission; legacy example submission count for round 17948 was 0" + } + ], + "evidence": { + "files": [ + "participant-scores/round-17948-finals/post-submissions.json", + "participant-scores/round-17948-finals/post-review-summations.json", + "participant-scores/round-17948-finals/assertion-analysis.json" + ], + "consoleErrors": "n/a (CLI/API validation surface)", + "network": "GET /v6/challenges?legacyId=17948 -> 200; GET /v6/challenges/8d6775ad-2235-4a4d-9daa-062bd4417978 -> 200; GET /resources?challengeId=8d6775ad-2235-4a4d-9daa-062bd4417978 -> 200; GET /v6/submissions?challengeId=8d6775ad-2235-4a4d-9daa-062bd4417978&perPage=1000 -> 200; GET /v6/reviewSummations?challengeId=8d6775ad-2235-4a4d-9daa-062bd4417978&perPage=1000 -> 200" + }, + "issues": null + }, + { + "id": "VAL-SCORE-006", + "title": "Final-score order reproduces legacy placement order after excluding missing-member skips", + "status": "pass", + "steps": [ + { + "action": "Sort imported final review summations by aggregateScore descending", + "expected": "Imported final ordering reproduces legacy placement order after excluding missing-member finalists", + "observed": "Imported sorted final list contained 45 members and matched the legacy filtered finalist order exactly" + }, + { + "action": "Filter legacy final-result rows by skipped missing-member finalists", + "expected": "Legacy filtered order has the same member and placement sequence as imported finals", + "observed": "Legacy filtered member order and placement sequence both matched the imported final order" + } + ], + "evidence": { + "files": [ + "participant-scores/round-17948-finals/apply-skipped.json", + "participant-scores/round-17948-finals/post-review-summations.json", + "participant-scores/round-17948-finals/assertion-analysis.json" + ], + "consoleErrors": "n/a (CLI/API validation surface)", + "network": "GET /v6/challenges?legacyId=17948 -> 200; GET /v6/challenges/8d6775ad-2235-4a4d-9daa-062bd4417978 -> 200; GET /resources?challengeId=8d6775ad-2235-4a4d-9daa-062bd4417978 -> 200; GET /v6/submissions?challengeId=8d6775ad-2235-4a4d-9daa-062bd4417978&perPage=1000 -> 200; GET /v6/reviewSummations?challengeId=8d6775ad-2235-4a4d-9daa-062bd4417978&perPage=1000 -> 200" + }, + "issues": null + }, + { + "id": "VAL-SCORE-009", + "title": "Score reruns preserve stable imported identities and skipped-member reporting", + "status": "pass", + "steps": [ + { + "action": "Run apply for round 17948 twice and capture raw stdout/stderr plus skipped artifacts", + "expected": "Rerun creates no duplicate final/provisional records and preserves skipped-member reporting", + "observed": "first apply created provisional scores=370; rerun created provisional scores=0; rerun created final scores=0" + }, + { + "action": "Compare post-apply and post-rerun review-summation identity sets", + "expected": "Final and provisional identity sets stay unchanged and remain one-per-submission", + "observed": "final ids stable at 45; provisional ids stable at 370; no duplicate final or provisional submission links found" + }, + { + "action": "Diff apply-skipped.json vs rerun-skipped.json for score-related records", + "expected": "Score skip records remain deterministic and duplicate-free across reruns", + "observed": "score-related skipped records were identical across runs (367 records) with no duplicate entries detected" + } + ], + "evidence": { + "files": [ + "participant-scores/round-17948-finals/apply.stdout.txt", + "participant-scores/round-17948-finals/rerun-apply.stdout.txt", + "participant-scores/round-17948-finals/apply-skipped.json", + "participant-scores/round-17948-finals/rerun-skipped.json", + "participant-scores/round-17948-finals/post-review-summations.json", + "participant-scores/round-17948-finals/rerun-review-summations.json", + "participant-scores/round-17948-finals/assertion-analysis.json" + ], + "consoleErrors": "n/a (CLI/API validation surface)", + "network": "GET /v6/challenges?legacyId=17948 -> 200; GET /v6/challenges/8d6775ad-2235-4a4d-9daa-062bd4417978 -> 200; GET /resources?challengeId=8d6775ad-2235-4a4d-9daa-062bd4417978 -> 200; GET /v6/submissions?challengeId=8d6775ad-2235-4a4d-9daa-062bd4417978&perPage=1000 -> 200; GET /v6/reviewSummations?challengeId=8d6775ad-2235-4a4d-9daa-062bd4417978&perPage=1000 -> 200" + }, + "issues": null + } + ], + "frictions": [ + { + "description": "The submissions and reviewSummations APIs defaulted to paginated responses, so full-round validation required adding perPage=1000 to fetch all round 17948 records.", + "resolved": true, + "resolution": "Captured evidence with perPage=1000 for submissions and reviewSummations before doing Python reconciliation.", + "affectedAssertions": [ + "VAL-SCORE-001", + "VAL-SCORE-003", + "VAL-SCORE-006", + "VAL-SCORE-009" + ] + }, + { + "description": "The skipped artifact mixes member-level final/provisional skip records with per-submission provisional skip records, so score validation needed explicit filtering by affectedSurfaces and exact-record diffing.", + "resolved": true, + "resolution": "Filtered score-related records in Python, compared exact canonical JSON records across runs, and separately counted final-score member skips.", + "affectedAssertions": [ + "VAL-SCORE-001", + "VAL-SCORE-009" + ] + } + ], + "blockers": [], + "summary": "Tested 4 assertions for round 17948 finals: 4 passed, 0 failed, 0 blocked. Dry-run confirmed 81 legacy final candidates with 45 imported finals, 36 missing-member final skips, and 0 explicit unattachable-finalist skips; apply added 370 provisional review summations, and rerun preserved stable final/provisional identities and deterministic skipped-member score records." +} diff --git a/.factory/validation/participant-scores/user-testing/synthesis.json b/.factory/validation/participant-scores/user-testing/synthesis.json new file mode 100644 index 0000000..3d41ec0 --- /dev/null +++ b/.factory/validation/participant-scores/user-testing/synthesis.json @@ -0,0 +1,62 @@ +{ + "milestone": "participant-scores", + "round": 1, + "status": "pass", + "assertionsSummary": { + "total": 34, + "passed": 34, + "failed": 0, + "blocked": 0 + }, + "passedAssertions": [ + "VAL-CROSS-001", + "VAL-CROSS-002", + "VAL-CROSS-003", + "VAL-CROSS-004", + "VAL-CROSS-005", + "VAL-CROSS-006", + "VAL-CROSS-007", + "VAL-CROSS-008", + "VAL-PARTICIPANT-001", + "VAL-PARTICIPANT-002", + "VAL-PARTICIPANT-003", + "VAL-PARTICIPANT-004", + "VAL-PARTICIPANT-005", + "VAL-PARTICIPANT-006", + "VAL-PARTICIPANT-007", + "VAL-PARTICIPANT-008", + "VAL-PLAN-003", + "VAL-PLAN-008", + "VAL-PLAN-010", + "VAL-PLAN-011", + "VAL-PLAN-012", + "VAL-PLAN-014", + "VAL-PLAN-015", + "VAL-SCORE-001", + "VAL-SCORE-002", + "VAL-SCORE-003", + "VAL-SCORE-004", + "VAL-SCORE-005", + "VAL-SCORE-006", + "VAL-SCORE-007", + "VAL-SCORE-008", + "VAL-SCORE-009", + "VAL-SCORE-010", + "VAL-SCORE-011" + ], + "failedAssertions": [], + "blockedAssertions": [], + "appliedUpdates": [ + { + "target": "user-testing.md", + "description": "Documented the mixed response shapes for submissions/reviewSummations and the need to count rows from payload.data with a large perPage during API reconciliation.", + "source": "flow-report" + }, + { + "target": "validation-artifacts", + "description": "Orchestrator persisted the passing participant-scores user-testing artifacts after the worker completed validation but could not commit from the mission worker session.", + "source": "orchestrator" + } + ], + "previousRound": null +} From 93171c6ae94dac565957d6e34ff872d1265b52fb Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 8 Apr 2026 13:03:59 +1000 Subject: [PATCH 26/27] Historical MM import updates --- data-migration/MARATHON_README.md | 254 ++++++++++++++++++ data-migration/src/index.js | 25 -- data-migration/src/migrators/_baseMigrator.js | 6 +- .../importHistoricalMarathonMatches/apply.js | 62 ++++- .../finalScores.js | 57 +++- .../planning.js | 90 ++++++- .../provisionalScores.js | 97 ++++++- .../submissionHistory.js | 97 ++++++- .../src/scripts/importMarathonMatchWinners.js | 221 +++++++++++---- ...alMarathonMatches.applyFinalScores.test.js | 174 ++++++++++++ ...thonMatches.applyProvisionalScores.test.js | 190 +++++++++++++ ...alMarathonMatches.applySubmissions.test.js | 153 +++++++++++ ...toricalMarathonMatches.finalScores.test.js | 95 +++++++ ...athonMatches.missingMemberPlanning.test.js | 24 +- ...portHistoricalMarathonMatches.plan.test.js | 1 + ...lMarathonMatches.provisionalScores.test.js | 57 ++++ ...lMarathonMatches.submissionHistory.test.js | 44 +++ .../test/importMarathonMatchWinners.test.js | 79 ++++++ 18 files changed, 1618 insertions(+), 108 deletions(-) create mode 100644 data-migration/MARATHON_README.md create mode 100644 data-migration/test/importMarathonMatchWinners.test.js diff --git a/data-migration/MARATHON_README.md b/data-migration/MARATHON_README.md new file mode 100644 index 0000000..d2e0516 --- /dev/null +++ b/data-migration/MARATHON_README.md @@ -0,0 +1,254 @@ +# Historical Marathon Match Import + +This document covers how to configure, dry-run, validate, and apply the +`importHistoricalMarathonMatches.js` importer. + +Commands below assume you are running from the `challenge-api-v6` repository +root. + +## What the script does + +`data-migration/src/scripts/importHistoricalMarathonMatches.js` imports +historical Marathon Match rounds from legacy Informix JSON exports into the v6 +challenge stack. + +The script can: + +- discover an existing v6 Marathon Match challenge and backfill missing data +- create a new Marathon Match challenge and its standard phases when no safe + match exists +- reconcile submitter resources through the Resources API +- import submission history, final scores, and provisional scores into the + review database + +The default mode is `--dry-run`. No writes happen unless `--apply` is provided. + +## Required legacy input files + +By default the importer expects these files under `DATA_DIRECTORY` or +`--data-dir`: + +- `round_1.json` +- `round_component_1.json` +- `component_1.json` +- `problem_1.json` +- `long_component_state_1.json` +- files matching `^round_registration_\d+\.json$` +- files matching `^user_\d+\.json$` +- files matching `^long_submission_\d+\.json$` +- files matching `^long_comp_result_\d+\.json$` + +All of those defaults can be overridden with CLI flags if your export filenames +are different. + +## Environment configuration + +The script automatically loads: + +- `challenge-api-v6/.env.importer.local` +- then the normal process environment + +Create or update `challenge-api-v6/.env.importer.local` with placeholder values +similar to: + +```bash +DATA_DIRECTORY=/mnt/Informix + +# v6 challenge database +DATABASE_URL=postgresql://user:password@host:5432/challenge_db + +# optional member lookup override; defaults to DATABASE_URL when omitted +MEMBER_DB_URL=postgresql://user:password@host:5432/member_db +MEMBER_DB_SCHEMA=members + +# required for apply mode score/submission reconciliation +REVIEW_DB_URL=postgresql://user:password@host:5432/review_db +REVIEW_DB_SCHEMA=reviews + +# required for apply mode resource reconciliation +RESOURCES_API_URL=https://api.topcoder-dev.com/v5/resources +AUTH0_URL=https://topcoder-dev.auth0.com +AUTH0_AUDIENCE=https://www.topcoder-dev.com +AUTH0_CLIENT_ID=your-m2m-client-id +AUTH0_CLIENT_SECRET=your-m2m-client-secret + +# optional attribution +CREATED_BY=historical-mm-importer +UPDATED_BY=historical-mm-importer + +# optional override; defaults to the standard Submitter role +SUBMITTER_ROLE_ID=732339e7-8e30-49d7-9198-cccf9451e221 +``` + +### What is required for each mode + +For a useful dry run: + +- `DATA_DIRECTORY` or `--data-dir` +- `DATABASE_URL` is strongly recommended so the importer can do authoritative + v6 discovery and resolve the canonical Marathon Match/Data Science timeline + template +- `MEMBER_DB_URL` is recommended if member lookup is not in the same database as + `DATABASE_URL` + +Without `DATABASE_URL`, the script can still read the legacy files, but rounds +that need create-path planning will usually stay `unresolved`. + +For apply mode: + +- `DATABASE_URL` +- `REVIEW_DB_URL` +- `RESOURCES_API_URL` +- `AUTH0_URL` +- `AUTH0_AUDIENCE` +- `AUTH0_CLIENT_ID` +- `AUTH0_CLIENT_SECRET` +- `MEMBER_DB_URL` if member lookup is not available through `DATABASE_URL` + +## CLI usage + +Basic form: + +```bash +node data-migration/src/scripts/importHistoricalMarathonMatches.js \ + --dry-run \ + --round-id +``` + +Useful options: + +- `--round-id `: import a single legacy round, repeatable +- `--round-ids `: import multiple rounds in one run +- `--data-dir `: override the legacy export directory +- `--existing-state-file `: optional offline snapshot for count hints only +- `--skipped-file `: where to write the deterministic skip artifact +- `--apply`: perform writes instead of planning only + +Show full help: + +```bash +node data-migration/src/scripts/importHistoricalMarathonMatches.js --help +``` + +## Dry-run workflow + +1. Change into the service root and select the repo Node version. + +```bash +cd challenge-api-v6 +nvm use +``` + +2. Run a dry run for one round first. + +```bash +mkdir -p data-migration/out + +node data-migration/src/scripts/importHistoricalMarathonMatches.js \ + --dry-run \ + --round-id 12345 \ + --skipped-file data-migration/out/historical-mm-skipped-12345.json \ + | tee data-migration/out/historical-mm-plan-12345.log +``` + +3. Review the output. + +The script writes one `PLAN_RECORD` per round and a final `PLAN_SUMMARY`, for +example: + +- `PLAN_RECORD {...}` +- `PLAN_SUMMARY {...}` + +Useful checks: + +```bash +rg '^PLAN_SUMMARY ' data-migration/out/historical-mm-plan-12345.log +rg '^PLAN_RECORD ' data-migration/out/historical-mm-plan-12345.log +cat data-migration/out/historical-mm-skipped-12345.json +``` + +## What to validate before apply + +Do not run `--apply` until the dry run looks clean. + +Validate these points: + +- `PLAN_SUMMARY.countsByDecision.unresolved` is `0` +- `PLAN_SUMMARY.countsByDecision.unmatched` is `0` +- each selected round has a `PLAN_RECORD.decision` of either `create` or + `reuse/backfill-only` +- `PLAN_RECORD.reason` is expected for that round +- `skippedFileArtifact.recordCount` is understood and acceptable +- `missing-member` entries in the skipped artifact are expected, or the member + lookup configuration has been fixed +- `finalist-without-attachable-submission` entries have been reviewed against + the legacy data + +Recommended interpretation: + +- `create`: the importer plans to create the v6 challenge and standard phases, + then reconcile resources and review-side data +- `reuse/backfill-only`: a safe existing v6 challenge match was found, so the + importer will only backfill missing linked data +- `unresolved`: configuration or data prerequisites are missing; fix these + before apply +- `unmatched`: the selected round was not found in the legacy source set + +If you want the plan to use direct v6 counts rather than snapshot hints, make +sure the dry run can reach `DATABASE_URL`, and optionally `REVIEW_DB_URL` and +`RESOURCES_API_URL`. + +## Apply workflow + +After the dry run has been reviewed and accepted, rerun the same selection with +`--apply`. + +```bash +node data-migration/src/scripts/importHistoricalMarathonMatches.js \ + --apply \ + --round-id 12345 \ + --skipped-file data-migration/out/historical-mm-skipped-12345.json \ + | tee data-migration/out/historical-mm-apply-12345.log +``` + +Apply mode still prints structured output: + +- `APPLY_RECORD {...}` +- `APPLY_SUMMARY {...}` + +Review it with: + +```bash +rg '^APPLY_SUMMARY ' data-migration/out/historical-mm-apply-12345.log +rg '^APPLY_RECORD ' data-migration/out/historical-mm-apply-12345.log +cat data-migration/out/historical-mm-skipped-12345.json +``` + +Expected apply result: + +- `APPLY_SUMMARY.errors` is `0` +- `APPLY_SUMMARY.unresolved` is `0` +- `APPLY_SUMMARY.unmatched` is `0` +- rounds show `created` or `existing` status as expected + +## Recommended rollout sequence + +1. Run `--dry-run` for a single round. +2. Validate the `PLAN_RECORD`, `PLAN_SUMMARY`, and skipped artifact. +3. Run `--apply` for that same single round. +4. Validate the `APPLY_RECORD`, `APPLY_SUMMARY`, and target-system data. +5. Repeat with the next round, or run a controlled batch with `--round-ids`. + +## Notes and pitfalls + +- The skipped artifact path defaults to + `./historical-mm-skipped-.json` relative to the current working + directory. Use `--skipped-file` if you want a predictable location. +- `--existing-state-file` is only a hint source for offline planning. It is not + authoritative reuse matching. +- Apply mode requires write-capable connectivity to the challenge database, the + review database, and the Resources API. +- If `REVIEW_DB_URL` is missing, apply mode will fail before submission, final + score, or provisional score import starts. +- If `RESOURCES_API_URL` or Auth0 credentials are missing, apply mode will fail + before participant reconciliation starts. diff --git a/data-migration/src/index.js b/data-migration/src/index.js index d1776df..cb27681 100644 --- a/data-migration/src/index.js +++ b/data-migration/src/index.js @@ -1,5 +1,4 @@ const { MigrationManager } = require('./migrationManager'); -const { AuditLogMigrator } = require('./migrators/auditLogMigrator') const { ChallengeMigrator } = require('./migrators/challengeMigrator') const { ChallengeTypeMigrator } = require('./migrators/challengeTypeMigrator'); const { ChallengeTrackMigrator } = require('./migrators/challengeTrackMigrator'); @@ -21,31 +20,7 @@ const { ChallengeSkillMigrator } = require('./migrators/challengeSkillMigrator') const { ChallengeEventMigrator } = require('./migrators/challengeEventMigrator'); const { ChallengeDiscussionOptionMigrator } = require('./migrators/challengeDiscussionOptionMigrator'); const { ChallengeConstraintMigrator } = require('./migrators/challengeConstraintMigrator'); -const { PrismaClient } = require('@prisma/client'); - -async function checkDatabaseConnection() { - const prisma = new PrismaClient(); - try { - await prisma.$connect(); - console.log('Database connection successful'); - return true; - } catch (error) { - console.error('Database connection failed:', error.message); - console.error('Make sure your Docker database is running with: npm run db:up'); - return false; - } finally { - await prisma.$disconnect(); - } -} - - async function main() { - // Check database connection first - // const isConnected = await checkDatabaseConnection(); - // if (!isConnected) { - // process.exit(1); - // } - try { // Create migration manager const manager = new MigrationManager(); diff --git a/data-migration/src/migrators/_baseMigrator.js b/data-migration/src/migrators/_baseMigrator.js index 284b09b..16ba49a 100644 --- a/data-migration/src/migrators/_baseMigrator.js +++ b/data-migration/src/migrators/_baseMigrator.js @@ -899,7 +899,7 @@ class BaseMigrator { } try { return BigInt(trimmed); - } catch (err) { + } catch { return null; } } @@ -930,7 +930,7 @@ class BaseMigrator { if (Prisma?.Decimal) { try { return new Prisma.Decimal(trimmed); - } catch (err) { + } catch { // Fallback to native number if Decimal instantiation fails } } @@ -1023,7 +1023,7 @@ class BaseMigrator { if (upsertData?.where) { try { return `[where: ${JSON.stringify(upsertData.where)}]`; - } catch (err) { + } catch { return '[where: unable to serialize]'; } } diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js index 017c7d2..d3720c7 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js @@ -42,12 +42,38 @@ const parseRoundLegacyId = (roundId) => { return parsed; }; +const minSubmissionMs = (left, right) => { + if (!Number.isFinite(left)) { + return Number.isFinite(right) ? right : null; + } + if (!Number.isFinite(right)) { + return left; + } + return Math.min(left, right); +}; + +const maxSubmissionMs = (left, right) => { + if (!Number.isFinite(left)) { + return Number.isFinite(right) ? right : null; + } + if (!Number.isFinite(right)) { + return left; + } + return Math.max(left, right); +}; + const derivePhaseWindows = (roundId, counters) => { const registrationStartMs = counters && counters.registrationStartMs; const registrationEndMs = counters && counters.registrationEndMs; - const latestSubmissionMs = counters && counters.latestNonExampleSubmitMs; + const latestSubmissionMs = maxSubmissionMs( + counters && counters.latestNonExampleSubmitMs, + counters && counters.latestExampleOnlyFinalistSubmitMs + ); const earliestSubmissionOpenMs = counters && counters.earliestSubmissionOpenMs; - const earliestSubmissionMs = counters && counters.earliestNonExampleSubmitMs; + const earliestSubmissionMs = minSubmissionMs( + counters && counters.earliestNonExampleSubmitMs, + counters && counters.earliestExampleOnlyFinalistSubmitMs + ); if (!Number.isFinite(registrationStartMs) || !Number.isFinite(registrationEndMs)) { throw new Error( @@ -56,7 +82,7 @@ const derivePhaseWindows = (roundId, counters) => { } if (!Number.isFinite(latestSubmissionMs)) { throw new Error( - `Round ${roundId} is missing non-example submission timestamps needed for phase derivation.` + `Round ${roundId} is missing attachable submission timestamps needed for phase derivation.` ); } @@ -68,7 +94,7 @@ const derivePhaseWindows = (roundId, counters) => { : earliestSubmissionMs; if (!Number.isFinite(rawSubmissionStartMs)) { throw new Error( - `Round ${roundId} is missing both submission open_time and non-example submission start timestamps.` + `Round ${roundId} is missing both submission open_time and attachable submission start timestamps.` ); } @@ -139,9 +165,15 @@ const buildChallengeCreateData = ({ }) => { const legacyId = parseRoundLegacyId(roundId); const registrationCount = counters && counters.eligibleRegistrants ? counters.eligibleRegistrants.size : 0; - const submissionCount = counters && Number.isFinite(counters.nonExampleSubmissions) - ? counters.nonExampleSubmissions - : 0; + const nonExampleSubmissionCount = + counters && Number.isFinite(counters.nonExampleSubmissions) + ? counters.nonExampleSubmissions + : 0; + const exampleOnlyFinalistSubmissionCount = + counters && Number.isFinite(counters.exampleOnlyFinalistSubmissions) + ? counters.exampleOnlyFinalistSubmissions + : 0; + const submissionCount = nonExampleSubmissionCount + exampleOnlyFinalistSubmissionCount; return { legacyId, @@ -718,6 +750,14 @@ const runApplyMode = async ({ longComponentStateFile: options.longComponentStateFile, longSubmissionPattern: options.longSubmissionPattern, roundIds: actionableRoundIds, + attachableExampleOnlyFinalistCoderIdsByRoundId: new Map( + actionableRoundIds.map((roundId) => [ + roundId, + (plan.roundDataById.get(roundId) && + plan.roundDataById.get(roundId).finalCandidateCoderIds) || + new Set(), + ]) + ), }); submissionStore = options.submissionStore || @@ -748,6 +788,14 @@ const runApplyMode = async ({ longComponentStateFile: options.longComponentStateFile, longSubmissionPattern: options.longSubmissionPattern, roundIds: actionableRoundIds, + attachableExampleOnlyFinalistCoderIdsByRoundId: new Map( + actionableRoundIds.map((roundId) => [ + roundId, + (plan.roundDataById.get(roundId) && + plan.roundDataById.get(roundId).finalCandidateCoderIds) || + new Set(), + ]) + ), }); provisionalScoreStore = options.provisionalScoreStore || diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/finalScores.js b/data-migration/src/scripts/importHistoricalMarathonMatches/finalScores.js index 8012ba9..7c76476 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/finalScores.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/finalScores.js @@ -122,6 +122,56 @@ const compareFinalRows = (left, right) => { }); }; +/** + * Clears legacy placements when Informix reused the same raw `placed` value for multiple rows + * that do not share the same score. The original placement is preserved in + * `rawLegacyPlacement` for diagnostics, while lower-scoring or unscored rows lose their + * normalized `legacyPlacement` so downstream imports do not treat the duplicate placement as + * authoritative. + * + * @param {Array} rows legacy final rows for a single round + * @returns {Array} rows cloned with normalized placement fields + */ +const normalizeConflictingDuplicatePlacements = (rows = []) => { + const normalizedRows = rows.map((row) => ({ + ...row, + rawLegacyPlacement: Number.isFinite(row && row.legacyPlacement) ? row.legacyPlacement : null, + })); + + const rowsByPlacement = new Map(); + normalizedRows.forEach((row) => { + if (!Number.isFinite(row.rawLegacyPlacement)) { + return; + } + if (!rowsByPlacement.has(row.rawLegacyPlacement)) { + rowsByPlacement.set(row.rawLegacyPlacement, []); + } + rowsByPlacement.get(row.rawLegacyPlacement).push(row); + }); + + rowsByPlacement.forEach((placementRows) => { + const scoredRows = placementRows.filter((row) => Number.isFinite(row.aggregateScore)); + if (scoredRows.length === 0) { + return; + } + + const distinctScores = Array.from(new Set(scoredRows.map((row) => row.aggregateScore))); + const hasUnscoredRows = scoredRows.length !== placementRows.length; + if (distinctScores.length <= 1 && !hasUnscoredRows) { + return; + } + + const topScore = distinctScores.reduce((highest, score) => Math.max(highest, score), Number.NEGATIVE_INFINITY); + placementRows.forEach((row) => { + if (!Number.isFinite(row.aggregateScore) || row.aggregateScore !== topScore) { + row.legacyPlacement = null; + } + }); + }); + + return normalizedRows; +}; + const loadLegacyFinalRowsByRoundId = async ({ dataDir, longComponentStateFile, @@ -201,7 +251,11 @@ const loadLegacyFinalRowsByRoundId = async ({ ); rowsByRoundId.forEach((rows, roundId) => { - rowsByRoundId.set(roundId, [...rows].sort(compareFinalRows)); + rowsByRoundId.set( + roundId, + normalizeConflictingDuplicatePlacements(rows) + .sort(compareFinalRows) + ); }); return rowsByRoundId; @@ -379,6 +433,7 @@ const reconcileRoundFinalScores = async ({ legacyCoderId: finalRow.coderId, scoreSource: finalRow.scoreSource, legacyPlacement: finalRow.legacyPlacement, + rawLegacyPlacement: finalRow.rawLegacyPlacement, }, }); existingFinalSummationsBySubmissionId.set(submissionId, [ diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js b/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js index 947f59d..0d673cc 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js @@ -31,14 +31,18 @@ const createEmptyCounters = () => ({ eligibleRegistrants: new Set(), nonExampleSubmissions: 0, exampleSubmissions: 0, + exampleOnlyFinalistSubmissions: 0, nonExampleSubmitterCoderIds: new Set(), nonExampleSubmissionCountsByCoderId: new Map(), + exampleOnlyFinalistSubmissionCountsByCoderId: new Map(), finalCandidateCoderIds: new Set(), registrationStartMs: null, registrationEndMs: null, earliestSubmissionOpenMs: null, earliestNonExampleSubmitMs: null, latestNonExampleSubmitMs: null, + earliestExampleOnlyFinalistSubmitMs: null, + latestExampleOnlyFinalistSubmitMs: null, }); const sortIds = (values) => @@ -152,6 +156,7 @@ const buildRoundSummaryCounts = ({ eligibleRegistrants: counters.eligibleRegistrants.size, nonExampleSubmissions: counters.nonExampleSubmissions, exampleSubmissionsFiltered: counters.exampleSubmissions, + exampleOnlyFinalistSubmissions: counters.exampleOnlyFinalistSubmissions, plannedFinalScores, plannedProvisionalScores, finalistsWithoutAttachableSubmission, @@ -178,6 +183,7 @@ const buildZeroPartitions = () => ({ submissions: { legacyNonExample: 0, legacyExampleFiltered: 0, + legacyExampleOnlyFinalists: 0, toImport: 0, alreadyPresent: 0, missingMember: 0, @@ -198,6 +204,7 @@ const buildZeroPartitions = () => ({ }, provisionalScores: { legacyNonExample: 0, + legacyExampleOnlyFinalists: 0, toImport: 0, alreadyPresent: 0, missingMember: 0, @@ -454,7 +461,10 @@ const buildSurfacePartitionsForRound = ({ const partitions = buildZeroPartitions(); partitions.submissions.legacyNonExample = counters.nonExampleSubmissions; partitions.submissions.legacyExampleFiltered = counters.exampleSubmissions; + partitions.submissions.legacyExampleOnlyFinalists = counters.exampleOnlyFinalistSubmissions; partitions.provisionalScores.legacyNonExample = counters.nonExampleSubmissions; + partitions.provisionalScores.legacyExampleOnlyFinalists = + counters.exampleOnlyFinalistSubmissions; partitions.finalScores.legacyFinalCandidates = counters.finalCandidateCoderIds.size; const memberStatsByMemberId = new Map(); @@ -472,7 +482,7 @@ const buildSurfacePartitionsForRound = ({ memberHandle: memberHandle || null, coderIds: new Set(), eligibleResourceCount: 0, - nonExampleSubmissionCount: 0, + attachableSubmissionCount: 0, finalCandidateCount: 0, }); } @@ -511,7 +521,19 @@ const buildSurfacePartitionsForRound = ({ if (!stats) { return; } - stats.nonExampleSubmissionCount += parseNonNegativeInteger(count); + stats.attachableSubmissionCount += parseNonNegativeInteger(count); + }); + + counters.exampleOnlyFinalistSubmissionCountsByCoderId.forEach((count, coderId) => { + const identity = resolveIdentityForCoderId(coderId, normalizedIdentityByCoderId); + if (!identity) { + return; + } + const stats = ensureMemberStats(identity); + if (!stats) { + return; + } + stats.attachableSubmissionCount += parseNonNegativeInteger(count); }); counters.finalCandidateCoderIds.forEach((coderId) => { @@ -536,9 +558,9 @@ const buildSurfacePartitionsForRound = ({ memberStatsByMemberId.forEach((stats) => { const isResolved = resolvedMemberIds.has(stats.memberId); const missingResourceCount = isResolved ? 0 : stats.eligibleResourceCount; - const missingSubmissionCount = isResolved ? 0 : stats.nonExampleSubmissionCount; - const missingProvisionalCount = isResolved ? 0 : stats.nonExampleSubmissionCount; - const hasAttachableFinal = stats.nonExampleSubmissionCount > 0; + const missingSubmissionCount = isResolved ? 0 : stats.attachableSubmissionCount; + const missingProvisionalCount = isResolved ? 0 : stats.attachableSubmissionCount; + const hasAttachableFinal = stats.attachableSubmissionCount > 0; const missingFinalCount = isResolved ? 0 : stats.finalCandidateCount; const explicitFinalSkipCount = isResolved && !hasAttachableFinal @@ -555,8 +577,8 @@ const buildSurfacePartitionsForRound = ({ partitions.finalScores.missingMember += missingFinalCount; materializableResourceCount += isResolved ? stats.eligibleResourceCount : 0; - materializableSubmissionCount += isResolved ? stats.nonExampleSubmissionCount : 0; - materializableProvisionalCount += isResolved ? stats.nonExampleSubmissionCount : 0; + materializableSubmissionCount += isResolved ? stats.attachableSubmissionCount : 0; + materializableProvisionalCount += isResolved ? stats.attachableSubmissionCount : 0; materializableFinalScoreCount += importableFinalCount; if (!isResolved) { @@ -889,6 +911,7 @@ const summarizePlan = (records, selectedRoundIds, skippedFilePath) => { eligibleRegistrants: 0, nonExampleSubmissions: 0, exampleSubmissionsFiltered: 0, + exampleOnlyFinalistSubmissions: 0, plannedFinalScores: 0, plannedProvisionalScores: 0, finalistsWithoutAttachableSubmission: 0, @@ -911,6 +934,8 @@ const summarizePlan = (records, selectedRoundIds, skippedFilePath) => { totals.eligibleRegistrants += record.summaryCounts.eligibleRegistrants; totals.nonExampleSubmissions += record.summaryCounts.nonExampleSubmissions; totals.exampleSubmissionsFiltered += record.summaryCounts.exampleSubmissionsFiltered; + totals.exampleOnlyFinalistSubmissions += + parseNonNegativeInteger(record.summaryCounts.exampleOnlyFinalistSubmissions); totals.plannedFinalScores += record.summaryCounts.plannedFinalScores; totals.plannedProvisionalScores += record.summaryCounts.plannedProvisionalScores; totals.finalistsWithoutAttachableSubmission += @@ -932,6 +957,8 @@ const summarizePlan = (records, selectedRoundIds, skippedFilePath) => { record.partitions.submissions.legacyNonExample; totals.partitions.submissions.legacyExampleFiltered += record.partitions.submissions.legacyExampleFiltered; + totals.partitions.submissions.legacyExampleOnlyFinalists += + parseNonNegativeInteger(record.partitions.submissions.legacyExampleOnlyFinalists); totals.partitions.submissions.toImport += record.partitions.submissions.toImport; totals.partitions.submissions.alreadyPresent += record.partitions.submissions.alreadyPresent; @@ -957,6 +984,8 @@ const summarizePlan = (records, selectedRoundIds, skippedFilePath) => { totals.partitions.provisionalScores.legacyNonExample += record.partitions.provisionalScores.legacyNonExample; + totals.partitions.provisionalScores.legacyExampleOnlyFinalists += + parseNonNegativeInteger(record.partitions.provisionalScores.legacyExampleOnlyFinalists); totals.partitions.provisionalScores.toImport += record.partitions.provisionalScores.toImport; totals.partitions.provisionalScores.alreadyPresent += @@ -1027,6 +1056,7 @@ const readLegacyPlanningInputs = async (options, roundDataById) => { const selectedRoundIdSet = new Set(roundDataById.keys()); const selectedComponentIds = new Set(); const longComponentStateById = new Map(); + const stateSubmissionSummaryById = new Map(); await streamJsonArray(fixedFiles.round, "round", (row) => { const roundId = String(row && row.round_id ? row.round_id : "").trim(); @@ -1106,6 +1136,13 @@ const readLegacyPlanningInputs = async (options, roundDataById) => { roundId, coderId, }); + stateSubmissionSummaryById.set(longComponentStateId, { + roundId, + coderId, + nonExampleCount: 0, + exampleCount: 0, + latestExampleSubmitMs: null, + }); }); await Promise.all( @@ -1127,11 +1164,26 @@ const readLegacyPlanningInputs = async (options, roundDataById) => { counters.earliestSubmissionOpenMs = minMs(counters.earliestSubmissionOpenMs, submissionOpenMs); const isExample = String(row && row.example ? row.example : "").trim() === "1"; + const stateSubmissionSummary = + stateSubmissionSummaryById.get(longComponentStateId) || { + roundId: stateInfo.roundId, + coderId: stateInfo.coderId, + nonExampleCount: 0, + exampleCount: 0, + latestExampleSubmitMs: null, + }; + stateSubmissionSummaryById.set(longComponentStateId, stateSubmissionSummary); if (isExample) { counters.exampleSubmissions += 1; + stateSubmissionSummary.exampleCount += 1; + stateSubmissionSummary.latestExampleSubmitMs = maxMs( + stateSubmissionSummary.latestExampleSubmitMs, + parseEpochMs(row && row.submit_time) + ); return; } counters.nonExampleSubmissions += 1; + stateSubmissionSummary.nonExampleCount += 1; const submitMs = parseEpochMs(row && row.submit_time); counters.earliestNonExampleSubmitMs = minMs(counters.earliestNonExampleSubmitMs, submitMs); @@ -1163,6 +1215,30 @@ const readLegacyPlanningInputs = async (options, roundDataById) => { }) ) ); + + stateSubmissionSummaryById.forEach((summary) => { + const counters = roundDataById.get(summary.roundId); + if (!counters) { + return; + } + if (summary.nonExampleCount > 0 || summary.exampleCount <= 0) { + return; + } + if (!counters.finalCandidateCoderIds.has(summary.coderId)) { + return; + } + + counters.exampleOnlyFinalistSubmissions += 1; + addCount(counters.exampleOnlyFinalistSubmissionCountsByCoderId, summary.coderId, 1); + counters.earliestExampleOnlyFinalistSubmitMs = minMs( + counters.earliestExampleOnlyFinalistSubmitMs, + summary.latestExampleSubmitMs + ); + counters.latestExampleOnlyFinalistSubmitMs = maxMs( + counters.latestExampleOnlyFinalistSubmitMs, + summary.latestExampleSubmitMs + ); + }); }; const buildDryRunPlan = async (options, existingStateByRoundId, planningPrerequisites = {}) => { diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/provisionalScores.js b/data-migration/src/scripts/importHistoricalMarathonMatches/provisionalScores.js index 3bd2597..bd7ce68 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/provisionalScores.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/provisionalScores.js @@ -108,6 +108,41 @@ const compareProvisionalRows = (left, right) => { }); }; +const normalizeCoderIdSetByRoundId = (value) => { + const byRoundId = new Map(); + if (!(value instanceof Map)) { + return byRoundId; + } + + value.forEach((coderIds, roundId) => { + const normalizedRoundId = String(roundId || "").trim(); + if (!normalizedRoundId) { + return; + } + + const normalizedCoderIds = new Set( + Array.from(coderIds || []) + .map((coderId) => String(coderId || "").trim()) + .filter(Boolean) + ); + if (normalizedCoderIds.size > 0) { + byRoundId.set(normalizedRoundId, normalizedCoderIds); + } + }); + + return byRoundId; +}; + +const selectLaterProvisionalRow = (currentRow, candidateRow) => { + if (!currentRow) { + return candidateRow || null; + } + if (!candidateRow) { + return currentRow; + } + return compareProvisionalRows(currentRow, candidateRow) <= 0 ? candidateRow : currentRow; +}; + const formatImportedCountsByMemberId = (countsByMemberId) => Object.fromEntries( Array.from(countsByMemberId.entries()).sort(([left], [right]) => @@ -120,6 +155,7 @@ const loadLegacyProvisionalRowsByRoundId = async ({ longComponentStateFile, longSubmissionPattern, roundIds, + attachableExampleOnlyFinalistCoderIdsByRoundId = new Map(), }) => { const selectedRoundIds = Array.from( new Set((roundIds || []).map((roundId) => String(roundId || "").trim()).filter(Boolean)) @@ -138,6 +174,8 @@ const loadLegacyProvisionalRowsByRoundId = async ({ ); const selectedRoundIdSet = new Set(selectedRoundIds); + const normalizedAttachableExampleOnlyFinalistCoderIdsByRoundId = + normalizeCoderIdSetByRoundId(attachableExampleOnlyFinalistCoderIdsByRoundId); const stateInfoById = new Map(); await streamJsonArray(longComponentStatePath, "long_component_state", (row) => { const roundId = String(row && row.round_id ? row.round_id : "").trim(); @@ -158,6 +196,9 @@ const loadLegacyProvisionalRowsByRoundId = async ({ }); const generatedSubmissionOrdinalByStateId = new Map(); + const generatedExampleSubmissionOrdinalByStateId = new Map(); + const latestExampleOnlyProvisionalByStateId = new Map(); + const stateIdsWithNonExampleSubmissions = new Set(); await Promise.all( longSubmissionFiles.map((filePath) => streamJsonArray(filePath, "long_submission", (row) => { @@ -171,9 +212,37 @@ const loadLegacyProvisionalRowsByRoundId = async ({ const isExample = String(row && row.example ? row.example : "").trim() === "1"; if (isExample) { + const currentExampleOrdinal = + generatedExampleSubmissionOrdinalByStateId.get(longComponentStateId) || 0; + const fallbackOrdinal = currentExampleOrdinal + 1; + generatedExampleSubmissionOrdinalByStateId.set(longComponentStateId, fallbackOrdinal); + const submissionNumber = + parsePositiveInteger(row && row.submission_number) || fallbackOrdinal; + const legacySubmissionId = deriveLegacySubmissionId({ + longComponentStateId, + submissionNumber, + }); + + latestExampleOnlyProvisionalByStateId.set( + longComponentStateId, + selectLaterProvisionalRow( + latestExampleOnlyProvisionalByStateId.get(longComponentStateId), + { + legacyRoundId: stateInfo.legacyRoundId, + coderId: stateInfo.coderId, + longComponentStateId, + submissionNumber, + legacySubmissionId, + submitTimeMs: parseEpochMs(row && row.submit_time), + aggregateScore: parseNumericScore(row && row.submission_points), + isSyntheticExampleOnlyFinalist: true, + } + ) + ); return; } + stateIdsWithNonExampleSubmissions.add(longComponentStateId); const currentOrdinal = generatedSubmissionOrdinalByStateId.get(longComponentStateId) || 0; const fallbackOrdinal = currentOrdinal + 1; generatedSubmissionOrdinalByStateId.set(longComponentStateId, fallbackOrdinal); @@ -192,11 +261,31 @@ const loadLegacyProvisionalRowsByRoundId = async ({ legacySubmissionId, submitTimeMs: parseEpochMs(row && row.submit_time), aggregateScore: parseNumericScore(row && row.submission_points), + isSyntheticExampleOnlyFinalist: false, }); }) ) ); + stateInfoById.forEach((stateInfo, longComponentStateId) => { + if (stateIdsWithNonExampleSubmissions.has(longComponentStateId)) { + return; + } + + const attachableCoderIds = + normalizedAttachableExampleOnlyFinalistCoderIdsByRoundId.get(stateInfo.legacyRoundId); + if (!attachableCoderIds || !attachableCoderIds.has(stateInfo.coderId)) { + return; + } + + const exampleOnlyProvisional = latestExampleOnlyProvisionalByStateId.get(longComponentStateId); + if (!exampleOnlyProvisional) { + return; + } + + rowsByRoundId.get(stateInfo.legacyRoundId).push(exampleOnlyProvisional); + }); + rowsByRoundId.forEach((rows, roundId) => { rowsByRoundId.set(roundId, [...rows].sort(compareProvisionalRows)); }); @@ -224,6 +313,11 @@ const reconcileRoundProvisionalScores = async ({ } const legacyProvisionalRows = provisionalRowsByRoundId.get(roundId) || []; + const legacyNonExampleProvisionalScores = legacyProvisionalRows.filter( + (row) => row && row.isSyntheticExampleOnlyFinalist !== true + ).length; + const legacyExampleOnlyFinalistProvisionalScores = + legacyProvisionalRows.length - legacyNonExampleProvisionalScores; const importedSubmissionByLegacySubmissionId = await provisionalScoreStore.listImportedNonExampleSubmissionsByLegacySubmissionId({ challengeId, @@ -339,7 +433,8 @@ const reconcileRoundProvisionalScores = async ({ } return { - legacyNonExampleProvisionalScores: legacyProvisionalRows.length, + legacyNonExampleProvisionalScores, + legacyExampleOnlyFinalistProvisionalScores, importedProvisionalScores: createdProvisionalScores + alreadyPresentProvisionalScores, alreadyPresentProvisionalScores, diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/submissionHistory.js b/data-migration/src/scripts/importHistoricalMarathonMatches/submissionHistory.js index 1000dbb..a893a84 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/submissionHistory.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/submissionHistory.js @@ -74,6 +74,41 @@ const compareSubmissionRows = (left, right) => { return String(left.legacySubmissionId || "").localeCompare(String(right.legacySubmissionId || "")); }; +const normalizeCoderIdSetByRoundId = (value) => { + const byRoundId = new Map(); + if (!(value instanceof Map)) { + return byRoundId; + } + + value.forEach((coderIds, roundId) => { + const normalizedRoundId = String(roundId || "").trim(); + if (!normalizedRoundId) { + return; + } + + const normalizedCoderIds = new Set( + Array.from(coderIds || []) + .map((coderId) => String(coderId || "").trim()) + .filter(Boolean) + ); + if (normalizedCoderIds.size > 0) { + byRoundId.set(normalizedRoundId, normalizedCoderIds); + } + }); + + return byRoundId; +}; + +const selectLaterSubmissionRow = (currentRow, candidateRow) => { + if (!currentRow) { + return candidateRow || null; + } + if (!candidateRow) { + return currentRow; + } + return compareSubmissionRows(currentRow, candidateRow) <= 0 ? candidateRow : currentRow; +}; + const deriveLegacySubmissionId = ({ longComponentStateId, submissionNumber }) => { const normalizedStateId = String(longComponentStateId || "").trim(); if (!normalizedStateId) { @@ -118,6 +153,7 @@ const loadNonExampleLegacySubmissionRowsByRoundId = async ({ longComponentStateFile, longSubmissionPattern, roundIds, + attachableExampleOnlyFinalistCoderIdsByRoundId = new Map(), }) => { const selectedRoundIds = Array.from(new Set((roundIds || []).map((roundId) => String(roundId || "").trim()).filter(Boolean))); const rowsByRoundId = new Map(selectedRoundIds.map((roundId) => [roundId, []])); @@ -134,6 +170,8 @@ const loadNonExampleLegacySubmissionRowsByRoundId = async ({ ); const selectedRoundIdSet = new Set(selectedRoundIds); + const normalizedAttachableExampleOnlyFinalistCoderIdsByRoundId = + normalizeCoderIdSetByRoundId(attachableExampleOnlyFinalistCoderIdsByRoundId); const stateInfoById = new Map(); await streamJsonArray(longComponentStatePath, "long_component_state", (row) => { const roundId = String(row && row.round_id ? row.round_id : "").trim(); @@ -154,6 +192,9 @@ const loadNonExampleLegacySubmissionRowsByRoundId = async ({ }); const generatedSubmissionOrdinalByStateId = new Map(); + const generatedExampleSubmissionOrdinalByStateId = new Map(); + const latestExampleOnlyCandidateByStateId = new Map(); + const stateIdsWithNonExampleSubmissions = new Set(); await Promise.all( longSubmissionFiles.map((filePath) => streamJsonArray(filePath, "long_submission", (row) => { @@ -167,9 +208,38 @@ const loadNonExampleLegacySubmissionRowsByRoundId = async ({ const isExample = String(row && row.example ? row.example : "").trim() === "1"; if (isExample) { + const currentExampleOrdinal = + generatedExampleSubmissionOrdinalByStateId.get(longComponentStateId) || 0; + const fallbackOrdinal = currentExampleOrdinal + 1; + generatedExampleSubmissionOrdinalByStateId.set(longComponentStateId, fallbackOrdinal); + const submissionNumber = + parsePositiveInteger(row && row.submission_number) || fallbackOrdinal; + const legacySubmissionId = deriveLegacySubmissionId({ + longComponentStateId, + submissionNumber, + }); + latestExampleOnlyCandidateByStateId.set( + longComponentStateId, + selectLaterSubmissionRow( + latestExampleOnlyCandidateByStateId.get(longComponentStateId), + { + legacyRoundId: stateInfo.legacyRoundId, + coderId: stateInfo.coderId, + longComponentStateId, + submissionNumber, + submitTimeMs: parseEpochMs(row && row.submit_time), + submittedDate: parseEpochMs(row && row.submit_time) + ? new Date(parseEpochMs(row && row.submit_time)) + : null, + legacySubmissionId, + isSyntheticExampleOnlyFinalist: true, + } + ) + ); return; } + stateIdsWithNonExampleSubmissions.add(longComponentStateId); const currentOrdinal = generatedSubmissionOrdinalByStateId.get(longComponentStateId) || 0; const fallbackOrdinal = currentOrdinal + 1; generatedSubmissionOrdinalByStateId.set(longComponentStateId, fallbackOrdinal); @@ -190,11 +260,31 @@ const loadNonExampleLegacySubmissionRowsByRoundId = async ({ ? new Date(parseEpochMs(row && row.submit_time)) : null, legacySubmissionId, + isSyntheticExampleOnlyFinalist: false, }); }) ) ); + stateInfoById.forEach((stateInfo, longComponentStateId) => { + if (stateIdsWithNonExampleSubmissions.has(longComponentStateId)) { + return; + } + + const attachableCoderIds = + normalizedAttachableExampleOnlyFinalistCoderIdsByRoundId.get(stateInfo.legacyRoundId); + if (!attachableCoderIds || !attachableCoderIds.has(stateInfo.coderId)) { + return; + } + + const exampleOnlyCandidate = latestExampleOnlyCandidateByStateId.get(longComponentStateId); + if (!exampleOnlyCandidate) { + return; + } + + rowsByRoundId.get(stateInfo.legacyRoundId).push(exampleOnlyCandidate); + }); + rowsByRoundId.forEach((rows, roundId) => { const sortedRows = [...rows].sort(compareSubmissionRows); rowsByRoundId.set(roundId, sortedRows); @@ -373,6 +463,10 @@ const reconcileRoundSubmissionHistory = async ({ } const legacyRows = rowsByRoundId.get(roundId) || []; + const legacyNonExampleSubmissions = legacyRows.filter( + (row) => row && row.isSyntheticExampleOnlyFinalist !== true + ).length; + const legacyExampleOnlyFinalistSubmissions = legacyRows.length - legacyNonExampleSubmissions; const existingByLegacySubmissionId = await submissionStore.listExistingSubmissionsByLegacyId({ challengeId, }); @@ -448,7 +542,8 @@ const reconcileRoundSubmissionHistory = async ({ } return { - legacyNonExampleSubmissions: legacyRows.length, + legacyNonExampleSubmissions, + legacyExampleOnlyFinalistSubmissions, importedSubmissions: createdSubmissions + alreadyPresentSubmissions, alreadyPresentSubmissions, createdSubmissions, diff --git a/data-migration/src/scripts/importMarathonMatchWinners.js b/data-migration/src/scripts/importMarathonMatchWinners.js index 2e19f7d..51be3be 100644 --- a/data-migration/src/scripts/importMarathonMatchWinners.js +++ b/data-migration/src/scripts/importMarathonMatchWinners.js @@ -3,6 +3,8 @@ /** * Import marathon match winners from Informix JSON exports. + * Conflicting duplicate legacy placements are normalized by score so lower-scoring rows with the + * same raw `placed` value are skipped instead of creating duplicate placements downstream. * * Required environment: * - DATABASE_URL: Challenge DB connection string. @@ -235,6 +237,161 @@ const parseNumericString = (value) => { return Number.isInteger(num) ? num : null; }; +const parseNumericScore = (value) => { + if (value === null || value === undefined) { + return null; + } + const text = String(value).trim(); + if (!text || text.toLowerCase() === "null") { + return null; + } + const num = Number.parseFloat(text); + return Number.isFinite(num) ? num : null; +}; + +const resolvePlacementScore = (row) => { + const systemPointTotal = parseNumericScore(row && row.system_point_total); + if (Number.isFinite(systemPointTotal)) { + return systemPointTotal; + } + const pointTotal = parseNumericScore(row && row.point_total); + if (Number.isFinite(pointTotal)) { + return pointTotal; + } + return null; +}; + +/** + * Clears duplicate legacy placements within the same round when Informix reused the same raw + * `placed` value for rows that do not share the same score. Lower-scoring or unscored rows lose + * their normalized `placement`, but retain `rawPlacement` for diagnostics. + * + * @param {Array} entries parsed winner entries across one or more rounds + * @returns {Array} cloned entries with normalized placement fields + */ +const normalizeConflictingDuplicatePlacements = (entries = []) => { + const normalizedEntries = entries.map((entry) => ({ + ...entry, + rawPlacement: Number.isFinite(entry && entry.placement) ? entry.placement : null, + })); + + const entriesByRoundPlacement = new Map(); + normalizedEntries.forEach((entry) => { + if (!entry.roundId || !Number.isFinite(entry.rawPlacement)) { + return; + } + const key = `${entry.roundId}:${entry.rawPlacement}`; + if (!entriesByRoundPlacement.has(key)) { + entriesByRoundPlacement.set(key, []); + } + entriesByRoundPlacement.get(key).push(entry); + }); + + entriesByRoundPlacement.forEach((placementEntries) => { + const scoredEntries = placementEntries.filter((entry) => Number.isFinite(entry.score)); + if (scoredEntries.length === 0) { + return; + } + + const distinctScores = Array.from(new Set(scoredEntries.map((entry) => entry.score))); + const hasUnscoredEntries = scoredEntries.length !== placementEntries.length; + if (distinctScores.length <= 1 && !hasUnscoredEntries) { + return; + } + + const topScore = distinctScores.reduce( + (highest, score) => Math.max(highest, score), + Number.NEGATIVE_INFINITY + ); + placementEntries.forEach((entry) => { + if (!Number.isFinite(entry.score) || entry.score !== topScore) { + entry.placement = null; + } + }); + }); + + return normalizedEntries; +}; + +/** + * Parses long_comp_result rows into placement entries grouped by round, clearing duplicate legacy + * placements when the shared raw `placed` value conflicts with the score ordering. + * + * @param {Array} longCompResults raw Informix long_comp_result rows + * @param {object} options parse options + * @param {Array} options.roundIds optional round ids to include + * @returns {{resultsByRound: Map>, userIds: Set, skipped: object}} + */ +const buildPlacementEntries = (longCompResults = [], { roundIds = [] } = {}) => { + const selectedRoundIds = new Set((roundIds || []).map((roundId) => String(roundId || "").trim()).filter(Boolean)); + const parsedEntries = []; + const resultsByRound = new Map(); + const userIds = new Set(); + const skipped = { + missingRoundId: 0, + invalidUserId: 0, + missingPlacement: 0, + invalidPlacement: 0, + conflictingDuplicatePlacement: 0, + }; + + longCompResults.forEach((row) => { + if (!row) { + return; + } + const roundId = String(row.round_id || "").trim(); + if (!roundId) { + skipped.missingRoundId += 1; + return; + } + if (selectedRoundIds.size > 0 && !selectedRoundIds.has(roundId)) { + return; + } + + const placement = parseNumericString(row.placed); + if (placement === null) { + skipped.missingPlacement += 1; + return; + } + if (!Number.isFinite(placement) || placement <= 0) { + skipped.invalidPlacement += 1; + return; + } + + const userId = parseNumericString(row.coder_id); + if (!Number.isFinite(userId) || userId <= 0) { + skipped.invalidUserId += 1; + return; + } + + parsedEntries.push({ + roundId, + placement, + userId, + score: resolvePlacementScore(row), + }); + userIds.add(userId); + }); + + normalizeConflictingDuplicatePlacements(parsedEntries).forEach((entry) => { + if (!Number.isFinite(entry.placement) || entry.placement <= 0) { + skipped.conflictingDuplicatePlacement += 1; + return; + } + + if (!resultsByRound.has(entry.roundId)) { + resultsByRound.set(entry.roundId, []); + } + resultsByRound.get(entry.roundId).push(entry); + }); + + return { + resultsByRound, + userIds, + skipped, + }; +}; + const levenshtein = (a, b) => { if (a === b) return 0; if (!a) return b.length; @@ -424,50 +581,8 @@ async function main() { } }); - const resultsByRound = new Map(); - const userIds = new Set(); - const skipped = { - missingRoundId: 0, - invalidUserId: 0, - missingPlacement: 0, - invalidPlacement: 0, - }; - - longCompResults.forEach((row) => { - if (!row) { - return; - } - const roundId = String(row.round_id || "").trim(); - if (!roundId) { - skipped.missingRoundId += 1; - return; - } - if (options.roundIds.length && !options.roundIds.includes(roundId)) { - return; - } - - const placement = parseNumericString(row.placed); - if (placement === null) { - skipped.missingPlacement += 1; - return; - } - if (!Number.isFinite(placement) || placement <= 0) { - skipped.invalidPlacement += 1; - return; - } - - const userId = parseNumericString(row.coder_id); - if (!Number.isFinite(userId) || userId <= 0) { - skipped.invalidUserId += 1; - return; - } - - const entry = { roundId, placement, userId }; - if (!resultsByRound.has(roundId)) { - resultsByRound.set(roundId, []); - } - resultsByRound.get(roundId).push(entry); - userIds.add(userId); + const { resultsByRound, userIds, skipped } = buildPlacementEntries(longCompResults, { + roundIds: options.roundIds, }); if (!resultsByRound.size) { @@ -647,7 +762,7 @@ async function main() { console.log(` Winners skipped (existing): ${summary.winnersSkippedExisting}`); console.log(` Winners skipped (missing handle): ${summary.winnersSkippedMissingHandle}`); console.log( - ` Skipped rows: missingRoundId=${skipped.missingRoundId}, missingPlacement=${skipped.missingPlacement}, invalidPlacement=${skipped.invalidPlacement}, invalidUserId=${skipped.invalidUserId}` + ` Skipped rows: missingRoundId=${skipped.missingRoundId}, missingPlacement=${skipped.missingPlacement}, invalidPlacement=${skipped.invalidPlacement}, invalidUserId=${skipped.invalidUserId}, conflictingDuplicatePlacement=${skipped.conflictingDuplicatePlacement}` ); } finally { if (rl) { @@ -660,7 +775,15 @@ async function main() { } } -main().catch((error) => { - console.error(error); - process.exit(1); -}); +if (require.main === module) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} + +module.exports = { + buildPlacementEntries, + normalizeConflictingDuplicatePlacements, + parseArgs, +}; diff --git a/data-migration/test/importHistoricalMarathonMatches.applyFinalScores.test.js b/data-migration/test/importHistoricalMarathonMatches.applyFinalScores.test.js index da16ae4..a21b3ab 100644 --- a/data-migration/test/importHistoricalMarathonMatches.applyFinalScores.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.applyFinalScores.test.js @@ -207,4 +207,178 @@ describe("importHistoricalMarathonMatches apply mode final-score wiring", () => }) ); }); + + test("apply-mode attaches final scores to latest example-only finalist submissions", async () => { + writeJson(fixtureDir, "long_submission_1.json", "long_submission", [ + { long_component_state_id: "1001", submission_number: "1", example: "0", submit_time: "1000" }, + { long_component_state_id: "1002", submission_number: "1", example: "1", submit_time: "1001" }, + { long_component_state_id: "1002", submission_number: "2", example: "1", submit_time: "1002" }, + ]); + + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({ id: "challenge-1" }), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([]), + createMany: jest.fn(), + }, + }; + + const phaseRows = [ + { id: "phase-registration", name: "Registration" }, + { id: "phase-submission", name: "Submission" }, + { id: "phase-review", name: "Review" }, + ]; + + const prisma = { + challengeType: { + findMany: jest.fn().mockResolvedValue([{ id: "type-mm" }]), + }, + challengeTrack: { + findMany: jest.fn().mockResolvedValue([{ id: "track-ds" }]), + }, + phase: { + findMany: jest + .fn() + .mockResolvedValueOnce(phaseRows) + .mockResolvedValueOnce(phaseRows), + }, + challengeTimelineTemplate: { + findMany: jest.fn().mockResolvedValue([ + { + timelineTemplateId: "timeline-mm", + isDefault: true, + timelineTemplate: { + phases: [ + { phaseId: "phase-registration" }, + { phaseId: "phase-submission" }, + { phaseId: "phase-review" }, + ], + }, + }, + ]), + }, + $transaction: async (callback) => callback(tx), + }; + + const submissionStoreRecordsByChallengeId = new Map(); + const submissionStore = { + listExistingSubmissionsByLegacyId: async ({ challengeId }) => + new Map(submissionStoreRecordsByChallengeId.get(challengeId) || []), + createSubmission: async ({ challengeId, legacySubmissionId, memberId, submittedDate }) => { + if (!submissionStoreRecordsByChallengeId.has(challengeId)) { + submissionStoreRecordsByChallengeId.set(challengeId, new Map()); + } + submissionStoreRecordsByChallengeId.get(challengeId).set(legacySubmissionId, { + id: `sub-${legacySubmissionId}`, + legacySubmissionId, + memberId: String(memberId), + submittedDate, + createdAt: submittedDate, + }); + }, + }; + + const createdFinalSummations = []; + const finalScoreStore = { + listImportedNonExampleSubmissionsByChallenge: async ({ challengeId }) => + Array.from( + (submissionStoreRecordsByChallengeId.get(challengeId) || new Map()).values() + ), + listExistingFinalSummationsBySubmissionId: async () => new Map(), + createFinalSummation: async (payload) => { + createdFinalSummations.push(payload); + }, + }; + + const result = await runApplyMode({ + prisma, + options: { + roundIds: ["9892"], + skippedFilePath: path.join(fixtureDir, "apply-example-only-final-skipped.json"), + importSubmissions: true, + importFinalScores: true, + submissionStore, + finalScoreStore, + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + resourceClient: { + listSubmitterResources: jest.fn().mockResolvedValue([]), + createSubmitterResource: jest.fn().mockResolvedValue({}), + }, + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "create", + reason: "no-matching-v6-challenge-found", + }, + ], + roundDataById: new Map([ + [ + "9892", + { + round: { round_id: "9892", round_type_id: "13", name: "MM 9892" }, + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T01:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestExampleOnlyFinalistSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestExampleOnlyFinalistSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + eligibleRegistrants: new Set(["1", "3"]), + nonExampleSubmissions: 1, + exampleOnlyFinalistSubmissions: 1, + nonExampleSubmitterCoderIds: new Set(["1"]), + exampleOnlyFinalistSubmissionCountsByCoderId: new Map([["3", 1]]), + finalCandidateCoderIds: new Set(["1", "3"]), + }, + ], + ]), + }, + actor: "importer", + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["3", { coderId: "3", memberId: 3, memberHandle: "charlie" }], + ]), + }); + + expect(createdFinalSummations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + submissionId: "sub-10010001", + aggregateScore: 10, + legacySubmissionId: "10010001", + }), + expect.objectContaining({ + submissionId: "sub-10020002", + aggregateScore: 5, + legacySubmissionId: "10020002", + }), + ]) + ); + + expect(result.records).toEqual([ + expect.objectContaining({ + recordType: "apply-record", + legacyRoundId: "9892", + status: "created", + challengeId: "challenge-1", + finalScoreReconciliation: { + legacyFinalCandidates: 2, + importedFinalScores: 2, + alreadyPresentFinalScores: 0, + createdFinalScores: 2, + missingMemberSkippedFinalScores: 0, + explicitSkippedFinalScores: 0, + runtimeSkipRecords: [], + }, + }), + ]); + }); }); diff --git a/data-migration/test/importHistoricalMarathonMatches.applyProvisionalScores.test.js b/data-migration/test/importHistoricalMarathonMatches.applyProvisionalScores.test.js index 3cb539d..0baedfa 100644 --- a/data-migration/test/importHistoricalMarathonMatches.applyProvisionalScores.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.applyProvisionalScores.test.js @@ -22,6 +22,7 @@ describe("importHistoricalMarathonMatches apply mode provisional-score wiring", writeJson(fixtureDir, "long_component_state_1.json", "long_component_state", [ { long_component_state_id: "1001", round_id: "9892", coder_id: "1", component_id: "5503", points: "9.5" }, { long_component_state_id: "1002", round_id: "9892", coder_id: "2", component_id: "5503", points: "7.0" }, + { long_component_state_id: "1003", round_id: "9892", coder_id: "3", component_id: "5503", points: "5.5" }, ]); writeJson(fixtureDir, "long_submission_1.json", "long_submission", [ { @@ -38,6 +39,20 @@ describe("importHistoricalMarathonMatches apply mode provisional-score wiring", submit_time: "1001", submission_points: "7.0", }, + { + long_component_state_id: "1003", + submission_number: "1", + example: "1", + submit_time: "1002", + submission_points: "6.0", + }, + { + long_component_state_id: "1003", + submission_number: "2", + example: "1", + submit_time: "1003", + submission_points: "5.5", + }, ]); }); @@ -211,6 +226,7 @@ describe("importHistoricalMarathonMatches apply mode provisional-score wiring", challengeId: "challenge-1", provisionalScoreReconciliation: { legacyNonExampleProvisionalScores: 2, + legacyExampleOnlyFinalistProvisionalScores: 0, importedProvisionalScores: 1, alreadyPresentProvisionalScores: 0, createdProvisionalScores: 1, @@ -241,4 +257,178 @@ describe("importHistoricalMarathonMatches apply mode provisional-score wiring", }) ); }); + + test("apply-mode imports a provisional score for the latest example-only finalist submission", async () => { + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({ id: "challenge-1" }), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([]), + createMany: jest.fn(), + }, + }; + + const phaseRows = [ + { id: "phase-registration", name: "Registration" }, + { id: "phase-submission", name: "Submission" }, + { id: "phase-review", name: "Review" }, + ]; + + const prisma = { + challengeType: { + findMany: jest.fn().mockResolvedValue([{ id: "type-mm" }]), + }, + challengeTrack: { + findMany: jest.fn().mockResolvedValue([{ id: "track-ds" }]), + }, + phase: { + findMany: jest + .fn() + .mockResolvedValueOnce(phaseRows) + .mockResolvedValueOnce(phaseRows), + }, + challengeTimelineTemplate: { + findMany: jest.fn().mockResolvedValue([ + { + timelineTemplateId: "timeline-mm", + isDefault: true, + timelineTemplate: { + phases: [ + { phaseId: "phase-registration" }, + { phaseId: "phase-submission" }, + { phaseId: "phase-review" }, + ], + }, + }, + ]), + }, + $transaction: async (callback) => callback(tx), + }; + + const submissionStoreRecordsByChallengeId = new Map(); + const submissionStore = { + listExistingSubmissionsByLegacyId: async ({ challengeId }) => + new Map(submissionStoreRecordsByChallengeId.get(challengeId) || []), + createSubmission: async ({ challengeId, legacySubmissionId, memberId, submittedDate }) => { + if (!submissionStoreRecordsByChallengeId.has(challengeId)) { + submissionStoreRecordsByChallengeId.set(challengeId, new Map()); + } + submissionStoreRecordsByChallengeId.get(challengeId).set(legacySubmissionId, { + id: `sub-${legacySubmissionId}`, + legacySubmissionId, + memberId: String(memberId), + submittedDate, + createdAt: submittedDate, + }); + }, + }; + + const createdProvisionalSummations = []; + const provisionalScoreStore = { + listImportedNonExampleSubmissionsByLegacySubmissionId: async ({ challengeId }) => + new Map( + Array.from( + (submissionStoreRecordsByChallengeId.get(challengeId) || new Map()).values() + ).map((submission) => [submission.legacySubmissionId, submission]) + ), + listExistingProvisionalSummationsBySubmissionId: async () => new Map(), + createProvisionalSummation: async (payload) => { + createdProvisionalSummations.push(payload); + }, + }; + + const result = await runApplyMode({ + prisma, + options: { + roundIds: ["9892"], + skippedFilePath: path.join(fixtureDir, "apply-example-only-provisional-skipped.json"), + importSubmissions: true, + importProvisionalScores: true, + submissionStore, + provisionalScoreStore, + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + resourceClient: { + listSubmitterResources: jest.fn().mockResolvedValue([]), + createSubmitterResource: jest.fn().mockResolvedValue({}), + }, + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "create", + reason: "no-matching-v6-challenge-found", + plannedSkipRecords: [], + }, + ], + roundDataById: new Map([ + [ + "9892", + { + round: { round_id: "9892", round_type_id: "13", name: "MM 9892" }, + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T01:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestExampleOnlyFinalistSubmitMs: Date.parse("2020-01-01T03:00:00.000Z"), + latestExampleOnlyFinalistSubmitMs: Date.parse("2020-01-01T03:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2", "3"]), + nonExampleSubmissions: 2, + exampleOnlyFinalistSubmissions: 1, + nonExampleSubmitterCoderIds: new Set(["1", "2"]), + exampleOnlyFinalistSubmissionCountsByCoderId: new Map([["3", 1]]), + finalCandidateCoderIds: new Set(["3"]), + }, + ], + ]), + }, + actor: "importer", + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ["3", { coderId: "3", memberId: 3, memberHandle: "charlie" }], + ]), + }); + + expect(createdProvisionalSummations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + submissionId: "sub-10030002", + aggregateScore: 5.5, + legacySubmissionId: "10030002", + isFinal: false, + }), + ]) + ); + + expect(result.records).toEqual([ + expect.objectContaining({ + recordType: "apply-record", + legacyRoundId: "9892", + status: "created", + challengeId: "challenge-1", + provisionalScoreReconciliation: { + legacyNonExampleProvisionalScores: 2, + legacyExampleOnlyFinalistProvisionalScores: 1, + importedProvisionalScores: 3, + alreadyPresentProvisionalScores: 0, + createdProvisionalScores: 3, + missingMemberSkippedProvisionalScores: 0, + importedDistinctSubmitters: 3, + missingMemberDistinctSubmitters: 0, + importedProvisionalCountsByMemberId: { + 1: 1, + 2: 1, + 3: 1, + }, + skippedProvisionalRecords: [], + }, + }), + ]); + }); }); diff --git a/data-migration/test/importHistoricalMarathonMatches.applySubmissions.test.js b/data-migration/test/importHistoricalMarathonMatches.applySubmissions.test.js index fa85f8f..6aabedb 100644 --- a/data-migration/test/importHistoricalMarathonMatches.applySubmissions.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.applySubmissions.test.js @@ -22,10 +22,13 @@ describe("importHistoricalMarathonMatches apply mode submission-history wiring", writeJson(fixtureDir, "long_component_state_1.json", "long_component_state", [ { long_component_state_id: "1001", round_id: "9892", coder_id: "1", component_id: "5503" }, { long_component_state_id: "1002", round_id: "9892", coder_id: "2", component_id: "5503" }, + { long_component_state_id: "1003", round_id: "9892", coder_id: "3", component_id: "5503" }, ]); writeJson(fixtureDir, "long_submission_1.json", "long_submission", [ { long_component_state_id: "1001", submission_number: "1", example: "0", submit_time: "1000" }, { long_component_state_id: "1002", submission_number: "1", example: "0", submit_time: "1001" }, + { long_component_state_id: "1003", submission_number: "1", example: "1", submit_time: "1002" }, + { long_component_state_id: "1003", submission_number: "2", example: "1", submit_time: "1003" }, ]); }); @@ -159,6 +162,7 @@ describe("importHistoricalMarathonMatches apply mode submission-history wiring", challengeId: "challenge-1", submissionReconciliation: { legacyNonExampleSubmissions: 2, + legacyExampleOnlyFinalistSubmissions: 0, importedSubmissions: 1, alreadyPresentSubmissions: 0, createdSubmissions: 1, @@ -211,4 +215,153 @@ describe("importHistoricalMarathonMatches apply mode submission-history wiring", ]) ); }); + + test("apply-mode materializes latest example-only finalist submission for official participants", async () => { + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({ id: "challenge-1" }), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([]), + createMany: jest.fn(), + }, + }; + + const phaseRows = [ + { id: "phase-registration", name: "Registration" }, + { id: "phase-submission", name: "Submission" }, + { id: "phase-review", name: "Review" }, + ]; + + const prisma = { + challengeType: { + findMany: jest.fn().mockResolvedValue([{ id: "type-mm" }]), + }, + challengeTrack: { + findMany: jest.fn().mockResolvedValue([{ id: "track-ds" }]), + }, + phase: { + findMany: jest + .fn() + .mockResolvedValueOnce(phaseRows) + .mockResolvedValueOnce(phaseRows), + }, + challengeTimelineTemplate: { + findMany: jest.fn().mockResolvedValue([ + { + timelineTemplateId: "timeline-mm", + isDefault: true, + timelineTemplate: { + phases: [ + { phaseId: "phase-registration" }, + { phaseId: "phase-submission" }, + { phaseId: "phase-review" }, + ], + }, + }, + ]), + }, + $transaction: async (callback) => callback(tx), + }; + + const submissionStoreRecords = new Map(); + const submissionStore = { + listExistingSubmissionsByLegacyId: async () => new Map(submissionStoreRecords), + createSubmission: async ({ legacySubmissionId, memberId, submitter }) => { + submissionStoreRecords.set(legacySubmissionId, { + legacySubmissionId, + memberId: String(memberId), + submitter: submitter || null, + }); + }, + }; + + const result = await runApplyMode({ + prisma, + options: { + roundIds: ["9892"], + skippedFilePath: path.join(fixtureDir, "apply-example-only-skipped.json"), + importSubmissions: true, + submissionStore, + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + resourceClient: { + listSubmitterResources: jest.fn().mockResolvedValue([]), + createSubmitterResource: jest.fn().mockResolvedValue({}), + }, + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "create", + reason: "no-matching-v6-challenge-found", + plannedSkipRecords: [], + }, + ], + roundDataById: new Map([ + [ + "9892", + { + round: { round_id: "9892", round_type_id: "13", name: "MM 9892" }, + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + earliestExampleOnlyFinalistSubmitMs: Date.parse("2020-01-01T03:00:00.000Z"), + latestExampleOnlyFinalistSubmitMs: Date.parse("2020-01-01T03:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2", "3"]), + nonExampleSubmissions: 2, + exampleOnlyFinalistSubmissions: 1, + nonExampleSubmitterCoderIds: new Set(["1", "2"]), + exampleOnlyFinalistSubmissionCountsByCoderId: new Map([["3", 1]]), + finalCandidateCoderIds: new Set(["3"]), + }, + ], + ]), + }, + actor: "importer", + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ["3", { coderId: "3", memberId: 3, memberHandle: "charlie" }], + ]), + }); + + expect(result.records).toEqual([ + expect.objectContaining({ + recordType: "apply-record", + legacyRoundId: "9892", + status: "created", + challengeId: "challenge-1", + submissionReconciliation: { + legacyNonExampleSubmissions: 2, + legacyExampleOnlyFinalistSubmissions: 1, + importedSubmissions: 3, + alreadyPresentSubmissions: 0, + createdSubmissions: 3, + missingMemberSkippedSubmissions: 0, + importedDistinctSubmitters: 3, + missingMemberDistinctSubmitters: 0, + importedSubmissionCountsByMemberId: { + 1: 1, + 2: 1, + 3: 1, + }, + skippedSubmissionRecords: [], + }, + }), + ]); + + expect(submissionStoreRecords).toEqual( + new Map([ + ["10010001", { legacySubmissionId: "10010001", memberId: "1", submitter: null }], + ["10020001", { legacySubmissionId: "10020001", memberId: "2", submitter: null }], + ["10030002", { legacySubmissionId: "10030002", memberId: "3", submitter: null }], + ]) + ); + }); }); diff --git a/data-migration/test/importHistoricalMarathonMatches.finalScores.test.js b/data-migration/test/importHistoricalMarathonMatches.finalScores.test.js index ad489c3..fac7a79 100644 --- a/data-migration/test/importHistoricalMarathonMatches.finalScores.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.finalScores.test.js @@ -139,6 +139,91 @@ describe("importHistoricalMarathonMatches final score import", () => { } }); + test("clears conflicting duplicate legacy placements while preserving the raw value", async () => { + const duplicatePlacementFixtureDir = fs.mkdtempSync( + path.join(os.tmpdir(), "mm-final-scores-duplicate-placement-fixture-") + ); + try { + writeJson( + duplicatePlacementFixtureDir, + "long_component_state_1.json", + "long_component_state", + [{ long_component_state_id: "4001", round_id: "10929", coder_id: "2", points: "0" }] + ); + writeJson( + duplicatePlacementFixtureDir, + "long_comp_result_1.json", + "long_comp_result", + [ + { + round_id: "10929", + coder_id: "1", + system_point_total: "19837.23", + point_total: "2486.27", + placed: "18", + }, + { + round_id: "10929", + coder_id: "2", + system_point_total: "0.00", + point_total: null, + placed: "18", + }, + { + round_id: "10929", + coder_id: "3", + system_point_total: null, + point_total: null, + placed: "18", + }, + { + round_id: "10929", + coder_id: "4", + system_point_total: "123.45", + point_total: null, + placed: "19", + }, + ] + ); + + const rowsByRoundId = await loadLegacyFinalRowsByRoundId({ + dataDir: duplicatePlacementFixtureDir, + longComponentStateFile: "long_component_state_1.json", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + roundIds: ["10929"], + }); + + expect(rowsByRoundId.get("10929")).toEqual([ + expect.objectContaining({ + coderId: "1", + legacyPlacement: 18, + rawLegacyPlacement: 18, + aggregateScore: 19837.23, + }), + expect.objectContaining({ + coderId: "4", + legacyPlacement: 19, + rawLegacyPlacement: 19, + aggregateScore: 123.45, + }), + expect.objectContaining({ + coderId: "2", + legacyPlacement: null, + rawLegacyPlacement: 18, + aggregateScore: 0, + }), + expect.objectContaining({ + coderId: "3", + legacyPlacement: null, + rawLegacyPlacement: 18, + aggregateScore: null, + }), + ]); + } finally { + fs.rmSync(duplicatePlacementFixtureDir, { recursive: true, force: true }); + } + }); + test("attaches one final per member to latest imported non-example submission and tracks skips", async () => { const rowsByRoundId = await loadLegacyFinalRowsByRoundId({ dataDir: fixtureDir, @@ -222,11 +307,21 @@ describe("importHistoricalMarathonMatches final score import", () => { submissionId: "sub-1-new", aggregateScore: 100, legacySubmissionId: "10010002", + metadata: expect.objectContaining({ + legacyPlacement: 1, + rawLegacyPlacement: 1, + scoreSource: "system_point_total", + }), }), expect.objectContaining({ submissionId: "sub-2", aggregateScore: 70, legacySubmissionId: "10020001", + metadata: expect.objectContaining({ + legacyPlacement: 2, + rawLegacyPlacement: 2, + scoreSource: "point_total", + }), }), ]); }); diff --git a/data-migration/test/importHistoricalMarathonMatches.missingMemberPlanning.test.js b/data-migration/test/importHistoricalMarathonMatches.missingMemberPlanning.test.js index 4c8f91e..dd13221 100644 --- a/data-migration/test/importHistoricalMarathonMatches.missingMemberPlanning.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.missingMemberPlanning.test.js @@ -42,6 +42,8 @@ const buildFixtureDataDirectory = () => { { long_component_state_id: "lcs-2", submission_number: "1", example: "0", submit_time: "102", open_time: "90", submission_points: "12.0" }, { long_component_state_id: "lcs-3", submission_number: "1", example: "0", submit_time: "103", open_time: "90", submission_points: "13.0" }, { long_component_state_id: "lcs-3", submission_number: "2", example: "0", submit_time: "104", open_time: "90", submission_points: "14.0" }, + { long_component_state_id: "lcs-4", submission_number: "1", example: "1", submit_time: "105", open_time: "90", submission_points: "15.0" }, + { long_component_state_id: "lcs-4", submission_number: "2", example: "1", submit_time: "106", open_time: "90", submission_points: "16.0" }, ]); writeJson(baseDir, "long_comp_result_1.json", "long_comp_result", [ { round_id: "9892", coder_id: "1", system_point_total: "98.1", point_total: null, placed: "1" }, @@ -173,8 +175,9 @@ describe("importHistoricalMarathonMatches missing-member planning/reporting", () }); expect(record.partitions.submissions).toEqual({ legacyNonExample: 4, - legacyExampleFiltered: 1, - toImport: 1, + legacyExampleFiltered: 3, + legacyExampleOnlyFinalists: 1, + toImport: 2, alreadyPresent: 1, missingMember: 2, explicitSkips: { @@ -184,19 +187,18 @@ describe("importHistoricalMarathonMatches missing-member planning/reporting", () }); expect(record.partitions.finalScores).toEqual({ legacyFinalCandidates: 3, - toImport: 1, + toImport: 2, alreadyPresent: 0, missingMember: 1, explicitSkips: { - total: 1, - byReason: { - "finalist-without-attachable-submission": 1, - }, + total: 0, + byReason: {}, }, }); expect(record.partitions.provisionalScores).toEqual({ legacyNonExample: 4, - toImport: 1, + legacyExampleOnlyFinalists: 1, + toImport: 2, alreadyPresent: 1, missingMember: 2, explicitSkips: { @@ -213,12 +215,6 @@ describe("importHistoricalMarathonMatches missing-member planning/reporting", () reasonCode: "missing-member", affectedSurfaces: ["resource", "submission", "final-score", "provisional-score"], }), - expect.objectContaining({ - legacyRoundId: "9892", - memberId: "4", - reasonCode: "finalist-without-attachable-submission", - affectedSurfaces: ["final-score"], - }), ]) ); expect(record.skippedFileArtifact).toEqual( diff --git a/data-migration/test/importHistoricalMarathonMatches.plan.test.js b/data-migration/test/importHistoricalMarathonMatches.plan.test.js index eddec28..fe3401e 100644 --- a/data-migration/test/importHistoricalMarathonMatches.plan.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.plan.test.js @@ -180,6 +180,7 @@ describe("importHistoricalMarathonMatches CLI planning behavior", () => { eligibleRegistrants: 2, nonExampleSubmissions: 3, exampleSubmissionsFiltered: 1, + exampleOnlyFinalistSubmissions: 0, plannedFinalScores: 0, plannedProvisionalScores: 0, finalistsWithoutAttachableSubmission: 0, diff --git a/data-migration/test/importHistoricalMarathonMatches.provisionalScores.test.js b/data-migration/test/importHistoricalMarathonMatches.provisionalScores.test.js index 99556d7..424f084 100644 --- a/data-migration/test/importHistoricalMarathonMatches.provisionalScores.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.provisionalScores.test.js @@ -21,6 +21,7 @@ const createFixtureDataDirectory = () => { writeJson(baseDir, "long_component_state_1.json", "long_component_state", [ { long_component_state_id: "1001", round_id: "9892", coder_id: "1", component_id: "5503" }, { long_component_state_id: "1002", round_id: "9892", coder_id: "2", component_id: "5503" }, + { long_component_state_id: "1003", round_id: "9892", coder_id: "3", component_id: "5503" }, ]); writeJson(baseDir, "long_submission_1.json", "long_submission", [ { @@ -51,6 +52,20 @@ const createFixtureDataDirectory = () => { submit_time: "1003", submission_points: "7.0", }, + { + long_component_state_id: "1003", + submission_number: "1", + example: "1", + submit_time: "1004", + submission_points: "6.0", + }, + { + long_component_state_id: "1003", + submission_number: "2", + example: "1", + submit_time: "1005", + submission_points: "5.5", + }, ]); return baseDir; @@ -175,6 +190,7 @@ describe("importHistoricalMarathonMatches provisional score import", () => { expect(firstRun).toEqual({ legacyNonExampleProvisionalScores: 3, + legacyExampleOnlyFinalistProvisionalScores: 0, importedProvisionalScores: 2, alreadyPresentProvisionalScores: 1, createdProvisionalScores: 1, @@ -220,6 +236,7 @@ describe("importHistoricalMarathonMatches provisional score import", () => { expect(secondRun).toEqual({ legacyNonExampleProvisionalScores: 3, + legacyExampleOnlyFinalistProvisionalScores: 0, importedProvisionalScores: 2, alreadyPresentProvisionalScores: 2, createdProvisionalScores: 0, @@ -243,4 +260,44 @@ describe("importHistoricalMarathonMatches provisional score import", () => { ], }); }); + + test("loads the latest example-only finalist provisional row when requested", async () => { + const rowsByRoundId = await loadLegacyProvisionalRowsByRoundId({ + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + roundIds: ["9892"], + attachableExampleOnlyFinalistCoderIdsByRoundId: new Map([ + ["9892", new Set(["3"])], + ]), + }); + + expect(rowsByRoundId.get("9892")).toEqual([ + expect.objectContaining({ + legacyRoundId: "9892", + coderId: "1", + legacySubmissionId: "10010001", + isSyntheticExampleOnlyFinalist: false, + }), + expect.objectContaining({ + legacyRoundId: "9892", + coderId: "1", + legacySubmissionId: "10010003", + isSyntheticExampleOnlyFinalist: false, + }), + expect.objectContaining({ + legacyRoundId: "9892", + coderId: "2", + legacySubmissionId: "10020001", + isSyntheticExampleOnlyFinalist: false, + }), + expect.objectContaining({ + legacyRoundId: "9892", + coderId: "3", + legacySubmissionId: "10030002", + aggregateScore: 5.5, + isSyntheticExampleOnlyFinalist: true, + }), + ]); + }); }); diff --git a/data-migration/test/importHistoricalMarathonMatches.submissionHistory.test.js b/data-migration/test/importHistoricalMarathonMatches.submissionHistory.test.js index 6da6ac7..22b6882 100644 --- a/data-migration/test/importHistoricalMarathonMatches.submissionHistory.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.submissionHistory.test.js @@ -21,12 +21,15 @@ const createFixtureDataDirectory = () => { writeJson(baseDir, "long_component_state_1.json", "long_component_state", [ { long_component_state_id: "1001", round_id: "9892", coder_id: "1", component_id: "5503" }, { long_component_state_id: "1002", round_id: "9892", coder_id: "2", component_id: "5503" }, + { long_component_state_id: "1003", round_id: "9892", coder_id: "3", component_id: "5503" }, ]); writeJson(baseDir, "long_submission_1.json", "long_submission", [ { long_component_state_id: "1001", submission_number: "1", example: "0", submit_time: "1000" }, { long_component_state_id: "1001", submission_number: "2", example: "1", submit_time: "1001" }, { long_component_state_id: "1001", submission_number: "3", example: "0", submit_time: "1002" }, { long_component_state_id: "1002", submission_number: "1", example: "0", submit_time: "1003" }, + { long_component_state_id: "1003", submission_number: "1", example: "1", submit_time: "1004" }, + { long_component_state_id: "1003", submission_number: "2", example: "1", submit_time: "1005" }, ]); return baseDir; @@ -119,6 +122,7 @@ describe("importHistoricalMarathonMatches submission history", () => { expect(firstRun).toEqual({ legacyNonExampleSubmissions: 3, + legacyExampleOnlyFinalistSubmissions: 0, importedSubmissions: 2, alreadyPresentSubmissions: 0, createdSubmissions: 2, @@ -153,6 +157,7 @@ describe("importHistoricalMarathonMatches submission history", () => { expect(secondRun).toEqual({ legacyNonExampleSubmissions: 3, + legacyExampleOnlyFinalistSubmissions: 0, importedSubmissions: 2, alreadyPresentSubmissions: 2, createdSubmissions: 0, @@ -211,4 +216,43 @@ describe("importHistoricalMarathonMatches submission history", () => { 'Existing submission legacySubmissionId "10010001" is linked to memberId 999 but legacy coder 1 resolves to memberId 1.' ); }); + + test("materializes the latest example-only finalist submission when requested", async () => { + const rowsByRoundId = await loadNonExampleLegacySubmissionRowsByRoundId({ + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + roundIds: ["9892"], + attachableExampleOnlyFinalistCoderIdsByRoundId: new Map([ + ["9892", new Set(["3"])], + ]), + }); + + expect(rowsByRoundId.get("9892")).toEqual([ + expect.objectContaining({ + legacyRoundId: "9892", + coderId: "1", + legacySubmissionId: "10010001", + isSyntheticExampleOnlyFinalist: false, + }), + expect.objectContaining({ + legacyRoundId: "9892", + coderId: "1", + legacySubmissionId: "10010003", + isSyntheticExampleOnlyFinalist: false, + }), + expect.objectContaining({ + legacyRoundId: "9892", + coderId: "2", + legacySubmissionId: "10020001", + isSyntheticExampleOnlyFinalist: false, + }), + expect.objectContaining({ + legacyRoundId: "9892", + coderId: "3", + legacySubmissionId: "10030002", + isSyntheticExampleOnlyFinalist: true, + }), + ]); + }); }); diff --git a/data-migration/test/importMarathonMatchWinners.test.js b/data-migration/test/importMarathonMatchWinners.test.js new file mode 100644 index 0000000..57ac719 --- /dev/null +++ b/data-migration/test/importMarathonMatchWinners.test.js @@ -0,0 +1,79 @@ +const { + buildPlacementEntries, +} = require("../src/scripts/importMarathonMatchWinners"); + +describe("importMarathonMatchWinners placement normalization", () => { + test("skips lower-scoring duplicate placements within a round", () => { + const { resultsByRound, userIds, skipped } = buildPlacementEntries( + [ + { + round_id: "10929", + coder_id: "22657314", + placed: "18", + system_point_total: "19837.23", + point_total: "2486.27", + }, + { + round_id: "10929", + coder_id: "22695240", + placed: "18", + system_point_total: "0.00", + point_total: null, + }, + { + round_id: "10929", + coder_id: "30000000", + placed: "18", + system_point_total: null, + point_total: null, + }, + { + round_id: "10929", + coder_id: "30000001", + placed: "19", + system_point_total: "123.45", + point_total: null, + }, + { + round_id: "20000", + coder_id: "40000001", + placed: "1", + system_point_total: "999.99", + point_total: null, + }, + ], + { roundIds: ["10929"] } + ); + + expect(Array.from(userIds)).toEqual([ + 22657314, + 22695240, + 30000000, + 30000001, + ]); + expect(skipped).toEqual({ + missingRoundId: 0, + invalidUserId: 0, + missingPlacement: 0, + invalidPlacement: 0, + conflictingDuplicatePlacement: 2, + }); + expect(resultsByRound.get("10929")).toEqual([ + expect.objectContaining({ + roundId: "10929", + userId: 22657314, + placement: 18, + rawPlacement: 18, + score: 19837.23, + }), + expect.objectContaining({ + roundId: "10929", + userId: 30000001, + placement: 19, + rawPlacement: 19, + score: 123.45, + }), + ]); + expect(resultsByRound.has("20000")).toBe(false); + }); +}); From 5462281429d5e0ee629ebf70a2fc32ccfea2c8e4 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 13 Apr 2026 17:06:51 +1000 Subject: [PATCH 27/27] PM-4837: Make challenge listing name search case-insensitive What was broken Challenge listings only matched challenge names when the query casing matched the stored challenge name exactly. The project challenge page used the `name` filter and the common challenge listing used the shared `search` filter, so lowercase queries could miss mixed-case challenge names. Root cause The Prisma `contains` filters for challenge name matching were case-sensitive in both the dedicated `name` branch and the shared `search` branch of `searchChallenges`. What was changed Updated challenge name matching in `searchChallenges` to use Prisma's case-insensitive mode for both `criteria.name` and the challenge-name portion of `criteria.search`. Added focused unit coverage for both the project-page `name` filter path and the common-listing `search` filter path. Any added/updated tests Updated `test/unit/ChallengeService.test.js` with regression tests covering case-insensitive matching for both `name` and `search` challenge queries. --- src/services/ChallengeService.js | 31 ++++++++++++++++++------------ test/unit/ChallengeService.test.js | 24 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index ff63586..1bd1ece 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -926,7 +926,10 @@ async function searchChallenges(currentUser, criteria) { prismaFilter.where.AND.push({ OR: [ { - name: { contains: criteria.search }, + name: { + contains: criteria.search, + mode: "insensitive", + }, }, { description: { contains: criteria.search }, @@ -944,7 +947,10 @@ async function searchChallenges(currentUser, criteria) { } else { if (criteria.name) { prismaFilter.where.AND.push({ - name: { contains: criteria.name }, + name: { + contains: criteria.name, + mode: "insensitive", + }, }); } @@ -2524,7 +2530,7 @@ async function validateChallengeActivationBillingAccount({ if (!normalizedBillingAccountId) { throw new errors.BadRequestError( - "Cannot activate challenge because the project has no billing account." + "Cannot activate challenge because the project has no billing account.", ); } @@ -2533,36 +2539,37 @@ async function validateChallengeActivationBillingAccount({ if (resolvedProjectActive === false) { throw new errors.BadRequestError( - "Cannot activate challenge because the project billing account is inactive." + "Cannot activate challenge because the project billing account is inactive.", ); } if (isBillingAccountExpired(resolvedProjectActive, resolvedProjectEndDate)) { throw new errors.BadRequestError( - "Cannot activate challenge because the project billing account is expired." + "Cannot activate challenge because the project billing account is expired.", ); } - const billingAccountDetails = await projectHelper.getBillingAccountDetails(normalizedBillingAccountId); + const billingAccountDetails = await projectHelper.getBillingAccountDetails( + normalizedBillingAccountId, + ); const resolvedActive = resolveBillingAccountActive(billingAccountDetails); - const resolvedEndDate = - normalizeOptionalString(_.get(billingAccountDetails, "endDate")); + const resolvedEndDate = normalizeOptionalString(_.get(billingAccountDetails, "endDate")); if (!billingAccountDetails) { throw new errors.BadRequestError( - "Cannot activate challenge because the project billing account could not be found." + "Cannot activate challenge because the project billing account could not be found.", ); } if (resolvedActive === false) { throw new errors.BadRequestError( - "Cannot activate challenge because the project billing account is inactive." + "Cannot activate challenge because the project billing account is inactive.", ); } if (isBillingAccountExpired(resolvedActive, resolvedEndDate)) { throw new errors.BadRequestError( - "Cannot activate challenge because the project billing account is expired." + "Cannot activate challenge because the project billing account is expired.", ); } @@ -2570,7 +2577,7 @@ async function validateChallengeActivationBillingAccount({ if (!_.isNil(remainingBudget) && remainingBudget <= 0) { throw new errors.BadRequestError( - "Cannot activate challenge because the project billing account has insufficient remaining funds." + "Cannot activate challenge because the project billing account has insufficient remaining funds.", ); } } diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index 5661a62..adc0d74 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -863,6 +863,30 @@ describe("challenge service unit tests", () => { should.equal(result.result.length, 0); }); + it("search challenges by name case-insensitively", async () => { + const result = await service.searchChallenges( + { isMachine: true }, + { name: data.challenge.name.toLowerCase() }, + ); + + should.equal(result.total, 1); + should.equal(result.result.length, 1); + should.equal(result.result[0].id, data.challenge.id); + should.equal(result.result[0].name, data.challenge.name); + }); + + it("search challenges by search term case-insensitively for challenge names", async () => { + const result = await service.searchChallenges( + { isMachine: true }, + { search: data.challenge.name.toLowerCase() }, + ); + + should.equal(result.total, 1); + should.equal(result.result.length, 1); + should.equal(result.result[0].id, data.challenge.id); + should.equal(result.result[0].name, data.challenge.name); + }); + it("search challenges successfully 3", async () => { const res = await service.searchChallenges( { isMachine: true },