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/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/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", 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 abe318e..d10d873 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/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); diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index f741e08..c169b35 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -3686,10 +3686,63 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { } } + // 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') { + 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 + 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'; + // 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) && @@ -3698,11 +3751,20 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { ) { if ( finalStatus !== ChallengeStatusEnum.NEW && - finalTimelineTemplateId !== challenge.timelineTemplateId + finalTimelineTemplateId !== challenge.timelineTemplateId && + !isAiOnlyTemplateSwitch && + !isAiOnlyTemplateRevert ) { throw new errors.BadRequestError( `Cannot change the timelineTemplateId for challenges with status: ${finalStatus}`, ); + } 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; } } else if (finalTimelineTemplateId !== challenge.timelineTemplateId) { // make sure there are no previous phases if the timeline template has changed @@ -3798,9 +3860,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 @@ -4042,77 +4107,89 @@ 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 { + // 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 + } + + 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(", ")}`, + ); + } } }