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/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. 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/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/docs/swagger.yaml b/docs/swagger.yaml index c2d9dcb..6bf3f66 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2716,6 +2716,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/package.json b/package.json index 491f3d1..3f70374 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "e2e:cov": "nyc --reporter=html --reporter=text npm run e2e", "services:up": "docker-compose -f ./local/docker-compose.yml up -d", "services:down": "docker-compose -f ./local/docker-compose.yml down", - "services:logs": "docker-compose -f ./local/docker-compose.yml logs" + "services:logs": "docker-compose -f ./local/docker-compose.yml logs", + "deploy:dev": "BRANCH=$(git rev-parse --abbrev-ref HEAD) && TAG=\"dev-${BRANCH}\" && git tag -d \"$TAG\" 2>/dev/null; git push origin \":refs/tags/$TAG\" 2>/dev/null; git tag \"$TAG\" && git push origin \"$TAG\"" }, "author": "TCSCODER", "license": "MIT", diff --git a/prisma/migrations/20260526100000_add_ai_review_phase_and_template/migration.sql b/prisma/migrations/20260526100000_add_ai_review_phase_and_template/migration.sql new file mode 100644 index 0000000..7723637 --- /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 (no predecessor — runs in parallel with 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', + NULL, + 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/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/common/challenge-helper.js b/src/common/challenge-helper.js index abe318e..d1a1500 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) @@ -789,7 +796,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 +855,8 @@ class ChallengeHelper { } } + challenge.stalled = ChallengeHelper.isChallengeStalled(challenge); + if (challenge.created) challenge.created = ChallengeHelper.convertDateToISOString(challenge.created); if (challenge.updated) @@ -892,6 +903,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/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/src/phase-management/PhaseAdvancer.js b/src/phase-management/PhaseAdvancer.js index 110bc29..32f68fa 100644 --- a/src/phase-management/PhaseAdvancer.js +++ b/src/phase-management/PhaseAdvancer.js @@ -352,6 +352,7 @@ class PhaseAdvancer { }); return ch?.numOfSubmissions || 0; } + async #areAllSubmissionsReviewed(challengeId) { console.log(`Evaluating review completion for challenge ${challengeId}`); 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..5632e5e 100644 --- a/src/scripts/seed/TimelineTemplate.json +++ b/src/scripts/seed/TimelineTemplate.json @@ -407,5 +407,33 @@ "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, + "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e" + }, + { + "phaseId": "c3a4d5e6-f7b8-4c9d-a0e1-2b3c4d5e6f7a", + "defaultDuration": 86400, + "predecessor": "6950164f-3c5e-4bdc-abc8-22aaf5a1bd49", + "id": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f" + } + ], + "createdAt": "2025-03-10T13:08:02.378Z", + "createdBy": "topcoder user", + "updatedAt": "2025-03-10T13:08:02.378Z", + "updatedBy": "topcoder user" } ] \ No newline at end of file diff --git a/src/services/ChallengePhaseService.js b/src/services/ChallengePhaseService.js index e7857f0..cc991d9 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"); @@ -541,6 +541,18 @@ async function ensureRequiredResourcesBeforeOpeningPhase(challenge, phaseName) { return; } + // 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") { + 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); @@ -791,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 f741e08..eff0b60 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -187,6 +187,229 @@ 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 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 shouldReplaceSelectedReviewSummation(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); +} + +/** + * 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 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 or fallback latest + * submitter summation has no final summation + */ +function selectMarathonMatchWinnerSummations( + challengeId, + reviewSummations, + 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 (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()); + } + + 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 (shouldReplaceSelectedReviewSummation(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; @@ -632,6 +855,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(); @@ -661,17 +885,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 +928,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 +944,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 +964,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`); } /** @@ -3686,10 +3910,62 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { } } + // Auto-select the AI Only timeline template for AI_ONLY challenges, and revert to the + // default template when the AI_ONLY config is removed. Runs on draft saves and activation. + const isDraftSave = [ChallengeStatusEnum.NEW, ChallengeStatusEnum.DRAFT].includes(challenge.status) && !isStatusChangingToActive; + let cachedActivationAiConfig = null; + let aiConfigFetched = false; + if (isStatusChangingToActive || isDraftSave) { + try { + cachedActivationAiConfig = await helper.getAIReviewConfigByChallengeId(challengeId); + aiConfigFetched = true; + const currentTemplateId = data.timelineTemplateId || challenge.timelineTemplateId; + if (cachedActivationAiConfig?.mode === 'AI_ONLY') { + if (currentTemplateId !== config.AI_ONLY_TIMELINE_TEMPLATE_ID) { + logger.debug( + `updateChallenge: AI_ONLY mode detected, switching to AI Only timeline template (challengeId=${challengeId})`, + ); + data.timelineTemplateId = config.AI_ONLY_TIMELINE_TEMPLATE_ID; + } + } else if (isDraftSave && currentTemplateId === config.AI_ONLY_TIMELINE_TEMPLATE_ID) { + // AI_ONLY config was removed; revert to the default template for this challenge's type+track + const defaultTemplates = await ChallengeTimelineTemplateService.searchChallengeTimelineTemplates({ + typeId: challenge.typeId, + trackId: challenge.trackId, + isDefault: true, + }); + const defaultTemplate = defaultTemplates.result[0]; + if (defaultTemplate) { + logger.debug( + `updateChallenge: AI_ONLY config removed, reverting to default timeline template ${defaultTemplate.timelineTemplateId} (challengeId=${challengeId})`, + ); + data.timelineTemplateId = defaultTemplate.timelineTemplateId; + } else { + logger.debug( + `updateChallenge: AI_ONLY config removed but no default template found for typeId=${challenge.typeId} trackId=${challenge.trackId}; keeping current template (challengeId=${challengeId})`, + ); + } + } + } catch (_err) { + // non-fatal: if AI config fetch fails, proceed without template override + logger.debug( + `updateChallenge: failed to fetch AI review config for template auto-select (challengeId=${challengeId}): ${_err.message}`, + ); + } + } + // TODO: Fix this Tech Debt once legacy is turned off const finalStatus = data.status || challenge.status; const finalTimelineTemplateId = data.timelineTemplateId || challenge.timelineTemplateId; let timelineTemplateChanged = false; + const isAiOnlyTemplateSwitch = cachedActivationAiConfig?.mode === 'AI_ONLY'; + // True when the AI_ONLY config was removed and we are auto-reverting the template back to default. + // Requires a confirmed fetch (aiConfigFetched) so we don't revert on transient API failures. + const isAiOnlyTemplateRevert = + aiConfigFetched && + isDraftSave && + challenge.timelineTemplateId === config.AI_ONLY_TIMELINE_TEMPLATE_ID && + cachedActivationAiConfig?.mode !== 'AI_ONLY'; if ( !currentUser.isMachine && !hasAdminRole(currentUser) && @@ -3698,11 +3974,20 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { ) { if ( finalStatus !== ChallengeStatusEnum.NEW && - finalTimelineTemplateId !== challenge.timelineTemplateId + finalTimelineTemplateId !== challenge.timelineTemplateId && + !isAiOnlyTemplateSwitch && + !isAiOnlyTemplateRevert ) { throw new errors.BadRequestError( `Cannot change the timelineTemplateId for challenges with status: ${finalStatus}`, ); + } else if ( + (isAiOnlyTemplateSwitch || isAiOnlyTemplateRevert) && + finalTimelineTemplateId !== challenge.timelineTemplateId + ) { + // Auto-managed template change: clear existing phases so they are re-populated from the new template + challenge.phases = []; + timelineTemplateChanged = true; } } else if (finalTimelineTemplateId !== challenge.timelineTemplateId) { // make sure there are no previous phases if the timeline template has changed @@ -3798,9 +4083,12 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { !hadAIReviewersBeforeUpdate && hasAIReviewersAfterUpdate; logger.debug(`updateChallenge: isActiveWithNewAIReviewers=${isActiveWithNewAIReviewers}`); - const shouldEnsureAIScreeningPhase = isStatusChangingToActive || isActiveWithNewAIReviewers; + // AI_ONLY challenges use the AI Review phase instead of AI Screening; never add AI Screening for them + const isAiOnlyActivation = isStatusChangingToActive && cachedActivationAiConfig?.mode === 'AI_ONLY'; + const shouldEnsureAIScreeningPhase = + (isStatusChangingToActive && !isAiOnlyActivation) || isActiveWithNewAIReviewers; logger.debug( - `updateChallenge: shouldEnsureAIScreeningPhase=${shouldEnsureAIScreeningPhase} isStatusChangingToActive=${isStatusChangingToActive}`, + `updateChallenge: shouldEnsureAIScreeningPhase=${shouldEnsureAIScreeningPhase} isStatusChangingToActive=${isStatusChangingToActive} isAiOnlyActivation=${isAiOnlyActivation}`, ); // Add AI screening phase when activating a challenge, or when AI reviewers are newly added @@ -4042,77 +4330,89 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { !isStandardTaskType && (challenge.status === ChallengeStatusEnum.NEW || challenge.status === ChallengeStatusEnum.DRAFT) ) { - const effectiveReviewers = Array.isArray(data.reviewers) - ? data.reviewers - : Array.isArray(challenge.reviewers) - ? challenge.reviewers - : []; - - const reviewersMissingFields = []; - effectiveReviewers.forEach((reviewer, index) => { - const hasScorecardId = - reviewer && !_.isNil(reviewer.scorecardId) && String(reviewer.scorecardId).trim() !== ""; - const hasPhaseId = - reviewer && !_.isNil(reviewer.phaseId) && String(reviewer.phaseId).trim() !== ""; - - if (!hasScorecardId || !hasPhaseId) { - const missing = []; - if (!hasScorecardId) missing.push("scorecardId"); - if (!hasPhaseId) missing.push("phaseId"); - reviewersMissingFields.push(`reviewer[${index}] missing ${missing.join(" and ")}`); - } - }); - - if (reviewersMissingFields.length > 0) { - throw new errors.BadRequestError( - `Cannot activate challenge; reviewers are missing required fields: ${reviewersMissingFields.join( - "; ", - )}`, - ); - } + // For AI_ONLY review mode, manual reviewers are not required; skip validation + let isAiOnlyReviewMode = false; + try { + // Reuse the config fetched earlier for template auto-select if available + const activationAiConfig = cachedActivationAiConfig ?? await helper.getAIReviewConfigByChallengeId(challengeId); + isAiOnlyReviewMode = activationAiConfig?.mode === 'AI_ONLY'; + } catch (_err) { + // non-fatal: proceed with standard reviewer validation if AI config fetch fails + } + + if (!isAiOnlyReviewMode) { + const effectiveReviewers = Array.isArray(data.reviewers) + ? data.reviewers + : Array.isArray(challenge.reviewers) + ? challenge.reviewers + : []; + + const reviewersMissingFields = []; + effectiveReviewers.forEach((reviewer, index) => { + const hasScorecardId = + reviewer && !_.isNil(reviewer.scorecardId) && String(reviewer.scorecardId).trim() !== ""; + const hasPhaseId = + reviewer && !_.isNil(reviewer.phaseId) && String(reviewer.phaseId).trim() !== ""; + + if (!hasScorecardId || !hasPhaseId) { + const missing = []; + if (!hasScorecardId) missing.push("scorecardId"); + if (!hasPhaseId) missing.push("phaseId"); + reviewersMissingFields.push(`reviewer[${index}] missing ${missing.join(" and ")}`); + } + }); - const reviewerPhaseIds = new Set( - effectiveReviewers - .filter((reviewer) => reviewer && reviewer.phaseId) - .map((reviewer) => String(reviewer.phaseId)), - ); + if (reviewersMissingFields.length > 0) { + throw new errors.BadRequestError( + `Cannot activate challenge; reviewers are missing required fields: ${reviewersMissingFields.join( + "; ", + )}`, + ); + } - if (reviewerPhaseIds.size === 0) { - throw new errors.BadRequestError( - "Cannot activate a challenge without at least one reviewer configured", + const reviewerPhaseIds = new Set( + effectiveReviewers + .filter((reviewer) => reviewer && reviewer.phaseId) + .map((reviewer) => String(reviewer.phaseId)), ); - } - - const normalizePhaseName = (name) => - String(name || "") - .trim() - .toLowerCase(); - const effectivePhases = - (Array.isArray(phasesForUpdate) && phasesForUpdate.length > 0 - ? phasesForUpdate - : challenge.phases) || []; - const missingPhaseNames = new Set(); - for (const phase of effectivePhases) { - if (!phase) { - continue; - } - const normalizedName = normalizePhaseName(phase.name); - if (!REQUIRED_REVIEW_PHASE_NAME_SET.has(normalizedName)) { - continue; + if (reviewerPhaseIds.size === 0) { + throw new errors.BadRequestError( + "Cannot activate a challenge without at least one reviewer configured", + ); } - const phaseId = _.get(phase, "phaseId"); - if (!phaseId || !reviewerPhaseIds.has(String(phaseId))) { - missingPhaseNames.add(phase.name || "Unknown phase"); + + const normalizePhaseName = (name) => + String(name || "") + .trim() + .toLowerCase(); + const effectivePhases = + (Array.isArray(phasesForUpdate) && phasesForUpdate.length > 0 + ? phasesForUpdate + : challenge.phases) || []; + + const missingPhaseNames = new Set(); + for (const phase of effectivePhases) { + if (!phase) { + continue; + } + const normalizedName = normalizePhaseName(phase.name); + if (!REQUIRED_REVIEW_PHASE_NAME_SET.has(normalizedName)) { + continue; + } + const phaseId = _.get(phase, "phaseId"); + if (!phaseId || !reviewerPhaseIds.has(String(phaseId))) { + missingPhaseNames.add(phase.name || "Unknown phase"); + } } - } - if (missingPhaseNames.size > 0) { - throw new errors.BadRequestError( - `Cannot activate challenge; missing reviewers for phase(s): ${Array.from( - missingPhaseNames, - ).join(", ")}`, - ); + if (missingPhaseNames.size > 0) { + throw new errors.BadRequestError( + `Cannot activate challenge; missing reviewers for phase(s): ${Array.from( + missingPhaseNames, + ).join(", ")}`, + ); + } } } @@ -5131,11 +5431,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( @@ -5278,9 +5578,16 @@ async function closeMarathonMatch(currentUser, challengeId) { const finalSummations = (reviewSummations || []).filter( (summation) => summation.isFinal === true, ); + const latestSubmissions = await getLatestMarathonMatchSubmissions(challengeId); + const winnerSummations = selectMarathonMatchWinnerSummations( + challengeId, + reviewSummations, + finalSummations, + latestSubmissions, + ); const orderedSummations = _.orderBy( - finalSummations, + winnerSummations, ["aggregateScore", "createdAt"], ["desc", "asc"], ); @@ -5414,7 +5721,7 @@ module.exports = { getDefaultReviewers, setDefaultReviewers, indexChallengeAndPostToKafka, - ensureAIScreeningCanBeClosed, + ensureAIPhaseCanBeClosed, }; logger.buildService(module.exports); 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: { diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index a7c23c3..6c5a47f 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 = { @@ -779,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" }, @@ -1230,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 }, @@ -2978,6 +2996,9 @@ describe("challenge service unit tests", () => { updatedBy: "admin", }, }); + await reviewClient.$executeRawUnsafe( + `DELETE FROM ${submissionTableName} WHERE "challengeId" = '${data.marathonMatchChallenge.id}'`, + ); } }); @@ -3055,6 +3076,178 @@ 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 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 () => [ diff --git a/test/unit/challenge-helper.test.js b/test/unit/challenge-helper.test.js index 4f508a3..e43e72f 100644 --- a/test/unit/challenge-helper.test.js +++ b/test/unit/challenge-helper.test.js @@ -1,7 +1,134 @@ -const chai = require('chai') +require("../../app-bootstrap"); -const challengeHelper = require('../../src/common/challenge-helper') +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); + }); +}); chai.should() describe('challenge response helper', () => { 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); + }); +});