From bf6878b4bc760588ac4516402503b3a61e80fd71 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 25 May 2026 22:07:27 +0300 Subject: [PATCH 1/7] PM-5015 - ai only review mode --- src/services/ChallengeService.js | 139 +++++++++++++++++-------------- 1 file changed, 75 insertions(+), 64 deletions(-) diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index be77a0e..d70b494 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -4019,77 +4019,88 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { !isStandardTaskType && (challenge.status === ChallengeStatusEnum.NEW || challenge.status === ChallengeStatusEnum.DRAFT) ) { - const effectiveReviewers = Array.isArray(data.reviewers) - ? data.reviewers - : Array.isArray(challenge.reviewers) - ? challenge.reviewers - : []; - - const reviewersMissingFields = []; - effectiveReviewers.forEach((reviewer, index) => { - const hasScorecardId = - reviewer && !_.isNil(reviewer.scorecardId) && String(reviewer.scorecardId).trim() !== ""; - const hasPhaseId = - reviewer && !_.isNil(reviewer.phaseId) && String(reviewer.phaseId).trim() !== ""; - - if (!hasScorecardId || !hasPhaseId) { - const missing = []; - if (!hasScorecardId) missing.push("scorecardId"); - if (!hasPhaseId) missing.push("phaseId"); - reviewersMissingFields.push(`reviewer[${index}] missing ${missing.join(" and ")}`); - } - }); - - if (reviewersMissingFields.length > 0) { - throw new errors.BadRequestError( - `Cannot activate challenge; reviewers are missing required fields: ${reviewersMissingFields.join( - "; ", - )}`, - ); - } + // For AI_ONLY review mode, manual reviewers are not required; skip validation + let isAiOnlyReviewMode = false; + try { + const activationAiConfig = await helper.getAIReviewConfigByChallengeId(challengeId); + isAiOnlyReviewMode = activationAiConfig?.mode === 'AI_ONLY'; + } catch (_err) { + // non-fatal: proceed with standard reviewer validation if AI config fetch fails + } + + if (!isAiOnlyReviewMode) { + const effectiveReviewers = Array.isArray(data.reviewers) + ? data.reviewers + : Array.isArray(challenge.reviewers) + ? challenge.reviewers + : []; + + const reviewersMissingFields = []; + effectiveReviewers.forEach((reviewer, index) => { + const hasScorecardId = + reviewer && !_.isNil(reviewer.scorecardId) && String(reviewer.scorecardId).trim() !== ""; + const hasPhaseId = + reviewer && !_.isNil(reviewer.phaseId) && String(reviewer.phaseId).trim() !== ""; + + if (!hasScorecardId || !hasPhaseId) { + const missing = []; + if (!hasScorecardId) missing.push("scorecardId"); + if (!hasPhaseId) missing.push("phaseId"); + reviewersMissingFields.push(`reviewer[${index}] missing ${missing.join(" and ")}`); + } + }); - const reviewerPhaseIds = new Set( - effectiveReviewers - .filter((reviewer) => reviewer && reviewer.phaseId) - .map((reviewer) => String(reviewer.phaseId)), - ); + if (reviewersMissingFields.length > 0) { + throw new errors.BadRequestError( + `Cannot activate challenge; reviewers are missing required fields: ${reviewersMissingFields.join( + "; ", + )}`, + ); + } - if (reviewerPhaseIds.size === 0) { - throw new errors.BadRequestError( - "Cannot activate a challenge without at least one reviewer configured", + const reviewerPhaseIds = new Set( + effectiveReviewers + .filter((reviewer) => reviewer && reviewer.phaseId) + .map((reviewer) => String(reviewer.phaseId)), ); - } - const normalizePhaseName = (name) => - String(name || "") - .trim() - .toLowerCase(); - const effectivePhases = - (Array.isArray(phasesForUpdate) && phasesForUpdate.length > 0 - ? phasesForUpdate - : challenge.phases) || []; - - const missingPhaseNames = new Set(); - for (const phase of effectivePhases) { - if (!phase) { - continue; - } - const normalizedName = normalizePhaseName(phase.name); - if (!REQUIRED_REVIEW_PHASE_NAME_SET.has(normalizedName)) { - continue; + if (reviewerPhaseIds.size === 0) { + throw new errors.BadRequestError( + "Cannot activate a challenge without at least one reviewer configured", + ); } - const phaseId = _.get(phase, "phaseId"); - if (!phaseId || !reviewerPhaseIds.has(String(phaseId))) { - missingPhaseNames.add(phase.name || "Unknown phase"); + + const normalizePhaseName = (name) => + String(name || "") + .trim() + .toLowerCase(); + const effectivePhases = + (Array.isArray(phasesForUpdate) && phasesForUpdate.length > 0 + ? phasesForUpdate + : challenge.phases) || []; + + const missingPhaseNames = new Set(); + for (const phase of effectivePhases) { + if (!phase) { + continue; + } + const normalizedName = normalizePhaseName(phase.name); + if (!REQUIRED_REVIEW_PHASE_NAME_SET.has(normalizedName)) { + continue; + } + const phaseId = _.get(phase, "phaseId"); + if (!phaseId || !reviewerPhaseIds.has(String(phaseId))) { + missingPhaseNames.add(phase.name || "Unknown phase"); + } } - } - if (missingPhaseNames.size > 0) { - throw new errors.BadRequestError( - `Cannot activate challenge; missing reviewers for phase(s): ${Array.from( - missingPhaseNames, - ).join(", ")}`, - ); + if (missingPhaseNames.size > 0) { + throw new errors.BadRequestError( + `Cannot activate challenge; missing reviewers for phase(s): ${Array.from( + missingPhaseNames, + ).join(", ")}`, + ); + } } } From e61fcfdc6ebc909bc3a1ec61b899ae120a25befc Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 25 May 2026 22:07:32 +0300 Subject: [PATCH 2/7] deploy dev --- .circleci/config.yml | 2 ++ package.json | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 632368c..d88d36e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -93,6 +93,8 @@ workflows: - PM-4478_add-ai-screening-phase-when-editing-after-launch - review-context - PM-4684_challenge-approval-flow + tags: + only: /^dev-.*/ - "build-qa": context: org-global diff --git a/package.json b/package.json index 491f3d1..3f70374 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "e2e:cov": "nyc --reporter=html --reporter=text npm run e2e", "services:up": "docker-compose -f ./local/docker-compose.yml up -d", "services:down": "docker-compose -f ./local/docker-compose.yml down", - "services:logs": "docker-compose -f ./local/docker-compose.yml logs" + "services:logs": "docker-compose -f ./local/docker-compose.yml logs", + "deploy:dev": "BRANCH=$(git rev-parse --abbrev-ref HEAD) && TAG=\"dev-${BRANCH}\" && git tag -d \"$TAG\" 2>/dev/null; git push origin \":refs/tags/$TAG\" 2>/dev/null; git tag \"$TAG\" && git push origin \"$TAG\"" }, "author": "TCSCODER", "license": "MIT", From 455014ffc4bf99edd1c79eeafc287bc3f17887e8 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 25 May 2026 22:17:51 +0300 Subject: [PATCH 3/7] skip review phase when ai only mode is on --- src/services/ChallengePhaseService.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/services/ChallengePhaseService.js b/src/services/ChallengePhaseService.js index e7857f0..09c0613 100644 --- a/src/services/ChallengePhaseService.js +++ b/src/services/ChallengePhaseService.js @@ -541,6 +541,18 @@ async function ensureRequiredResourcesBeforeOpeningPhase(challenge, phaseName) { return; } + // For AI_ONLY review mode, no human Reviewer resources are required + if (normalizedPhaseName === "review") { + try { + const aiReviewConfig = await helper.getAIReviewConfigByChallengeId(challenge.id); + if (aiReviewConfig?.mode === "AI_ONLY") { + return; + } + } catch (_err) { + // non-fatal: proceed with standard check if AI config fetch fails + } + } + const challengeId = challenge.id; const challengeResources = await helper.getChallengeResources(challengeId); const requiredRoleNameLower = _.toLower(requiredRoleName); From e6d9c6b9f0ad5826ce80d63ce456c41d35d4195b Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 26 May 2026 13:32:55 +0300 Subject: [PATCH 4/7] timeline template for ai-only mode --- config/default.js | 4 + .../migration.sql | 118 ++++++++++++++++++ src/common/challenge-helper.js | 7 ++ src/scripts/seed/Phase.json | 11 ++ src/scripts/seed/TimelineTemplate.json | 29 +++++ src/services/ChallengeService.js | 34 ++++- 6 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260526100000_add_ai_review_phase_and_template/migration.sql diff --git a/config/default.js b/config/default.js index 02388ba..7e48aae 100644 --- a/config/default.js +++ b/config/default.js @@ -93,6 +93,10 @@ module.exports = { .filter(Boolean) : ["80000062"], + // The ID of the AI Only Challenge timeline template (seeded via migration) + AI_ONLY_TIMELINE_TEMPLATE_ID: + process.env.AI_ONLY_TIMELINE_TEMPLATE_ID || "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", + // health check timeout in milliseconds HEALTH_CHECK_TIMEOUT: process.env.HEALTH_CHECK_TIMEOUT || 3000, diff --git a/prisma/migrations/20260526100000_add_ai_review_phase_and_template/migration.sql b/prisma/migrations/20260526100000_add_ai_review_phase_and_template/migration.sql new file mode 100644 index 0000000..5052e57 --- /dev/null +++ b/prisma/migrations/20260526100000_add_ai_review_phase_and_template/migration.sql @@ -0,0 +1,118 @@ +-- Insert AI Review phase +INSERT INTO "Phase" ( + "id", + "name", + "description", + "isOpen", + "duration", + "createdAt", + "createdBy", + "updatedAt", + "updatedBy" +) VALUES ( + 'c3a4d5e6-f7b8-4c9d-a0e1-2b3c4d5e6f7a', + 'AI Review', + 'AI Review Phase', + true, + 86400, + '2025-03-10T13:08:02.378Z', + 'topcoder user', + '2025-03-10T13:08:02.378Z', + 'topcoder user' +) +ON CONFLICT DO NOTHING; + +-- Insert AI Only Challenge timeline template +INSERT INTO "TimelineTemplate" ( + "id", + "name", + "description", + "isActive", + "createdAt", + "createdBy", + "updatedAt", + "updatedBy" +) VALUES ( + 'b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e', + 'AI Only Challenge', + 'AI-Only Challenge Timeline', + true, + '2025-03-10T13:08:02.378Z', + 'topcoder user', + '2025-03-10T13:08:02.378Z', + 'topcoder user' +) +ON CONFLICT DO NOTHING; + +-- Insert TimelineTemplatePhase entries for the AI Only Challenge template +-- Registration phase (no predecessor) +INSERT INTO "TimelineTemplatePhase" ( + "id", + "timelineTemplateId", + "phaseId", + "predecessor", + "defaultDuration", + "createdAt", + "createdBy", + "updatedAt", + "updatedBy" +) VALUES ( + 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', + 'b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e', + 'a93544bc-c165-4af4-b55e-18f3593b457a', + NULL, + 432000, + '2025-03-10T13:08:02.378Z', + 'topcoder user', + '2025-03-10T13:08:02.378Z', + 'topcoder user' +) +ON CONFLICT DO NOTHING; + +-- Submission phase (predecessor: Registration) +INSERT INTO "TimelineTemplatePhase" ( + "id", + "timelineTemplateId", + "phaseId", + "predecessor", + "defaultDuration", + "createdAt", + "createdBy", + "updatedAt", + "updatedBy" +) VALUES ( + 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e', + 'b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e', + '6950164f-3c5e-4bdc-abc8-22aaf5a1bd49', + 'a93544bc-c165-4af4-b55e-18f3593b457a', + 432000, + '2025-03-10T13:08:02.378Z', + 'topcoder user', + '2025-03-10T13:08:02.378Z', + 'topcoder user' +) +ON CONFLICT DO NOTHING; + +-- AI Review phase (predecessor: Submission) +INSERT INTO "TimelineTemplatePhase" ( + "id", + "timelineTemplateId", + "phaseId", + "predecessor", + "defaultDuration", + "createdAt", + "createdBy", + "updatedAt", + "updatedBy" +) VALUES ( + 'c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f', + 'b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e', + 'c3a4d5e6-f7b8-4c9d-a0e1-2b3c4d5e6f7a', + '6950164f-3c5e-4bdc-abc8-22aaf5a1bd49', + 86400, + '2025-03-10T13:08:02.378Z', + 'topcoder user', + '2025-03-10T13:08:02.378Z', + 'topcoder user' +) +ON CONFLICT DO NOTHING; diff --git a/src/common/challenge-helper.js b/src/common/challenge-helper.js index 896f689..d582853 100644 --- a/src/common/challenge-helper.js +++ b/src/common/challenge-helper.js @@ -536,6 +536,13 @@ class ChallengeHelper { return; } + // If the challenge already has an AI Review phase (AI-only template), do not add AI Screening + const hasAIReviewPhase = challenge.phases.some((phase) => phase.name === "AI Review"); + if (hasAIReviewPhase) { + logDebugMessage("challenge has an AI Review phase; skipping AI Screening insertion"); + return; + } + // Find the regular submission phase const submissionPhaseName = SUBMISSION_PHASE_PRIORITY.find((name) => challenge.phases.some((phase) => phase.name === name) diff --git a/src/scripts/seed/Phase.json b/src/scripts/seed/Phase.json index 49404ed..9db1631 100644 --- a/src/scripts/seed/Phase.json +++ b/src/scripts/seed/Phase.json @@ -218,5 +218,16 @@ "createdBy": "topcoder user", "updatedAt": "2025-03-10T13:08:02.378Z", "updatedBy": "topcoder user" + }, + { + "id": "c3a4d5e6-f7b8-4c9d-a0e1-2b3c4d5e6f7a", + "name": "AI Review", + "description": "AI Review Phase", + "isOpen": true, + "duration": 86400, + "createdAt": "2025-03-10T13:08:02.378Z", + "createdBy": "topcoder user", + "updatedAt": "2025-03-10T13:08:02.378Z", + "updatedBy": "topcoder user" } ] diff --git a/src/scripts/seed/TimelineTemplate.json b/src/scripts/seed/TimelineTemplate.json index 6549452..1885678 100644 --- a/src/scripts/seed/TimelineTemplate.json +++ b/src/scripts/seed/TimelineTemplate.json @@ -407,5 +407,34 @@ "createdBy": "topcoder user", "updatedAt": "2025-03-10T13:08:02.378Z", "updatedBy": "topcoder user" + }, + { + "id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", + "name": "AI Only Challenge", + "description": "AI-Only Challenge Timeline", + "isActive": true, + "phases": [ + { + "phaseId": "a93544bc-c165-4af4-b55e-18f3593b457a", + "defaultDuration": 432000, + "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" + }, + { + "phaseId": "6950164f-3c5e-4bdc-abc8-22aaf5a1bd49", + "defaultDuration": 432000, + "predecessor": "a93544bc-c165-4af4-b55e-18f3593b457a", + "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e" + }, + { + "phaseId": "c3a4d5e6-f7b8-4c9d-a0e1-2b3c4d5e6f7a", + "defaultDuration": 86400, + "predecessor": "6950164f-3c5e-4bdc-abc8-22aaf5a1bd49", + "id": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f" + } + ], + "createdAt": "2025-03-10T13:08:02.378Z", + "createdBy": "topcoder user", + "updatedAt": "2025-03-10T13:08:02.378Z", + "updatedBy": "topcoder user" } ] \ No newline at end of file diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index d70b494..7b75d57 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -3663,10 +3663,34 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { } } + // If activating an AI_ONLY challenge, auto-select the AI Only timeline template + let cachedActivationAiConfig = null; + if (isStatusChangingToActive) { + try { + cachedActivationAiConfig = await helper.getAIReviewConfigByChallengeId(challengeId); + if (cachedActivationAiConfig?.mode === 'AI_ONLY') { + const currentTemplateId = data.timelineTemplateId || challenge.timelineTemplateId; + if (currentTemplateId !== config.AI_ONLY_TIMELINE_TEMPLATE_ID) { + logger.debug( + `updateChallenge: AI_ONLY mode detected, switching to AI Only timeline template (challengeId=${challengeId})`, + ); + data.timelineTemplateId = config.AI_ONLY_TIMELINE_TEMPLATE_ID; + } + } + } catch (_err) { + // non-fatal: if AI config fetch fails, proceed without template override + logger.debug( + `updateChallenge: failed to fetch AI review config for template auto-select (challengeId=${challengeId}): ${_err.message}`, + ); + } + } + // TODO: Fix this Tech Debt once legacy is turned off const finalStatus = data.status || challenge.status; const finalTimelineTemplateId = data.timelineTemplateId || challenge.timelineTemplateId; let timelineTemplateChanged = false; + const isAiOnlyTemplateSwitch = + isStatusChangingToActive && cachedActivationAiConfig?.mode === 'AI_ONLY'; if ( !currentUser.isMachine && !hasAdminRole(currentUser) && @@ -3675,11 +3699,16 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { ) { if ( finalStatus !== ChallengeStatusEnum.NEW && - finalTimelineTemplateId !== challenge.timelineTemplateId + finalTimelineTemplateId !== challenge.timelineTemplateId && + !isAiOnlyTemplateSwitch ) { throw new errors.BadRequestError( `Cannot change the timelineTemplateId for challenges with status: ${finalStatus}`, ); + } else if (isAiOnlyTemplateSwitch && finalTimelineTemplateId !== challenge.timelineTemplateId) { + // AI Only template auto-switch: clear existing phases so they are re-populated from the new template + challenge.phases = []; + timelineTemplateChanged = true; } } else if (finalTimelineTemplateId !== challenge.timelineTemplateId) { // make sure there are no previous phases if the timeline template has changed @@ -4022,7 +4051,8 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { // For AI_ONLY review mode, manual reviewers are not required; skip validation let isAiOnlyReviewMode = false; try { - const activationAiConfig = await helper.getAIReviewConfigByChallengeId(challengeId); + // Reuse the config fetched earlier for template auto-select if available + const activationAiConfig = cachedActivationAiConfig ?? await helper.getAIReviewConfigByChallengeId(challengeId); isAiOnlyReviewMode = activationAiConfig?.mode === 'AI_ONLY'; } catch (_err) { // non-fatal: proceed with standard reviewer validation if AI config fetch fails From 3f2a1c4c3fcdfd127d0057ed26b1c33a04c279a6 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 27 May 2026 12:11:06 +0300 Subject: [PATCH 5/7] AI only - skip ai screening phase creation --- src/services/ChallengeService.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 7b75d57..806b43e 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -3804,9 +3804,12 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { !hadAIReviewersBeforeUpdate && hasAIReviewersAfterUpdate; logger.debug(`updateChallenge: isActiveWithNewAIReviewers=${isActiveWithNewAIReviewers}`); - const shouldEnsureAIScreeningPhase = isStatusChangingToActive || isActiveWithNewAIReviewers; + // AI_ONLY challenges use the AI Review phase instead of AI Screening; never add AI Screening for them + const isAiOnlyActivation = isStatusChangingToActive && cachedActivationAiConfig?.mode === 'AI_ONLY'; + const shouldEnsureAIScreeningPhase = + (isStatusChangingToActive && !isAiOnlyActivation) || isActiveWithNewAIReviewers; logger.debug( - `updateChallenge: shouldEnsureAIScreeningPhase=${shouldEnsureAIScreeningPhase} isStatusChangingToActive=${isStatusChangingToActive}`, + `updateChallenge: shouldEnsureAIScreeningPhase=${shouldEnsureAIScreeningPhase} isStatusChangingToActive=${isStatusChangingToActive} isAiOnlyActivation=${isAiOnlyActivation}`, ); // Add AI screening phase when activating a challenge, or when AI reviewers are newly added From 94b3b6ca944c2038b361cd9d25e16e1a1b1ed579 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 28 May 2026 10:42:33 +0300 Subject: [PATCH 6/7] Save timeline on draft --- src/services/ChallengeService.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 9dee02d..fca6f37 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -3686,9 +3686,12 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { } } - // If activating an AI_ONLY challenge, auto-select the AI Only timeline template + // Auto-select the AI Only timeline template for AI_ONLY challenges. + // This runs on draft saves (status=NEW) as well as at activation so the correct + // template is persisted as early as possible. + const isDraftSave = [ChallengeStatusEnum.NEW, ChallengeStatusEnum.DRAFT].includes(challenge.status) && !isStatusChangingToActive; let cachedActivationAiConfig = null; - if (isStatusChangingToActive) { + if (isStatusChangingToActive || isDraftSave) { try { cachedActivationAiConfig = await helper.getAIReviewConfigByChallengeId(challengeId); if (cachedActivationAiConfig?.mode === 'AI_ONLY') { From 502c9633e396fef77fe30a4a9118aa793e70633a Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 28 May 2026 12:07:19 +0300 Subject: [PATCH 7/7] PM-5015 - make sure to select ai-only timeline or revert back depending on ai config --- src/services/ChallengeService.js | 44 +++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index fca6f37..c169b35 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -3686,22 +3686,41 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { } } - // Auto-select the AI Only timeline template for AI_ONLY challenges. - // This runs on draft saves (status=NEW) as well as at activation so the correct - // template is persisted as early as possible. + // Auto-select the AI Only timeline template for AI_ONLY challenges, and revert to the + // default template when the AI_ONLY config is removed. Runs on draft saves and activation. const isDraftSave = [ChallengeStatusEnum.NEW, ChallengeStatusEnum.DRAFT].includes(challenge.status) && !isStatusChangingToActive; let cachedActivationAiConfig = null; + let aiConfigFetched = false; if (isStatusChangingToActive || isDraftSave) { try { cachedActivationAiConfig = await helper.getAIReviewConfigByChallengeId(challengeId); + aiConfigFetched = true; + const currentTemplateId = data.timelineTemplateId || challenge.timelineTemplateId; if (cachedActivationAiConfig?.mode === 'AI_ONLY') { - const currentTemplateId = data.timelineTemplateId || challenge.timelineTemplateId; if (currentTemplateId !== config.AI_ONLY_TIMELINE_TEMPLATE_ID) { logger.debug( `updateChallenge: AI_ONLY mode detected, switching to AI Only timeline template (challengeId=${challengeId})`, ); data.timelineTemplateId = config.AI_ONLY_TIMELINE_TEMPLATE_ID; } + } else if (isDraftSave && currentTemplateId === config.AI_ONLY_TIMELINE_TEMPLATE_ID) { + // AI_ONLY config was removed; revert to the default template for this challenge's type+track + const defaultTemplates = await ChallengeTimelineTemplateService.searchChallengeTimelineTemplates({ + typeId: challenge.typeId, + trackId: challenge.trackId, + isDefault: true, + }); + const defaultTemplate = defaultTemplates.result[0]; + if (defaultTemplate) { + logger.debug( + `updateChallenge: AI_ONLY config removed, reverting to default timeline template ${defaultTemplate.timelineTemplateId} (challengeId=${challengeId})`, + ); + data.timelineTemplateId = defaultTemplate.timelineTemplateId; + } else { + logger.debug( + `updateChallenge: AI_ONLY config removed but no default template found for typeId=${challenge.typeId} trackId=${challenge.trackId}; keeping current template (challengeId=${challengeId})`, + ); + } } } catch (_err) { // non-fatal: if AI config fetch fails, proceed without template override @@ -3717,6 +3736,13 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { let timelineTemplateChanged = false; const isAiOnlyTemplateSwitch = isStatusChangingToActive && cachedActivationAiConfig?.mode === 'AI_ONLY'; + // True when the AI_ONLY config was removed and we are auto-reverting the template back to default. + // Requires a confirmed fetch (aiConfigFetched) so we don't revert on transient API failures. + const isAiOnlyTemplateRevert = + aiConfigFetched && + isDraftSave && + challenge.timelineTemplateId === config.AI_ONLY_TIMELINE_TEMPLATE_ID && + cachedActivationAiConfig?.mode !== 'AI_ONLY'; if ( !currentUser.isMachine && !hasAdminRole(currentUser) && @@ -3726,13 +3752,17 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { if ( finalStatus !== ChallengeStatusEnum.NEW && finalTimelineTemplateId !== challenge.timelineTemplateId && - !isAiOnlyTemplateSwitch + !isAiOnlyTemplateSwitch && + !isAiOnlyTemplateRevert ) { throw new errors.BadRequestError( `Cannot change the timelineTemplateId for challenges with status: ${finalStatus}`, ); - } else if (isAiOnlyTemplateSwitch && finalTimelineTemplateId !== challenge.timelineTemplateId) { - // AI Only template auto-switch: clear existing phases so they are re-populated from the new template + } else if ( + (isAiOnlyTemplateSwitch || isAiOnlyTemplateRevert) && + finalTimelineTemplateId !== challenge.timelineTemplateId + ) { + // Auto-managed template change: clear existing phases so they are re-populated from the new template challenge.phases = []; timelineTemplateChanged = true; }