From ff0752207c6b7f1bee40785c214dfc9d9b32cdfe Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 19 May 2026 10:52:33 +1000 Subject: [PATCH 01/18] Stalled flag to challenges --- docs/swagger.yaml | 3 + src/common/challenge-helper.js | 120 +++++++++++++++++++++++++- test/unit/challenge-helper.test.js | 131 +++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 test/unit/challenge-helper.test.js diff --git a/docs/swagger.yaml b/docs/swagger.yaml index c3706ee..b0c7b05 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2707,6 +2707,9 @@ definitions: type: array items: type: string + stalled: + type: boolean + description: Flag indicating the challenge has no open phase after a previous phase opened and closed, and a due successor phase has not opened. registrationStartDate: type: string format: date-time diff --git a/src/common/challenge-helper.js b/src/common/challenge-helper.js index 896f689..0109f73 100644 --- a/src/common/challenge-helper.js +++ b/src/common/challenge-helper.js @@ -789,7 +789,9 @@ class ChallengeHelper { /** * Enrich challenge for API responses. Normalizes dates, phases and ensures - * `track` and `type` fields have a consistent shape. + * `track` and `type` fields have a consistent shape. Adds `stalled` to show + * whether an active challenge has stopped between a closed phase and a due + * successor phase that has not opened. * * By default, `track` and `type` are returned as objects: * track: { id, name, track } @@ -846,6 +848,8 @@ class ChallengeHelper { } } + challenge.stalled = ChallengeHelper.isChallengeStalled(challenge); + if (challenge.created) challenge.created = ChallengeHelper.convertDateToISOString(challenge.created); if (challenge.updated) @@ -892,6 +896,120 @@ class ChallengeHelper { } } + /** + * Determines whether a challenge has stalled between phases. + * + * A stalled challenge must be active, have no currently open phase, have at + * least one phase that actually opened and closed, and have a successor of a + * closed phase that is due to open but has no actual start date. Successors + * may reference either the predecessor challenge phase id or phase definition + * id because both forms exist in stored phase chains. Used by + * enrichChallengeForResponse to populate the response flag. Does not throw. + * + * @param {Object} challenge challenge response data with phases + * @returns {Boolean} true when the challenge is stalled, false otherwise + */ + static isChallengeStalled(challenge) { + if (_.get(challenge, "status") !== ChallengeStatusEnum.ACTIVE) { + return false; + } + + const phases = _.get(challenge, "phases", []); + if (!Array.isArray(phases) || phases.length === 0) { + return false; + } + + if (_.some(phases, (phase) => phase && phase.isOpen === true)) { + return false; + } + + const closedPhaseIdentifiers = new Set(); + _.forEach(phases, (phase) => { + if (ChallengeHelper.hasPhaseOpenedAndClosed(phase)) { + _.forEach(ChallengeHelper.getPhaseIdentifiers(phase), (identifier) => { + closedPhaseIdentifiers.add(identifier); + }); + } + }); + + if (closedPhaseIdentifiers.size === 0) { + return false; + } + + return _.some(phases, (phase) => { + if (!phase || _.isNil(phase.predecessor)) { + return false; + } + if (!closedPhaseIdentifiers.has(String(phase.predecessor))) { + return false; + } + if (phase.isOpen === true || !_.isNil(phase.actualStartDate)) { + return false; + } + return ChallengeHelper.isPhaseDueToOpen(phase); + }); + } + + /** + * Checks whether a phase actually opened and later closed. Used internally + * by isChallengeStalled. Does not throw. + * + * @param {Object} phase challenge phase response data + * @returns {Boolean} true when actual start and end dates are set and the phase is not open + */ + static hasPhaseOpenedAndClosed(phase) { + return ( + phase && + phase.isOpen !== true && + !_.isNil(phase.actualStartDate) && + !_.isNil(phase.actualEndDate) + ); + } + + /** + * Gets identifiers that successor phases may store in their predecessor field. + * Used internally by isChallengeStalled. Does not throw. + * + * @param {Object} phase challenge phase response data + * @returns {String[]} string identifiers for the challenge phase id and phase definition id + */ + static getPhaseIdentifiers(phase) { + return _.compact([phase.id, phase.phaseId]).map((identifier) => String(identifier)); + } + + /** + * Determines whether a successor phase is due to open. + * + * Missing or invalid scheduled start dates are treated as due because a closed + * predecessor means the next phase has no scheduled future start to wait for. + * Used internally by isChallengeStalled. Does not throw. + * + * @param {Object} phase challenge phase response data + * @returns {Boolean} true when scheduledStartDate is absent, invalid, or not in the future + */ + static isPhaseDueToOpen(phase) { + const scheduledStartTime = ChallengeHelper.getDateTime(phase.scheduledStartDate); + if (_.isNil(scheduledStartTime)) { + return true; + } + return scheduledStartTime <= Date.now(); + } + + /** + * Converts a date-like value to epoch milliseconds. Used internally by + * isPhaseDueToOpen. Does not throw. + * + * @param {Date|String|Number} value date-like value + * @returns {Number|null} epoch milliseconds, or null when the value cannot be parsed + */ + static getDateTime(value) { + if (_.isNil(value)) { + return null; + } + const time = value instanceof Date ? value.getTime() : new Date(value).getTime(); + return Number.isFinite(time) ? time : null; + } + static convertDateToISOString(startDate) { if (startDate instanceof Date) { return startDate.toISOString(); diff --git a/test/unit/challenge-helper.test.js b/test/unit/challenge-helper.test.js new file mode 100644 index 0000000..2288664 --- /dev/null +++ b/test/unit/challenge-helper.test.js @@ -0,0 +1,131 @@ +require("../../app-bootstrap"); + +const { expect } = require("chai"); +const { ChallengeStatusEnum } = require("@prisma/client"); +const challengeHelper = require("../../src/common/challenge-helper"); + +describe("challenge response helper", () => { + function buildChallenge(phases) { + return { + status: ChallengeStatusEnum.ACTIVE, + phases, + }; + } + + function enrich(challenge) { + challengeHelper.enrichChallengeForResponse(challenge); + return challenge; + } + + it("marks an active challenge as stalled when a due successor phase has not opened", () => { + const challenge = buildChallenge([ + { + id: "registration-challenge-phase", + phaseId: "registration-phase", + name: "Registration", + isOpen: false, + actualStartDate: "2000-01-01T00:00:00.000Z", + actualEndDate: "2000-01-02T00:00:00.000Z", + }, + { + id: "submission-challenge-phase", + phaseId: "submission-phase", + name: "Submission", + predecessor: "registration-phase", + isOpen: false, + actualStartDate: "2000-01-02T00:00:00.000Z", + actualEndDate: "2000-01-03T00:00:00.000Z", + }, + { + id: "review-challenge-phase", + phaseId: "review-phase", + name: "Review", + predecessor: "submission-phase", + isOpen: false, + scheduledStartDate: "2000-01-03T00:00:00.000Z", + }, + ]); + + expect(enrich(challenge).stalled).to.equal(true); + }); + + it("does not mark a challenge as stalled while any phase is open", () => { + const challenge = buildChallenge([ + { + id: "registration-challenge-phase", + phaseId: "registration-phase", + name: "Registration", + isOpen: false, + actualStartDate: "2000-01-01T00:00:00.000Z", + actualEndDate: "2000-01-02T00:00:00.000Z", + }, + { + id: "submission-challenge-phase", + phaseId: "submission-phase", + name: "Submission", + predecessor: "registration-phase", + isOpen: true, + actualStartDate: "2000-01-02T00:00:00.000Z", + }, + { + id: "review-challenge-phase", + phaseId: "review-phase", + name: "Review", + predecessor: "submission-phase", + isOpen: false, + scheduledStartDate: "2000-01-03T00:00:00.000Z", + }, + ]); + + expect(enrich(challenge).stalled).to.equal(false); + }); + + it("does not mark a challenge as stalled before the successor phase is due", () => { + const challenge = buildChallenge([ + { + id: "submission-challenge-phase", + phaseId: "submission-phase", + name: "Submission", + isOpen: false, + actualStartDate: "2000-01-01T00:00:00.000Z", + actualEndDate: "2000-01-02T00:00:00.000Z", + }, + { + id: "review-challenge-phase", + phaseId: "review-phase", + name: "Review", + predecessor: "submission-challenge-phase", + isOpen: false, + scheduledStartDate: "2999-01-01T00:00:00.000Z", + }, + ]); + + expect(enrich(challenge).stalled).to.equal(false); + }); + + it("does not mark non-active challenges as stalled", () => { + const challenge = { + status: ChallengeStatusEnum.COMPLETED, + phases: [ + { + id: "submission-challenge-phase", + phaseId: "submission-phase", + name: "Submission", + isOpen: false, + actualStartDate: "2000-01-01T00:00:00.000Z", + actualEndDate: "2000-01-02T00:00:00.000Z", + }, + { + id: "review-challenge-phase", + phaseId: "review-phase", + name: "Review", + predecessor: "submission-phase", + isOpen: false, + scheduledStartDate: "2000-01-02T00:00:00.000Z", + }, + ], + }; + + expect(enrich(challenge).stalled).to.equal(false); + }); +}); From 43cf375ea32fe5dab7a101d5b6bd86a1bb7603bd Mon Sep 17 00:00:00 2001 From: jmgasper Date: Thu, 21 May 2026 10:23:41 +1000 Subject: [PATCH 02/18] License file --- LICENSE | 202 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From bf6878b4bc760588ac4516402503b3a61e80fd71 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 25 May 2026 22:07:27 +0300 Subject: [PATCH 03/18] 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 04/18] 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 05/18] 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 06/18] 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 07/18] 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 08/18] 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 09/18] 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; } From 161816304071078390493a614379417408bf2b4d Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 2 Jun 2026 08:24:24 +0300 Subject: [PATCH 10/18] Insert approval phase in AI-only timeline template --- .../migration.sql | 4 +-- .../migration.sql | 25 +++++++++++++++++++ src/scripts/seed/TimelineTemplate.json | 1 - 3 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260530120000_add_approval_phase_to_ai_only_template/migration.sql 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 index 5052e57..7723637 100644 --- a/prisma/migrations/20260526100000_add_ai_review_phase_and_template/migration.sql +++ b/prisma/migrations/20260526100000_add_ai_review_phase_and_template/migration.sql @@ -69,7 +69,7 @@ INSERT INTO "TimelineTemplatePhase" ( ) ON CONFLICT DO NOTHING; --- Submission phase (predecessor: Registration) +-- Submission phase (no predecessor — runs in parallel with Registration) INSERT INTO "TimelineTemplatePhase" ( "id", "timelineTemplateId", @@ -84,7 +84,7 @@ INSERT INTO "TimelineTemplatePhase" ( 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e', 'b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e', '6950164f-3c5e-4bdc-abc8-22aaf5a1bd49', - 'a93544bc-c165-4af4-b55e-18f3593b457a', + NULL, 432000, '2025-03-10T13:08:02.378Z', 'topcoder user', diff --git a/prisma/migrations/20260530120000_add_approval_phase_to_ai_only_template/migration.sql b/prisma/migrations/20260530120000_add_approval_phase_to_ai_only_template/migration.sql new file mode 100644 index 0000000..800e01c --- /dev/null +++ b/prisma/migrations/20260530120000_add_approval_phase_to_ai_only_template/migration.sql @@ -0,0 +1,25 @@ +-- Add Approval phase to AI Only Challenge timeline template +-- Approval phase (predecessor: AI Review) +-- Default duration: 43200 seconds (12 hours), configurable +INSERT INTO "TimelineTemplatePhase" ( + "id", + "timelineTemplateId", + "phaseId", + "predecessor", + "defaultDuration", + "createdAt", + "createdBy", + "updatedAt", + "updatedBy" +) VALUES ( + 'd4e5f6a7-b8c9-4d0e-1f2a-3b4c5d6e7f8a', + 'b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e', + 'ad985cff-ad3e-44de-b54e-3992505ba0ae', + 'c3a4d5e6-f7b8-4c9d-a0e1-2b3c4d5e6f7a', + 43200, + '2025-03-10T13:08:02.378Z', + 'topcoder user', + '2025-03-10T13:08:02.378Z', + 'topcoder user' +) +ON CONFLICT DO NOTHING; diff --git a/src/scripts/seed/TimelineTemplate.json b/src/scripts/seed/TimelineTemplate.json index 1885678..5632e5e 100644 --- a/src/scripts/seed/TimelineTemplate.json +++ b/src/scripts/seed/TimelineTemplate.json @@ -422,7 +422,6 @@ { "phaseId": "6950164f-3c5e-4bdc-abc8-22aaf5a1bd49", "defaultDuration": 432000, - "predecessor": "a93544bc-c165-4af4-b55e-18f3593b457a", "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e" }, { From d9d0a76da8a61cd04fa7841813c5f4ed7e3564f8 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 3 Jun 2026 12:25:53 +0300 Subject: [PATCH 11/18] Fix switch to ai only template id for non-admins --- src/services/ChallengeService.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index c169b35..60a06b4 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -3734,8 +3734,7 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { const finalStatus = data.status || challenge.status; const finalTimelineTemplateId = data.timelineTemplateId || challenge.timelineTemplateId; let timelineTemplateChanged = false; - const isAiOnlyTemplateSwitch = - isStatusChangingToActive && cachedActivationAiConfig?.mode === 'AI_ONLY'; + const isAiOnlyTemplateSwitch = 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 = From 34724bb20001b8176777004d42781a56bc108879 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 3 Jun 2026 17:02:55 +0300 Subject: [PATCH 12/18] PM-5203 - prevent closing ai review phase if there are running ai workflows --- src/phase-management/PhaseAdvancer.js | 65 ++++++++++++++ .../phase-management/PhaseAdvancer.test.js | 86 +++++++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/src/phase-management/PhaseAdvancer.js b/src/phase-management/PhaseAdvancer.js index 110bc29..610e302 100644 --- a/src/phase-management/PhaseAdvancer.js +++ b/src/phase-management/PhaseAdvancer.js @@ -122,6 +122,34 @@ class PhaseAdvancer { throw new errors.BadRequestError(`Phase ${targetPhaseName} not found or already closed`); } + // Block closing AI Review phase if there are pending AI decisions + if (operation === "close" && phase.name === "AI Review") { + const pendingAiDecisions = await this.#getPendingAiDecisionsCount(challengeId); + if (pendingAiDecisions > 0) { + console.log( + `Blocked closing AI Review phase for challenge ${challengeId}: ${pendingAiDecisions} pending AI decision(s)` + ); + return { + success: false, + message: `Cannot close AI Review phase: ${pendingAiDecisions} pending AI decision(s) not yet completed`, + detail: "Pending AI decisions must be finalized before closing AI Review phase", + failureReasons: [ + { + rule: "AI Review Phase Guard", + failedConditions: [ + { + fact: "pendingAiDecisions", + operator: "equal", + value: 0, + actual: pendingAiDecisions, + }, + ], + }, + ], + }; + } + } + const rules = this.#collectRules(operation, phase); const facts = await this.#generateFacts(challengeId, legacyId, phases, phase, operation); const validation = await this.#validateRules(rules, facts); @@ -353,6 +381,43 @@ class PhaseAdvancer { return ch?.numOfSubmissions || 0; } + /** + * Counts AI review decisions in PENDING status for active contest submissions. + * Used to block AI Review phase closure until all decisions are finalized. + */ + async #getPendingAiDecisionsCount(challengeId) { + console.log(`Getting pending AI decisions count for challenge ${challengeId}`); + const reviewPrisma = getReviewClient(); + const reviewSchema = config.REVIEW_DB_SCHEMA; + const aiReviewDecisionTable = Prisma.raw(`"${reviewSchema}"."aiReviewDecision"`); + const submissionTable = Prisma.raw(`"${reviewSchema}"."submission"`); + + try { + const [{ count = 0 } = {}] = await reviewPrisma.$queryRaw( + Prisma.sql` + SELECT COUNT(*)::int AS count + FROM ${aiReviewDecisionTable} aid + INNER JOIN ${submissionTable} s + ON s."id" = aid."submissionId" + WHERE s."challengeId" = ${challengeId} + AND (s."status" = 'ACTIVE' OR s."status" IS NULL) + AND ( + s."type" IS NULL + OR UPPER((s."type")::text) = 'CONTEST_SUBMISSION' + ) + AND UPPER((aid."status")::text) = 'PENDING' + ` + ); + const rawCount = Number(count); + return Number.isFinite(rawCount) ? rawCount : 0; + } catch (error) { + console.error( + `Failed to count pending AI decisions for challenge ${challengeId}: ${error.message}` + ); + throw error; + } + } + async #areAllSubmissionsReviewed(challengeId) { console.log(`Evaluating review completion for challenge ${challengeId}`); diff --git a/test/unit/phase-management/PhaseAdvancer.test.js b/test/unit/phase-management/PhaseAdvancer.test.js index b0065a0..71932d0 100644 --- a/test/unit/phase-management/PhaseAdvancer.test.js +++ b/test/unit/phase-management/PhaseAdvancer.test.js @@ -93,6 +93,92 @@ describe("PhaseAdvancer Iterative Review gating", () => { }); }); +describe("PhaseAdvancer AI Review phase closure blocking", () => { + const prisma = getClient(); + const originalFindUnique = prisma.challenge.findUnique; + const originalGetReviewClient = reviewPrisma.getReviewClient; + const phaseAdvancerPath = require.resolve("../../../src/phase-management/PhaseAdvancer"); + + afterEach(() => { + prisma.challenge.findUnique = originalFindUnique; + reviewPrisma.getReviewClient = originalGetReviewClient; + delete require.cache[phaseAdvancerPath]; + }); + + const buildAiReviewPhase = () => ({ + id: "phase-ai-review", + phaseId: "ai-review-phase-id", + name: "AI Review", + description: "AI Review phase", + duration: 86400, + isOpen: true, + predecessor: "submission-phase-id", + scheduledStartDate: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + scheduledEndDate: new Date(Date.now() + 23 * 60 * 60 * 1000).toISOString(), + actualStartDate: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + actualEndDate: null, + constraints: [], + }); + + it("blocks closing AI Review phase when there are pending AI decisions", async () => { + reviewPrisma.getReviewClient = () => ({ + $queryRaw: async () => [{ count: 3 }], + }); + + delete require.cache[phaseAdvancerPath]; + const PhaseAdvancerWithMock = require("../../../src/phase-management/PhaseAdvancer"); + const phaseAdvancer = new PhaseAdvancerWithMock({ + async getPhaseFacts() { + return {}; + }, + }); + + const phases = [buildAiReviewPhase()]; + + const result = await phaseAdvancer.advancePhase( + "challenge-123", + null, + phases, + "close", + "AI Review" + ); + + expect(result.success).to.be.false; + expect(result.message).to.contain("pending AI decision"); + expect(result.message).to.contain("3"); + expect(phases[0].isOpen).to.be.true; + expect(phases[0].actualEndDate).to.be.null; + }); + + it("allows closing AI Review phase when no pending AI decisions", async () => { + reviewPrisma.getReviewClient = () => ({ + $queryRaw: async () => [{ count: 0 }], + }); + + delete require.cache[phaseAdvancerPath]; + const PhaseAdvancerWithMock = require("../../../src/phase-management/PhaseAdvancer"); + const phaseAdvancer = new PhaseAdvancerWithMock({ + async getPhaseFacts() { + return {}; + }, + }); + + const phases = [buildAiReviewPhase()]; + + const result = await phaseAdvancer.advancePhase( + "challenge-123", + null, + phases, + "close", + "AI Review" + ); + + expect(result.success).to.be.true; + expect(phases[0].isOpen).to.be.false; + expect(phases[0].actualEndDate).to.not.be.null; + }); +}); + describe("PhaseAdvancer review completion queries", () => { const prisma = getClient(); const originalFindUnique = prisma.challenge.findUnique; From 0a6934be783e1930ff20dd5d44ac8e8d8e3f5619 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 4 Jun 2026 14:47:58 +0300 Subject: [PATCH 13/18] Make sure ai review phase can't be manually closed when there are pending ai decissiosn --- src/services/ChallengePhaseService.js | 4 ++-- src/services/ChallengeService.js | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/services/ChallengePhaseService.js b/src/services/ChallengePhaseService.js index 09c0613..2cae492 100644 --- a/src/services/ChallengePhaseService.js +++ b/src/services/ChallengePhaseService.js @@ -803,8 +803,8 @@ async function partiallyUpdateChallengePhase(currentUser, challengeId, id, data) ); } - if (normalizedClosingPhaseName === "ai screening") { - await ensureAIScreeningCanBeClosed(challengePhase.challengeId); + if (normalizedClosingPhaseName === "ai screening" || normalizedClosingPhaseName === "ai review") { + await ensureAIPhaseCanBeClosed(challengePhase.challengeId, closingPhaseName); } if (!("actualEndDate" in data) || _.isNil(data.actualEndDate)) { diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 60a06b4..72b39ca 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -661,17 +661,17 @@ async function ensureChallengeHasAiReviewers(challengeId) { } } -async function ensureAIScreeningCanBeClosed(challengeId) { - logger.debug(`Validating AI Screening closure for challenge ${challengeId}`); +async function ensureAIPhaseCanBeClosed(challengeId, phaseName = 'AI Screening') { + logger.debug(`Validating ${phaseName} closure for challenge ${challengeId}`); await ensureChallengeHasAiReviewers(challengeId); const aiReviewConfig = await helper.getAIReviewConfigByChallengeId(challengeId); if (!aiReviewConfig || !aiReviewConfig.id) { logger.debug( - `AI Screening closure blocked for challenge ${challengeId}: AI review configuration not found`, + `${phaseName} closure blocked for challenge ${challengeId}: AI review configuration not found`, ); throw new errors.BadRequestError( - "Cannot close AI Screening phase because AI review configuration could not be fetched", + `Cannot close ${phaseName} phase because AI review configuration could not be fetched`, ); } @@ -704,14 +704,14 @@ async function ensureAIScreeningCanBeClosed(challengeId) { ]); } catch (err) { logger.error( - `Failed to fetch AI screening submissions/decisions for challenge ${challengeId}: ${err.message}`, + `Failed to fetch ${phaseName} submissions/decisions for challenge ${challengeId}: ${err.message}`, err, ); throw err; } logger.debug( - `AI Screening data for challenge ${challengeId}: submissions=${(submissions || []).length}, decisions=${ + `${phaseName} data for challenge ${challengeId}: submissions=${(submissions || []).length}, decisions=${ (decisions || []).length }`, ); @@ -720,7 +720,7 @@ async function ensureAIScreeningCanBeClosed(challengeId) { (submissions || []).map((submission) => extractSubmissionId(submission)).filter((id) => !!id), ); if (submissionIds.length === 0) { - logger.debug(`AI Screening closure allowed for challenge ${challengeId}: no submissions found`); + logger.debug(`${phaseName} closure allowed for challenge ${challengeId}: no submissions found`); return; } @@ -740,14 +740,14 @@ async function ensureAIScreeningCanBeClosed(challengeId) { if (hasPendingDecision || missingFinalizedSubmissions.length > 0) { logger.debug( - `AI Screening closure blocked for challenge ${challengeId}: hasPendingDecision=${hasPendingDecision}, missingFinalizedSubmissions=${missingFinalizedSubmissions.length}`, + `${phaseName} closure blocked for challenge ${challengeId}: hasPendingDecision=${hasPendingDecision}, missingFinalizedSubmissions=${missingFinalizedSubmissions.length}`, ); throw new errors.BadRequestError( - "Cannot close AI Screening phase because AI reviews are not complete", + `Cannot close ${phaseName} phase because AI reviews are not complete`, ); } - logger.debug(`AI Screening closure allowed for challenge ${challengeId}: all reviews finalized`); + logger.debug(`${phaseName} closure allowed for challenge ${challengeId}: all reviews finalized`); } /** From 6f1da711518de3e19ed9ba67659070cfa6071ed8 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 4 Jun 2026 15:17:44 +0300 Subject: [PATCH 14/18] Fix check for closing ai review phase --- src/phase-management/PhaseAdvancer.js | 66 +------------- src/services/ChallengePhaseService.js | 2 +- src/services/ChallengeService.js | 11 +-- .../phase-management/PhaseAdvancer.test.js | 86 ------------------- 4 files changed, 8 insertions(+), 157 deletions(-) diff --git a/src/phase-management/PhaseAdvancer.js b/src/phase-management/PhaseAdvancer.js index 610e302..32f68fa 100644 --- a/src/phase-management/PhaseAdvancer.js +++ b/src/phase-management/PhaseAdvancer.js @@ -122,34 +122,6 @@ class PhaseAdvancer { throw new errors.BadRequestError(`Phase ${targetPhaseName} not found or already closed`); } - // Block closing AI Review phase if there are pending AI decisions - if (operation === "close" && phase.name === "AI Review") { - const pendingAiDecisions = await this.#getPendingAiDecisionsCount(challengeId); - if (pendingAiDecisions > 0) { - console.log( - `Blocked closing AI Review phase for challenge ${challengeId}: ${pendingAiDecisions} pending AI decision(s)` - ); - return { - success: false, - message: `Cannot close AI Review phase: ${pendingAiDecisions} pending AI decision(s) not yet completed`, - detail: "Pending AI decisions must be finalized before closing AI Review phase", - failureReasons: [ - { - rule: "AI Review Phase Guard", - failedConditions: [ - { - fact: "pendingAiDecisions", - operator: "equal", - value: 0, - actual: pendingAiDecisions, - }, - ], - }, - ], - }; - } - } - const rules = this.#collectRules(operation, phase); const facts = await this.#generateFacts(challengeId, legacyId, phases, phase, operation); const validation = await this.#validateRules(rules, facts); @@ -380,43 +352,7 @@ class PhaseAdvancer { }); return ch?.numOfSubmissions || 0; } - - /** - * Counts AI review decisions in PENDING status for active contest submissions. - * Used to block AI Review phase closure until all decisions are finalized. - */ - async #getPendingAiDecisionsCount(challengeId) { - console.log(`Getting pending AI decisions count for challenge ${challengeId}`); - const reviewPrisma = getReviewClient(); - const reviewSchema = config.REVIEW_DB_SCHEMA; - const aiReviewDecisionTable = Prisma.raw(`"${reviewSchema}"."aiReviewDecision"`); - const submissionTable = Prisma.raw(`"${reviewSchema}"."submission"`); - - try { - const [{ count = 0 } = {}] = await reviewPrisma.$queryRaw( - Prisma.sql` - SELECT COUNT(*)::int AS count - FROM ${aiReviewDecisionTable} aid - INNER JOIN ${submissionTable} s - ON s."id" = aid."submissionId" - WHERE s."challengeId" = ${challengeId} - AND (s."status" = 'ACTIVE' OR s."status" IS NULL) - AND ( - s."type" IS NULL - OR UPPER((s."type")::text) = 'CONTEST_SUBMISSION' - ) - AND UPPER((aid."status")::text) = 'PENDING' - ` - ); - const rawCount = Number(count); - return Number.isFinite(rawCount) ? rawCount : 0; - } catch (error) { - console.error( - `Failed to count pending AI decisions for challenge ${challengeId}: ${error.message}` - ); - throw error; - } - } + async #areAllSubmissionsReviewed(challengeId) { console.log(`Evaluating review completion for challenge ${challengeId}`); diff --git a/src/services/ChallengePhaseService.js b/src/services/ChallengePhaseService.js index 2cae492..a943b7d 100644 --- a/src/services/ChallengePhaseService.js +++ b/src/services/ChallengePhaseService.js @@ -13,7 +13,7 @@ const constants = require("../../app-constants"); const { getReviewClient } = require("../common/review-prisma"); const { indexChallengeAndPostToKafka, - ensureAIScreeningCanBeClosed, + ensureAIPhaseCanBeClosed, } = require("./ChallengeService"); const { getClient } = require("../common/prisma"); diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 72b39ca..f27cceb 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -632,6 +632,7 @@ const REVIEW_PHASE_NAMES = Object.freeze([ const REVIEW_PHASE_NAME_SET = new Set(REVIEW_PHASE_NAMES); const REQUIRED_REVIEW_PHASE_NAME_SET = new Set([...REVIEW_PHASE_NAMES, "iterative review"]); const AI_SCREENING_PHASE_NAME = "ai screening"; +const AI_REVIEW_PHASE_NAME = "ai review"; function normalizePhaseNameForComparison(phaseName) { return _.toString(phaseName).replace(/-/g, " ").trim().toLowerCase(); @@ -5207,11 +5208,11 @@ async function advancePhase(currentUser, challengeId, data) { throw new errors.BadRequestError(`Challenge with id: ${challengeId} is not in ACTIVE status.`); } - const isClosingAIScreening = + const isClosingAIScreeningOrReviewPhase = data.operation === "close" && - normalizePhaseNameForComparison(data.phase) === AI_SCREENING_PHASE_NAME; - if (isClosingAIScreening) { - await ensureAIScreeningCanBeClosed(challenge.id); + (normalizePhaseNameForComparison(data.phase) === AI_SCREENING_PHASE_NAME || normalizePhaseNameForComparison(data.phase) === AI_REVIEW_PHASE_NAME); + if (isClosingAIScreeningOrReviewPhase) { + await ensureAIPhaseCanBeClosed(challenge.id, data.phase); } const phaseAdvancerResult = await phaseAdvancer.advancePhase( @@ -5490,7 +5491,7 @@ module.exports = { getDefaultReviewers, setDefaultReviewers, indexChallengeAndPostToKafka, - ensureAIScreeningCanBeClosed, + ensureAIPhaseCanBeClosed, }; logger.buildService(module.exports); diff --git a/test/unit/phase-management/PhaseAdvancer.test.js b/test/unit/phase-management/PhaseAdvancer.test.js index 71932d0..b0065a0 100644 --- a/test/unit/phase-management/PhaseAdvancer.test.js +++ b/test/unit/phase-management/PhaseAdvancer.test.js @@ -93,92 +93,6 @@ describe("PhaseAdvancer Iterative Review gating", () => { }); }); -describe("PhaseAdvancer AI Review phase closure blocking", () => { - const prisma = getClient(); - const originalFindUnique = prisma.challenge.findUnique; - const originalGetReviewClient = reviewPrisma.getReviewClient; - const phaseAdvancerPath = require.resolve("../../../src/phase-management/PhaseAdvancer"); - - afterEach(() => { - prisma.challenge.findUnique = originalFindUnique; - reviewPrisma.getReviewClient = originalGetReviewClient; - delete require.cache[phaseAdvancerPath]; - }); - - const buildAiReviewPhase = () => ({ - id: "phase-ai-review", - phaseId: "ai-review-phase-id", - name: "AI Review", - description: "AI Review phase", - duration: 86400, - isOpen: true, - predecessor: "submission-phase-id", - scheduledStartDate: new Date(Date.now() - 60 * 60 * 1000).toISOString(), - scheduledEndDate: new Date(Date.now() + 23 * 60 * 60 * 1000).toISOString(), - actualStartDate: new Date(Date.now() - 60 * 60 * 1000).toISOString(), - actualEndDate: null, - constraints: [], - }); - - it("blocks closing AI Review phase when there are pending AI decisions", async () => { - reviewPrisma.getReviewClient = () => ({ - $queryRaw: async () => [{ count: 3 }], - }); - - delete require.cache[phaseAdvancerPath]; - const PhaseAdvancerWithMock = require("../../../src/phase-management/PhaseAdvancer"); - const phaseAdvancer = new PhaseAdvancerWithMock({ - async getPhaseFacts() { - return {}; - }, - }); - - const phases = [buildAiReviewPhase()]; - - const result = await phaseAdvancer.advancePhase( - "challenge-123", - null, - phases, - "close", - "AI Review" - ); - - expect(result.success).to.be.false; - expect(result.message).to.contain("pending AI decision"); - expect(result.message).to.contain("3"); - expect(phases[0].isOpen).to.be.true; - expect(phases[0].actualEndDate).to.be.null; - }); - - it("allows closing AI Review phase when no pending AI decisions", async () => { - reviewPrisma.getReviewClient = () => ({ - $queryRaw: async () => [{ count: 0 }], - }); - - delete require.cache[phaseAdvancerPath]; - const PhaseAdvancerWithMock = require("../../../src/phase-management/PhaseAdvancer"); - const phaseAdvancer = new PhaseAdvancerWithMock({ - async getPhaseFacts() { - return {}; - }, - }); - - const phases = [buildAiReviewPhase()]; - - const result = await phaseAdvancer.advancePhase( - "challenge-123", - null, - phases, - "close", - "AI Review" - ); - - expect(result.success).to.be.true; - expect(phases[0].isOpen).to.be.false; - expect(phases[0].actualEndDate).to.not.be.null; - }); -}); - describe("PhaseAdvancer review completion queries", () => { const prisma = getClient(); const originalFindUnique = prisma.challenge.findUnique; From 840fd957ccec5fb55a3b780e538cc84a184cf0d6 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Fri, 5 Jun 2026 14:46:25 +1000 Subject: [PATCH 15/18] PM-5161: Guard MM winner selection until final scoring completes What was broken Marathon Match challenge closing could persist winners before all parallel system scoring callbacks had written final summations for every member's latest submission. If an early final summation belonged to a lower-scoring member, that member could be stored as the winner even after later final scores showed a different winner. Root cause (if identifiable) closeMarathonMatch ranked whatever final review summations existed at the time of the close request. It did not verify that every latest member submission had a final/system summation, and it did not limit winner ranking to the latest submission for each member. What was changed Loaded latest non-checkpoint submissions from the review database during Marathon Match close. Winner selection now requires each latest submission to have a matching final summation before the challenge can be completed, and ranks only those latest-submission final summations. When submission rows are unavailable, the logic falls back to the newest final summation per submitter to avoid duplicate placements. Any added/updated tests Added ChallengeService unit coverage for ranking winners from latest member final summations and blocking close while any latest submission is missing a final summation. --- src/services/ChallengeService.js | 175 ++++++++++++++++++++++++++++- test/unit/ChallengeService.test.js | 124 ++++++++++++++++++++ 2 files changed, 298 insertions(+), 1 deletion(-) diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index c169b35..3bdec3e 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -187,6 +187,173 @@ async function applyLatestSubmissionCounts(challenges) { }); } +/** + * Loads the latest non-checkpoint Marathon Match submission per member from + * the review database. + * + * The close flow uses this to avoid selecting winners before parallel system + * scoring has produced a final summation for every member's latest attempt. + * + * @param {String} challengeId challenge identifier + * @returns {Promise>} latest submission rows keyed by member + */ +async function getLatestMarathonMatchSubmissions(challengeId) { + if (!config.REVIEW_DB_URL) { + return []; + } + + const reviewSchema = _.toString(config.REVIEW_DB_SCHEMA || "").trim(); + const submissionTable = reviewSchema + ? Prisma.raw(`"${reviewSchema.replace(/"/g, '""')}"."submission"`) + : Prisma.raw('"submission"'); + const reviewClient = getReviewClient(); + + return reviewClient.$queryRaw` + SELECT + "id", + "memberId", + "submittedDate", + "createdAt", + "updatedAt" + FROM ( + SELECT + "id", + "memberId", + "submittedDate", + "createdAt", + "updatedAt", + ROW_NUMBER() OVER ( + PARTITION BY "memberId" + ORDER BY + COALESCE("isLatest", false) DESC, + COALESCE("submittedDate", "createdAt", "updatedAt") DESC, + "id" DESC + ) AS "rowNumber" + FROM ${submissionTable} + WHERE "challengeId" = ${challengeId} + AND "memberId" IS NOT NULL + AND COALESCE("type"::text, '') <> ${CHECKPOINT_SUBMISSION_TYPE} + AND COALESCE("status"::text, '') <> 'DELETED' + ) ranked + WHERE "rowNumber" = 1 + `; +} + +/** + * Normalizes identifiers used to match review summations to submissions. + * @param {*} value raw identifier value + * @returns {String} trimmed identifier, or an empty string when absent + */ +function normalizeMatchId(value) { + return _.toString(value || "").trim(); +} + +/** + * Reads a review summation timestamp for latest-result comparisons. + * @param {Object} summation review summation returned by Review API + * @returns {Number} timestamp in milliseconds, or zero when unavailable + */ +function getReviewSummationTimestampValue(summation) { + const candidate = + _.get(summation, "reviewedDate") || + _.get(summation, "updatedAt") || + _.get(summation, "createdAt"); + const timestamp = new Date(candidate).getTime(); + return Number.isFinite(timestamp) ? timestamp : 0; +} + +/** + * Compares two final review summations for the same submission or submitter. + * Newer summations win; ties keep the higher score. + * + * @param {Object|null} current currently selected summation + * @param {Object} candidate summation being considered + * @returns {Boolean} true when candidate should replace current + */ +function shouldReplaceSelectedFinalSummation(current, candidate) { + if (!current) { + return true; + } + + const currentTimestamp = getReviewSummationTimestampValue(current); + const candidateTimestamp = getReviewSummationTimestampValue(candidate); + if (candidateTimestamp !== currentTimestamp) { + return candidateTimestamp > currentTimestamp; + } + + return Number(candidate.aggregateScore) > Number(current.aggregateScore); +} + +/** + * Selects the final summations that should determine Marathon Match winners. + * + * When latest submission rows are available, every latest submission must have + * a matching final summation. Without submission rows, the function falls back + * to the newest final summation per submitter to preserve legacy behavior while + * avoiding duplicate winner placements for repeated attempts. + * + * @param {String} challengeId challenge identifier used in error messages + * @param {Array} finalSummations final review summations + * @param {Array} latestSubmissions latest submission rows + * @returns {Array} selected final summations to rank + * @throws {BadRequestError} when any latest submission has no final summation + */ +function selectMarathonMatchWinnerSummations(challengeId, finalSummations, latestSubmissions) { + if (!Array.isArray(latestSubmissions) || latestSubmissions.length === 0) { + const latestBySubmitter = new Map(); + finalSummations.forEach((summation) => { + const submitterId = normalizeMatchId(summation.submitterId); + if (!submitterId) { + return; + } + if (shouldReplaceSelectedFinalSummation(latestBySubmitter.get(submitterId), summation)) { + latestBySubmitter.set(submitterId, summation); + } + }); + return Array.from(latestBySubmitter.values()); + } + + const finalBySubmission = new Map(); + finalSummations.forEach((summation) => { + const submitterId = normalizeMatchId(summation.submitterId); + const submissionId = normalizeMatchId(summation.submissionId); + if (!submitterId || !submissionId) { + return; + } + const key = `${submitterId}:${submissionId}`; + if (shouldReplaceSelectedFinalSummation(finalBySubmission.get(key), summation)) { + finalBySubmission.set(key, summation); + } + }); + + const selectedSummations = []; + const missingSubmissions = []; + latestSubmissions.forEach((submission) => { + const memberId = normalizeMatchId(submission.memberId); + const submissionId = normalizeMatchId(submission.id); + if (!memberId || !submissionId) { + return; + } + + const summation = finalBySubmission.get(`${memberId}:${submissionId}`); + if (summation) { + selectedSummations.push(summation); + } else { + missingSubmissions.push(submissionId); + } + }); + + if (missingSubmissions.length > 0) { + throw new errors.BadRequestError( + `Cannot close Marathon Match challenge ${challengeId}: final system scoring is not complete for latest submissions. Missing final summations for submissionIds: ${missingSubmissions.join( + ", ", + )}`, + ); + } + + return selectedSummations; +} + function normalizeStatusSortValue(statusValue) { if (_.isNil(statusValue)) { return null; @@ -5355,9 +5522,15 @@ async function closeMarathonMatch(currentUser, challengeId) { const finalSummations = (reviewSummations || []).filter( (summation) => summation.isFinal === true, ); + const latestSubmissions = await getLatestMarathonMatchSubmissions(challengeId); + const winnerSummations = selectMarathonMatchWinnerSummations( + challengeId, + finalSummations, + latestSubmissions, + ); const orderedSummations = _.orderBy( - finalSummations, + winnerSummations, ["aggregateScore", "createdAt"], ["desc", "asc"], ); diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index a7c23c3..9c7e7b1 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -103,6 +103,10 @@ describe("challenge service unit tests", () => { ALTER TABLE ${submissionTableName} ADD COLUMN IF NOT EXISTS "type" varchar(64) `); + await reviewClient.$executeRawUnsafe(` + ALTER TABLE ${submissionTableName} + ADD COLUMN IF NOT EXISTS "isLatest" boolean + `); await reviewClient.$executeRawUnsafe(`DELETE FROM ${submissionTableName}`); testChallengeData = { @@ -2978,6 +2982,9 @@ describe("challenge service unit tests", () => { updatedBy: "admin", }, }); + await reviewClient.$executeRawUnsafe( + `DELETE FROM ${submissionTableName} WHERE "challengeId" = '${data.marathonMatchChallenge.id}'`, + ); } }); @@ -3055,6 +3062,123 @@ describe("challenge service unit tests", () => { }); }); + it("close marathon match selects winners from latest member final summations", async () => { + await reviewClient.$executeRawUnsafe(` + INSERT INTO ${submissionTableName} + ("id", "challengeId", "memberId", "type", "status", "submittedDate", "createdAt", "updatedAt", "isLatest") + VALUES + ('old-topacc-submission', '${data.marathonMatchChallenge.id}', '12345678', 'Contest Submission', 'ACTIVE', '2024-05-01T09:00:00.000Z', '2024-05-01T09:00:00.000Z', '2024-05-01T09:00:00.000Z', false), + ('latest-topacc-submission', '${data.marathonMatchChallenge.id}', '12345678', 'Contest Submission', 'ACTIVE', '2024-05-01T10:00:00.000Z', '2024-05-01T10:00:00.000Z', '2024-05-01T10:00:00.000Z', true), + ('old-liuliquan-submission', '${data.marathonMatchChallenge.id}', '9876543', 'Contest Submission', 'ACTIVE', '2024-05-01T09:05:00.000Z', '2024-05-01T09:05:00.000Z', '2024-05-01T09:05:00.000Z', false), + ('latest-liuliquan-submission', '${data.marathonMatchChallenge.id}', '9876543', 'Contest Submission', 'ACTIVE', '2024-05-01T10:05:00.000Z', '2024-05-01T10:05:00.000Z', '2024-05-01T10:05:00.000Z', true) + `); + + const originalGetReviewSummations = helper.getReviewSummations; + helper.getReviewSummations = async () => [ + { + id: uuid(), + challengeId: data.marathonMatchChallenge.id, + isFinal: true, + aggregateScore: 75.8, + submissionId: "old-topacc-submission", + submitterId: "12345678", + submitterHandle: "thomaskranitsas", + createdAt: "2024-05-01T09:15:00.000Z", + reviewedDate: "2024-05-01T09:15:00.000Z", + }, + { + id: uuid(), + challengeId: data.marathonMatchChallenge.id, + isFinal: true, + aggregateScore: 95.7, + submissionId: "latest-topacc-submission", + submitterId: "12345678", + submitterHandle: "thomaskranitsas", + createdAt: "2024-05-01T10:15:00.000Z", + reviewedDate: "2024-05-01T10:15:00.000Z", + }, + { + id: uuid(), + challengeId: data.marathonMatchChallenge.id, + isFinal: true, + aggregateScore: 99.9, + submissionId: "old-liuliquan-submission", + submitterId: "9876543", + submitterHandle: "tonyj", + createdAt: "2024-05-01T09:20:00.000Z", + reviewedDate: "2024-05-01T09:20:00.000Z", + }, + { + id: uuid(), + challengeId: data.marathonMatchChallenge.id, + isFinal: true, + aggregateScore: 83.6, + submissionId: "latest-liuliquan-submission", + submitterId: "9876543", + submitterHandle: "tonyj", + createdAt: "2024-05-01T10:20:00.000Z", + reviewedDate: "2024-05-01T10:20: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(adminUser, data.marathonMatchChallenge.id); + + should.exist(result); + should.equal(result.status, ChallengeStatusEnum.COMPLETED); + should.equal(result.winners.length, 2); + should.equal(result.winners[0].placement, 1); + should.equal(result.winners[0].userId, 12345678); + should.equal(result.winners[1].placement, 2); + should.equal(result.winners[1].userId, 9876543); + }); + + it("close marathon match blocks until all latest submissions have final summations", async () => { + await reviewClient.$executeRawUnsafe(` + INSERT INTO ${submissionTableName} + ("id", "challengeId", "memberId", "type", "status", "submittedDate", "createdAt", "updatedAt", "isLatest") + VALUES + ('latest-topacc-submission', '${data.marathonMatchChallenge.id}', '12345678', 'Contest Submission', 'ACTIVE', '2024-05-02T10:00:00.000Z', '2024-05-02T10:00:00.000Z', '2024-05-02T10:00:00.000Z', true), + ('latest-liuliquan-submission', '${data.marathonMatchChallenge.id}', '9876543', 'Contest Submission', 'ACTIVE', '2024-05-02T10:05:00.000Z', '2024-05-02T10:05:00.000Z', '2024-05-02T10:05:00.000Z', true) + `); + + const originalGetReviewSummations = helper.getReviewSummations; + helper.getReviewSummations = async () => [ + { + id: uuid(), + challengeId: data.marathonMatchChallenge.id, + isFinal: true, + aggregateScore: 83.6, + submissionId: "latest-liuliquan-submission", + submitterId: "9876543", + submitterHandle: "tonyj", + createdAt: "2024-05-02T10:20:00.000Z", + reviewedDate: "2024-05-02T10:20:00.000Z", + }, + ]; + originalReviewSummations = originalGetReviewSummations; + + try { + await service.closeMarathonMatch(adminUser, data.marathonMatchChallenge.id); + } catch (e) { + should.equal(e.name, "BadRequestError"); + should.equal( + e.message.indexOf("final system scoring is not complete for latest submissions") >= 0, + true, + ); + should.equal(e.message.indexOf("latest-topacc-submission") >= 0, true); + return; + } + throw new Error("should not reach here"); + }); + it("close marathon match successfully with M2M token", async () => { const originalGetReviewSummations = helper.getReviewSummations; helper.getReviewSummations = async () => [ From 01726736d5bc363694541c0e43b9448e08fdf8a6 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 9 Jun 2026 08:27:24 +0300 Subject: [PATCH 16/18] Allow opening approval phase without approval user in AI Only challenges --- src/services/ChallengePhaseService.js | 4 +- test/unit/ChallengePhaseService.test.js | 49 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/services/ChallengePhaseService.js b/src/services/ChallengePhaseService.js index a943b7d..cc991d9 100644 --- a/src/services/ChallengePhaseService.js +++ b/src/services/ChallengePhaseService.js @@ -541,8 +541,8 @@ async function ensureRequiredResourcesBeforeOpeningPhase(challenge, phaseName) { return; } - // For AI_ONLY review mode, no human Reviewer resources are required - if (normalizedPhaseName === "review") { + // For AI_ONLY review mode, no human Reviewer or Approver resources are required + if (normalizedPhaseName === "review" || normalizedPhaseName === "approval") { try { const aiReviewConfig = await helper.getAIReviewConfigByChallengeId(challenge.id); if (aiReviewConfig?.mode === "AI_ONLY") { diff --git a/test/unit/ChallengePhaseService.test.js b/test/unit/ChallengePhaseService.test.js index 25ca132..0030472 100644 --- a/test/unit/ChallengePhaseService.test.js +++ b/test/unit/ChallengePhaseService.test.js @@ -1713,6 +1713,55 @@ describe('challenge phase service unit tests', () => { throw new Error('should not reach here') }) + it('partially update challenge phase - opens approval phase without approver resource for AI_ONLY challenges', async () => { + const approvalPhase = await prisma.phase.create({ + data: { + id: uuid(), + name: 'Approval', + description: 'desc', + isOpen: false, + duration: 86400, + createdBy: 'admin', + updatedBy: 'admin' + } + }) + const approvalChallengePhaseId = uuid() + await prisma.challengePhase.create({ + data: { + id: approvalChallengePhaseId, + challengeId: data.challenge.id, + phaseId: approvalPhase.id, + name: 'Approval', + isOpen: false, + createdBy: 'admin', + updatedBy: 'admin' + } + }) + + const originalGetAIReviewConfigByChallengeId = helper.getAIReviewConfigByChallengeId + const originalGetChallengeResources = helper.getChallengeResources + const originalGetResourceRoles = helper.getResourceRoles + helper.getAIReviewConfigByChallengeId = async () => ({ mode: 'AI_ONLY' }) + helper.getChallengeResources = async () => [] + helper.getResourceRoles = async () => [{ id: 'approver-role-id', name: 'Approver' }] + + try { + const challengePhase = await service.partiallyUpdateChallengePhase( + authUser, + data.challenge.id, + approvalChallengePhaseId, + { isOpen: true } + ) + should.equal(challengePhase.isOpen, true) + } finally { + helper.getAIReviewConfigByChallengeId = originalGetAIReviewConfigByChallengeId + helper.getChallengeResources = originalGetChallengeResources + helper.getResourceRoles = originalGetResourceRoles + await prisma.challengePhase.delete({ where: { id: approvalChallengePhaseId } }) + await prisma.phase.delete({ where: { id: approvalPhase.id } }) + } + }) + it('partially update challenge phase - opens marathon match review phase without reviewer resource', async () => { const reviewPhase = await prisma.phase.create({ data: { From 4f767be1e26f5f3376c806aa1ab10b3661283c4c Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 10 Jun 2026 12:00:16 +1000 Subject: [PATCH 17/18] PM-5161: Guard MM winner fallback final scoring What was broken Marathon Match close could still persist winners from an incomplete set of final summations when latest submission rows were unavailable to challenge-api. In that fallback path, an older or partial final score could be selected before another submitter's latest known summation had a final score. Root cause (if identifiable) The previous fix guarded the direct latest-submission path, but the no-submission-row fallback still ranked the newest final summation per submitter without checking whether Review API already showed a newer submission-scoped summation for that submitter. What was changed The fallback winner selection now derives the newest submission-scoped summation per submitter from all review summations. If that latest known submission does not have the selected final summation, closeMarathonMatch rejects the close instead of persisting premature winners. Any added/updated tests Added ChallengeService unit coverage for blocking fallback winner selection when a submitter has an older final summation and a newer non-final submission-scoped summation. --- src/services/ChallengeService.js | 75 ++++++++++++++++++++++++++---- test/unit/ChallengeService.test.js | 55 ++++++++++++++++++++++ 2 files changed, 121 insertions(+), 9 deletions(-) diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index caac860..eff0b60 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -263,14 +263,14 @@ function getReviewSummationTimestampValue(summation) { } /** - * Compares two final review summations for the same submission or submitter. + * Compares two review summations for the same submission or submitter. * Newer summations win; ties keep the higher score. * * @param {Object|null} current currently selected summation * @param {Object} candidate summation being considered * @returns {Boolean} true when candidate should replace current */ -function shouldReplaceSelectedFinalSummation(current, candidate) { +function shouldReplaceSelectedReviewSummation(current, candidate) { if (!current) { return true; } @@ -284,21 +284,55 @@ function shouldReplaceSelectedFinalSummation(current, candidate) { return Number(candidate.aggregateScore) > Number(current.aggregateScore); } +/** + * Finds the newest submission-scoped review summation per submitter. + * + * This is used as a fallback completeness signal when latest submission rows + * cannot be read directly from the review database. + * + * @param {Array} reviewSummations review summations returned by Review API + * @returns {Map} latest submission-scoped summation by submitter id + */ +function getLatestSubmissionScopedSummationsBySubmitter(reviewSummations) { + const latestBySubmitter = new Map(); + + (Array.isArray(reviewSummations) ? reviewSummations : []).forEach((summation) => { + const submitterId = normalizeMatchId(summation.submitterId); + const submissionId = normalizeMatchId(summation.submissionId); + if (!submitterId || !submissionId) { + return; + } + + if (shouldReplaceSelectedReviewSummation(latestBySubmitter.get(submitterId), summation)) { + latestBySubmitter.set(submitterId, summation); + } + }); + + return latestBySubmitter; +} + /** * Selects the final summations that should determine Marathon Match winners. * * When latest submission rows are available, every latest submission must have - * a matching final summation. Without submission rows, the function falls back - * to the newest final summation per submitter to preserve legacy behavior while - * avoiding duplicate winner placements for repeated attempts. + * a matching final summation. Without submission rows, the function uses the + * newest submission-scoped summation per submitter as a fallback completeness + * signal before ranking the newest final summation per submitter. * * @param {String} challengeId challenge identifier used in error messages + * @param {Array} reviewSummations all review summations * @param {Array} finalSummations final review summations * @param {Array} latestSubmissions latest submission rows * @returns {Array} selected final summations to rank - * @throws {BadRequestError} when any latest submission has no final summation + * @throws {BadRequestError} when any latest submission or fallback latest + * submitter summation has no final summation */ -function selectMarathonMatchWinnerSummations(challengeId, finalSummations, latestSubmissions) { +function selectMarathonMatchWinnerSummations( + challengeId, + reviewSummations, + finalSummations, + latestSubmissions, +) { if (!Array.isArray(latestSubmissions) || latestSubmissions.length === 0) { const latestBySubmitter = new Map(); finalSummations.forEach((summation) => { @@ -306,10 +340,32 @@ function selectMarathonMatchWinnerSummations(challengeId, finalSummations, lates if (!submitterId) { return; } - if (shouldReplaceSelectedFinalSummation(latestBySubmitter.get(submitterId), summation)) { + if (shouldReplaceSelectedReviewSummation(latestBySubmitter.get(submitterId), summation)) { latestBySubmitter.set(submitterId, summation); } }); + + const latestKnownSummations = getLatestSubmissionScopedSummationsBySubmitter(reviewSummations); + const missingSubmitters = []; + latestKnownSummations.forEach((latestSummation, submitterId) => { + const selectedFinal = latestBySubmitter.get(submitterId); + if ( + !selectedFinal || + normalizeMatchId(selectedFinal.submissionId) !== + normalizeMatchId(latestSummation.submissionId) + ) { + missingSubmitters.push(submitterId); + } + }); + + if (missingSubmitters.length > 0) { + throw new errors.BadRequestError( + `Cannot close Marathon Match challenge ${challengeId}: final system scoring is not complete for latest submitter summations. Missing final summations for submitterIds: ${missingSubmitters + .sort() + .join(", ")}`, + ); + } + return Array.from(latestBySubmitter.values()); } @@ -321,7 +377,7 @@ function selectMarathonMatchWinnerSummations(challengeId, finalSummations, lates return; } const key = `${submitterId}:${submissionId}`; - if (shouldReplaceSelectedFinalSummation(finalBySubmission.get(key), summation)) { + if (shouldReplaceSelectedReviewSummation(finalBySubmission.get(key), summation)) { finalBySubmission.set(key, summation); } }); @@ -5525,6 +5581,7 @@ async function closeMarathonMatch(currentUser, challengeId) { const latestSubmissions = await getLatestMarathonMatchSubmissions(challengeId); const winnerSummations = selectMarathonMatchWinnerSummations( challengeId, + reviewSummations, finalSummations, latestSubmissions, ); diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index 9c7e7b1..0f96be6 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -3179,6 +3179,61 @@ describe("challenge service unit tests", () => { throw new Error("should not reach here"); }); + it("close marathon match blocks fallback selection when a newer submitter summation is not final", async () => { + const originalGetReviewSummations = helper.getReviewSummations; + helper.getReviewSummations = async () => [ + { + id: uuid(), + challengeId: data.marathonMatchChallenge.id, + isFinal: true, + aggregateScore: 75.8, + submissionId: "old-topacc-submission", + submitterId: "12345678", + submitterHandle: "thomaskranitsas", + createdAt: "2024-05-03T09:15:00.000Z", + reviewedDate: "2024-05-03T09:15:00.000Z", + }, + { + id: uuid(), + challengeId: data.marathonMatchChallenge.id, + isFinal: false, + aggregateScore: 95.7, + submissionId: "latest-topacc-submission", + submitterId: "12345678", + submitterHandle: "thomaskranitsas", + createdAt: "2024-05-03T10:15:00.000Z", + reviewedDate: "2024-05-03T10:15:00.000Z", + }, + { + id: uuid(), + challengeId: data.marathonMatchChallenge.id, + isFinal: true, + aggregateScore: 83.6, + submissionId: "latest-liuliquan-submission", + submitterId: "9876543", + submitterHandle: "tonyj", + createdAt: "2024-05-03T10:20:00.000Z", + reviewedDate: "2024-05-03T10:20:00.000Z", + }, + ]; + originalReviewSummations = originalGetReviewSummations; + + try { + await service.closeMarathonMatch(adminUser, data.marathonMatchChallenge.id); + } catch (e) { + should.equal(e.name, "BadRequestError"); + should.equal( + e.message.indexOf( + "final system scoring is not complete for latest submitter summations", + ) >= 0, + true, + ); + should.equal(e.message.indexOf("12345678") >= 0, true); + return; + } + throw new Error("should not reach here"); + }); + it("close marathon match successfully with M2M token", async () => { const originalGetReviewSummations = helper.getReviewSummations; helper.getReviewSummations = async () => [ From 53344c1ab3ad1e47e2f3659816cc6bb97d02ecca Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 10 Jun 2026 16:23:12 +1000 Subject: [PATCH 18/18] Whitelist changes for M2M tokens --- app-routes.js | 6 ++++-- src/common/helper.js | 9 +++++++-- test/unit/ChallengeService.test.js | 14 ++++++++++++++ test/unit/helper-whitelist.test.js | 25 +++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 test/unit/helper-whitelist.test.js diff --git a/app-routes.js b/app-routes.js index e4c46fe..dac9b7f 100644 --- a/app-routes.js +++ b/app-routes.js @@ -185,9 +185,11 @@ module.exports = (app) => { !helper.checkIfExists(def.scopes, req.authUser.scopes) ) { logger.info( - `[${getSignature(req)}] Public route: dropping machine token due to scope mismatch` + `[${getSignature(req)}] Public route: preserving machine token whitelist bypass despite scope mismatch` ); - req.authUser = undefined; + req.authUser = { + bypassChallengeWhitelist: true, + }; } else { logger.info(`[${getSignature(req)}] Public route: valid machine token attached`); } diff --git a/src/common/helper.js b/src/common/helper.js index 8b9bcd4..a7bfbeb 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -1262,13 +1262,18 @@ async function validateChallengeTerms(terms = []) { /** * Determine whether challenge whitelist checks apply for a request. * Interactive users, including admins and anonymous callers, must be evaluated; - * M2M callers are allowed to bypass this user-facing access control. + * M2M callers are allowed to bypass this user-facing access control. Public + * routes may preserve `bypassChallengeWhitelist` for valid machine tokens that + * are not otherwise attached as full M2M callers. * * @param {Object} currentUser the user who performs the operation * @returns {Boolean} true when whitelist rules should be applied */ function shouldApplyChallengeWhitelist(currentUser) { - return !_.get(currentUser, "isMachine", false); + return ( + !_.get(currentUser, "isMachine", false) && + !_.get(currentUser, "bypassChallengeWhitelist", false) + ); } /** diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index 0f96be6..6c5a47f 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -783,6 +783,13 @@ describe("challenge service unit tests", () => { ); should.equal(machine.id, createdChallengeData.id); + const whitelistBypassOnly = await service.getChallenge( + { bypassChallengeWhitelist: true }, + createdChallengeData.id, + true, + ); + should.equal(whitelistBypassOnly.id, createdChallengeData.id); + try { await service.getChallenge( { handle: "blocked", roles: ["administrator"], userId: "blocked-user" }, @@ -1234,6 +1241,13 @@ describe("challenge service unit tests", () => { ); should.equal(machine.total, 1); should.equal(machine.result[0].id, data.challenge.id); + + const whitelistBypassOnly = await service.searchChallenges( + { bypassChallengeWhitelist: true }, + { id: data.challenge.id }, + ); + should.equal(whitelistBypassOnly.total, 1); + should.equal(whitelistBypassOnly.result[0].id, data.challenge.id); } finally { await prisma.challengeUserWhitelist.deleteMany({ where: { challengeId: data.challenge.id }, diff --git a/test/unit/helper-whitelist.test.js b/test/unit/helper-whitelist.test.js new file mode 100644 index 0000000..b70365a --- /dev/null +++ b/test/unit/helper-whitelist.test.js @@ -0,0 +1,25 @@ +require("../../app-bootstrap"); + +const { expect } = require("chai"); +const helper = require("../../src/common/helper"); + +describe("challenge whitelist helper", () => { + it("does not apply challenge user whitelist checks to full M2M callers", () => { + expect(helper.shouldApplyChallengeWhitelist({ isMachine: true })).to.equal(false); + }); + + it("does not apply challenge user whitelist checks to whitelist-only M2M callers", () => { + expect(helper.shouldApplyChallengeWhitelist({ bypassChallengeWhitelist: true })).to.equal( + false, + ); + }); + + it("continues to apply challenge user whitelist checks to interactive callers", () => { + expect( + helper.shouldApplyChallengeWhitelist({ + roles: ["administrator"], + userId: "blocked-user", + }), + ).to.equal(true); + }); +});