diff --git a/.env.example b/.env.example index 94fb7ca..06e1376 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,14 @@ AUTH0_AUDIENCE= # Auth0 proxy server URL (optional) AUTH0_PROXY_SEREVR_URL= +# ------------------------------------- +# Finance API (optional) +# ------------------------------------- +# Base URL for the Finance API (e.g. http://localhost:4000/v6/finance) +FINANCE_API_URL= +# Request timeout when calling Finance API (ms) +FINANCE_API_TIMEOUT_MS=15000 + # ------------------------------------- # Sync Service Configuration # ------------------------------------- diff --git a/.gitignore b/.gitignore index fc157d1..5b7ce59 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ lerna-debug.log* # IDE .vscode/ +# E2E tests +secrets/m2m.json + # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/812522dc-eabb-4875-bc44-ea2ba9b90494.json b/812522dc-eabb-4875-bc44-ea2ba9b90494.json deleted file mode 100644 index 12ba41e..0000000 --- a/812522dc-eabb-4875-bc44-ea2ba9b90494.json +++ /dev/null @@ -1,853 +0,0 @@ - -> autopilot-service@0.0.1 pull:logs /home/jmgasper/Documents/Git/autopilot-v6 -> ts-node scripts/fetch-autopilot-actions.ts 812522dc-eabb-4875-bc44-ea2ba9b90494 - -[ - { - "id": "1ed86240-2208-48f6-8f89-a6821b327769", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "resources.getResourceById", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleName": "Test Role Fixed - Updated", - "resourceId": "9b45c581-57b1-4d9c-a546-f5c5ff72b6ad" - }, - "createdAt": "2025-09-29T02:12:33.686Z" - }, - { - "id": "415d677e-5d7e-43ea-ba47-90cc8e2f6e4e", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:12:33.931Z" - }, - { - "id": "7dd5f17a-126c-4b15-bfa6-a3691afb7ac6", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:12:34.972Z" - }, - { - "id": "0322d950-86f9-449e-9f77-47d4b86acc96", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:12:35.984Z" - }, - { - "id": "b2950b12-5f7c-4631-a0bf-e53cb6f18da4", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "resources.getResourceById", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleName": "Copilot", - "resourceId": "3a77dbfe-d2a1-451b-a562-264acf0f6c2c" - }, - "createdAt": "2025-09-29T02:12:37.423Z" - }, - { - "id": "a0823585-e6c0-45bb-aec5-2c909a755b90", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:12:37.429Z" - }, - { - "id": "50347bd1-0429-48e9-a327-4fac9bde6331", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "resources.getResourceById", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleName": "Reviewer", - "resourceId": "d131b692-60bc-4c61-bdae-e5c190063c4f" - }, - "createdAt": "2025-09-29T02:12:38.169Z" - }, - { - "id": "c1d33d87-d4ec-4cdb-bf51-a2f02ad57812", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:12:38.176Z" - }, - { - "id": "6b623512-f3de-4899-86b0-6c5b6c1c88e3", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "resources.getResourceById", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleName": "Submitter", - "resourceId": "93a3d3db-b9fd-47c6-ac2b-d07a53ab4b6b" - }, - "createdAt": "2025-09-29T02:12:38.938Z" - }, - { - "id": "cb1579d5-fdb4-4e2b-b9d6-075843ac37b6", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "resources.getResourceById", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleName": "Submitter", - "resourceId": "d9d217f8-0af9-4d1a-bfc3-9f208e3d8378" - }, - "createdAt": "2025-09-29T02:12:39.845Z" - }, - { - "id": "5bec8d61-047a-45a6-9b4f-83df31dab6af", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:13:35.819Z" - }, - { - "id": "a6720db2-c324-47e9-8ac4-954e9b855720", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:13:35.819Z" - }, - { - "id": "2685bf1c-6c5b-4395-99c4-81b368bc09aa", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "c6b69e49-5b4e-40cc-be0e-d050a487e735" - }, - "createdAt": "2025-09-29T02:13:35.820Z" - }, - { - "id": "19cc180a-1839-47a8-a73e-cfa6737bfb2f", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "c6b69e49-5b4e-40cc-be0e-d050a487e735" - }, - "createdAt": "2025-09-29T02:13:35.826Z" - }, - { - "id": "db666c11-8dac-4460-8709-9d4a094b0b76", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:13:35.826Z" - }, - { - "id": "94e2566c-64e8-4844-8c35-50911172c300", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:13:35.826Z" - }, - { - "id": "d50102ea-ca96-4ee7-a10d-25f4ce5c6bcc", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:13:35.933Z" - }, - { - "id": "721af2b9-bff8-4d4d-b3dd-24885c15ddf8", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:13:35.934Z" - }, - { - "id": "0ceb9cc5-a13d-4767-a1df-3b7488905f66", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "c6b69e49-5b4e-40cc-be0e-d050a487e735" - }, - "createdAt": "2025-09-29T02:13:35.934Z" - }, - { - "id": "fd9bef42-fd02-4311-bf08-7dafa977999c", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:13:35.937Z" - }, - { - "id": "61ed6a63-0ef3-4dfa-8f17-f68f0abebdc1", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:13:35.937Z" - }, - { - "id": "32c88b4c-29a8-4495-b0b5-207a362865ae", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "c6b69e49-5b4e-40cc-be0e-d050a487e735" - }, - "createdAt": "2025-09-29T02:13:35.938Z" - }, - { - "id": "c7bc00ba-fd5c-4a4e-bade-f1ed31b1a807", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.advancePhase", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseId": "c6b69e49-5b4e-40cc-be0e-d050a487e735", - "operation": "close", - "nextPhaseCount": 1, - "scheduleAdjusted": false, - "hasWinningSubmission": false - }, - "createdAt": "2025-09-29T02:13:35.959Z" - }, - { - "id": "932002b9-25eb-45c5-9199-4e3f4d444fd2", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "review.getActiveSubmissionCount", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "submissionCount": 2 - }, - "createdAt": "2025-09-29T02:13:35.962Z" - }, - { - "id": "19a00af9-cfbc-4520-9ae4-13735112e7e5", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.advancePhase", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseId": "c6b69e49-5b4e-40cc-be0e-d050a487e735", - "operation": "close", - "nextPhaseCount": 1, - "scheduleAdjusted": false, - "hasWinningSubmission": false - }, - "createdAt": "2025-09-29T02:13:35.965Z" - }, - { - "id": "243c3233-62f0-4d00-b245-3c30c61a1379", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "review.getActiveSubmissionCount", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "submissionCount": 2 - }, - "createdAt": "2025-09-29T02:13:35.967Z" - }, - { - "id": "92b95fd7-d2cd-4ff5-983f-084fc1f85a49", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:13:35.972Z" - }, - { - "id": "06a0956d-e5f0-4f81-8b7d-3ac788a06fec", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "resources.getReviewerResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "reviewerCount": 1 - }, - "createdAt": "2025-09-29T02:13:35.975Z" - }, - { - "id": "b858c3bc-f414-4b81-b798-44b309991277", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:13:35.982Z" - }, - { - "id": "95ffcfae-247b-4ccc-a773-6c422d3d5e8f", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "resources.getReviewerResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "reviewerCount": 1 - }, - "createdAt": "2025-09-29T02:13:35.984Z" - }, - { - "id": "f2976900-a3a2-467d-80e1-b9a96029537b", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:13:36.026Z" - }, - { - "id": "538d6192-d485-48fd-9603-4d71828ec71c", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:13:36.027Z" - }, - { - "id": "bbd7fb89-0b8e-4421-872d-b465ecc2db72", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "e0249df9-b2a7-469d-bd24-abcabfdd2a88" - }, - "createdAt": "2025-09-29T02:13:36.027Z" - }, - { - "id": "1f9c2f13-33f0-4790-b4e7-7147a6251f33", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:13:36.037Z" - }, - { - "id": "e0e46945-61dc-4b57-9026-3681bcddb98d", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:13:36.037Z" - }, - { - "id": "9469e515-c353-47c8-aaf0-459284d58ad2", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "e0249df9-b2a7-469d-bd24-abcabfdd2a88" - }, - "createdAt": "2025-09-29T02:13:36.037Z" - }, - { - "id": "689940f5-6192-4f05-963a-c45371f779a9", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:13:36.145Z" - }, - { - "id": "384761ce-7641-414e-9d02-0e30f43e8942", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:13:36.145Z" - }, - { - "id": "eb21d178-3426-4b94-b369-93c737964d5c", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:13:36.146Z" - }, - { - "id": "2a94190f-a4fd-4821-99b3-f24670d3f1c9", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "e0249df9-b2a7-469d-bd24-abcabfdd2a88" - }, - "createdAt": "2025-09-29T02:13:36.147Z" - }, - { - "id": "524b6006-040c-481a-be43-92c8f5a9194b", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "e0249df9-b2a7-469d-bd24-abcabfdd2a88" - }, - "createdAt": "2025-09-29T02:13:36.150Z" - }, - { - "id": "abbbb995-fb12-4916-a4ef-c6eab8e2e651", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "resources.hasSubmitterResource", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "submitterCount": 2 - }, - "createdAt": "2025-09-29T02:13:36.150Z" - }, - { - "id": "9e73a7db-0cee-4045-bb85-2a68e8be7e20", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:13:36.150Z" - }, - { - "id": "1fa8192f-a8b4-46c4-aac0-838c0d0ac6d4", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.advancePhase", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseId": "e0249df9-b2a7-469d-bd24-abcabfdd2a88", - "operation": "close", - "nextPhaseCount": 0, - "scheduleAdjusted": false, - "hasWinningSubmission": false - }, - "createdAt": "2025-09-29T02:13:36.174Z" - }, - { - "id": "3203fb42-6bb1-4a86-9fa1-0703a1c4d976", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "resources.hasSubmitterResource", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "submitterCount": 2 - }, - "createdAt": "2025-09-29T02:13:36.181Z" - }, - { - "id": "dae31f98-0dc9-41e8-aa15-de78d22076c1", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.advancePhase", - "status": "INFO", - "source": "ChallengeApiService", - "details": { - "result": { - "message": "Phase Registration is already closed", - "success": false - }, - "phaseId": "e0249df9-b2a7-469d-bd24-abcabfdd2a88", - "operation": "close" - }, - "createdAt": "2025-09-29T02:13:36.188Z" - }, - { - "id": "aec6e5c2-719c-4b53-a234-2097f1c773ed", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:15:00.728Z" - }, - { - "id": "86024f52-94fd-4ae0-9a62-c5bf6980745a", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "8137c396-6c32-4b69-87e5-46555c52e0a0" - }, - "createdAt": "2025-09-29T02:15:00.734Z" - }, - { - "id": "756d90e6-69b7-4849-9a60-51181fa188bd", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:15:00.734Z" - }, - { - "id": "631dcb79-8807-4f68-bd17-ab9e00b204bd", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:15:00.807Z" - }, - { - "id": "64d5809f-dd8b-4f8c-b1e6-f93f89c6dbe0", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:15:00.807Z" - }, - { - "id": "7fd4ee7a-0d76-4aac-8652-5cac41028570", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "8137c396-6c32-4b69-87e5-46555c52e0a0" - }, - "createdAt": "2025-09-29T02:15:00.808Z" - }, - { - "id": "383f3341-9483-4e79-a1a5-f43ce64126fd", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:15:00.950Z" - }, - { - "id": "01df4a7b-6ff3-4aca-a1a6-cdbd8aafbbe4", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "8137c396-6c32-4b69-87e5-46555c52e0a0" - }, - "createdAt": "2025-09-29T02:15:00.964Z" - }, - { - "id": "1c739947-24e1-44f8-aceb-ca9b7724f1e8", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:15:00.964Z" - }, - { - "id": "e400f56a-cffb-4c9e-9f09-a01905903281", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:15:00.981Z" - }, - { - "id": "6149ce41-2bac-4685-a1af-3b45f91a5129", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:15:00.999Z" - }, - { - "id": "24681221-b64e-4616-a234-c3c33e0ab297", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "8137c396-6c32-4b69-87e5-46555c52e0a0" - }, - "createdAt": "2025-09-29T02:15:01.000Z" - }, - { - "id": "6e2d6d32-1b3f-49a1-aaf3-604dd0f5f13b", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.advancePhase", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseId": "8137c396-6c32-4b69-87e5-46555c52e0a0", - "operation": "open", - "nextPhaseCount": 0, - "scheduleAdjusted": false, - "hasWinningSubmission": false - }, - "createdAt": "2025-09-29T02:15:01.085Z" - }, - { - "id": "fbef8631-4fe7-4e77-84c4-a594fda05bc4", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:15:01.145Z" - }, - { - "id": "20d9e470-c8e5-4be7-84c4-141d029e6313", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "resources.getReviewerResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "reviewerCount": 1 - }, - "createdAt": "2025-09-29T02:15:01.164Z" - }, - { - "id": "2d91c84d-115a-433a-b254-92d19333f3ee", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "review.getActiveSubmissionIds", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "submissionCount": 2 - }, - "createdAt": "2025-09-29T02:15:01.188Z" - }, - { - "id": "631022ee-8453-4642-9978-6736ba1b9e76", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "review.getExistingReviewPairs", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "phaseId": "8137c396-6c32-4b69-87e5-46555c52e0a0", - "pairCount": 0 - }, - "createdAt": "2025-09-29T02:15:01.191Z" - }, - { - "id": "d18f8891-7c3f-49e5-9faf-c6e1f26c2cad", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "review.createPendingReview", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "created": true, - "phaseId": "8137c396-6c32-4b69-87e5-46555c52e0a0", - "resourceId": "d131b692-60bc-4c61-bdae-e5c190063c4f", - "submissionId": "HdpgN5Cm3-CuR2" - }, - "createdAt": "2025-09-29T02:15:01.225Z" - }, - { - "id": "62e035c1-697e-4ff0-9f36-cd25401ac8c9", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "review.createPendingReview", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "created": true, - "phaseId": "8137c396-6c32-4b69-87e5-46555c52e0a0", - "resourceId": "d131b692-60bc-4c61-bdae-e5c190063c4f", - "submissionId": "HQR_cBlCW6koLV" - }, - "createdAt": "2025-09-29T02:15:01.250Z" - }, - { - "id": "fda9ae59-d55f-40ea-85c3-462c701b8de2", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.advancePhase", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseId": "8137c396-6c32-4b69-87e5-46555c52e0a0", - "operation": "open", - "nextPhaseCount": 0, - "scheduleAdjusted": false, - "hasWinningSubmission": false - }, - "createdAt": "2025-09-29T02:15:01.250Z" - }, - { - "id": "bf2dc7d2-b4f8-4649-82da-63ad4e536641", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 5 - }, - "createdAt": "2025-09-29T02:15:01.317Z" - }, - { - "id": "986178e8-f108-45dc-a6dc-3c0239d4e52e", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "resources.getReviewerResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "reviewerCount": 1 - }, - "createdAt": "2025-09-29T02:15:01.324Z" - }, - { - "id": "3f5b4c0e-4d5a-41d2-9451-6bd743fe0703", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "review.getActiveSubmissionIds", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "submissionCount": 2 - }, - "createdAt": "2025-09-29T02:15:01.332Z" - }, - { - "id": "7fefac70-a774-49fb-9449-c4388e7c9cd5", - "challengeId": "812522dc-eabb-4875-bc44-ea2ba9b90494", - "action": "review.getExistingReviewPairs", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "phaseId": "8137c396-6c32-4b69-87e5-46555c52e0a0", - "pairCount": 2 - }, - "createdAt": "2025-09-29T02:15:01.344Z" - } -] diff --git a/package.json b/package.json index 4b3de5b..81c81e1 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", + "test:flows": "tsx scripts/run-autopilot-flows.ts", "prisma:generate": "prisma generate --schema prisma/challenge.schema.prisma && prisma generate --schema prisma/autopilot.schema.prisma", "postinstall": "prisma generate --schema prisma/challenge.schema.prisma && prisma generate --schema prisma/autopilot.schema.prisma && patch-package", "prisma:pushautopilot": "prisma db push --schema prisma/autopilot.schema.prisma", @@ -57,6 +58,7 @@ "winston": "^3.17.0" }, "devDependencies": { + "@aws-sdk/client-s3": "^3.645.0", "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.18.0", "@nestjs/schematics": "^11.0.0", @@ -68,11 +70,14 @@ "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.2", "@types/uuid": "^9.0.8", + "dayjs": "^1.11.13", + "eventemitter3": "^5.0.1", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", "globals": "^16.0.0", "jest": "^29.7.0", + "nanoid": "^5.0.7", "nodemon": "^3.0.0", "patch-package": "^8.0.0", "prettier": "^3.4.2", @@ -83,6 +88,7 @@ "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", "tsconfig-paths": "^4.2.0", + "tsx": "^4.16.2", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0412ca9..fa0f371 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: specifier: ^3.17.0 version: 3.17.0 devDependencies: + '@aws-sdk/client-s3': + specifier: ^3.645.0 + version: 3.901.0 '@eslint/eslintrc': specifier: ^3.2.0 version: 3.3.1 @@ -126,6 +129,9 @@ importers: '@types/uuid': specifier: ^9.0.8 version: 9.0.8 + dayjs: + specifier: ^1.11.13 + version: 1.11.18 eslint: specifier: ^9.18.0 version: 9.31.0(jiti@2.5.1) @@ -135,12 +141,18 @@ importers: eslint-plugin-prettier: specifier: ^5.2.2 version: 5.5.1(@types/eslint@9.6.1)(eslint-config-prettier@10.1.5(eslint@9.31.0(jiti@2.5.1)))(eslint@9.31.0(jiti@2.5.1))(prettier@3.6.2) + eventemitter3: + specifier: ^5.0.1 + version: 5.0.1 globals: specifier: ^16.0.0 version: 16.3.0 jest: specifier: ^29.7.0 version: 29.7.0(@types/node@22.16.4)(ts-node@10.9.2(@swc/core@1.12.14)(@types/node@22.16.4)(typescript@5.8.3)) + nanoid: + specifier: ^5.0.7 + version: 5.1.6 nodemon: specifier: ^3.0.0 version: 3.1.10 @@ -171,6 +183,9 @@ importers: tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 + tsx: + specifier: ^4.16.2 + version: 4.20.6 typescript: specifier: ^5.7.3 version: 5.8.3 @@ -293,6 +308,161 @@ packages: cpu: [x64] os: [win32] + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-s3@3.901.0': + resolution: {integrity: sha512-wyKhZ51ur1tFuguZ6PgrUsot9KopqD0Tmxw8O8P/N3suQDxFPr0Yo7Y77ezDRDZQ95Ml3C0jlvx79HCo8VxdWA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/client-sso@3.901.0': + resolution: {integrity: sha512-sGyDjjkJ7ppaE+bAKL/Q5IvVCxtoyBIzN+7+hWTS/mUxWJ9EOq9238IqmVIIK6sYNIzEf9yhobfMARasPYVTNg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/core@3.901.0': + resolution: {integrity: sha512-brKAc3y64tdhyuEf+OPIUln86bRTqkLgb9xkd6kUdIeA5+qmp/N6amItQz+RN4k4O3kqkCPYnAd3LonTKluobw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-env@3.901.0': + resolution: {integrity: sha512-5hAdVl3tBuARh3zX5MLJ1P/d+Kr5kXtDU3xm1pxUEF4xt2XkEEpwiX5fbkNkz2rbh3BCt2gOHsAbh6b3M7n+DA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-http@3.901.0': + resolution: {integrity: sha512-Ggr7+0M6QZEsrqRkK7iyJLf4LkIAacAxHz9c4dm9hnDdU7vqrlJm6g73IxMJXWN1bIV7IxfpzB11DsRrB/oNjQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-ini@3.901.0': + resolution: {integrity: sha512-zxadcDS0hNJgv8n4hFYJNOXyfjaNE1vvqIiF/JzZSQpSSYXzCd+WxXef5bQh+W3giDtRUmkvP5JLbamEFjZKyw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-node@3.901.0': + resolution: {integrity: sha512-dPuFzMF7L1s/lQyT3wDxqLe82PyTH+5o1jdfseTEln64LJMl0ZMWaKX/C1UFNDxaTd35Cgt1bDbjjAWHMiKSFQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-process@3.901.0': + resolution: {integrity: sha512-/IWgmgM3Cl1wTdJA5HqKMAojxLkYchh5kDuphApxKhupLu6Pu0JBOHU8A5GGeFvOycyaVwosod6zDduINZxe+A==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-sso@3.901.0': + resolution: {integrity: sha512-SjmqZQHmqFSET7+6xcZgtH7yEyh5q53LN87GqwYlJZ6KJ5oNw11acUNEhUOL1xTSJEvaWqwTIkS2zqrzLcM9bw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.901.0': + resolution: {integrity: sha512-NYjy/6NLxH9m01+pfpB4ql8QgAorJcu8tw69kzHwUd/ql6wUDTbC7HcXqtKlIwWjzjgj2BKL7j6SyFapgCuafA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-bucket-endpoint@3.901.0': + resolution: {integrity: sha512-mPF3N6eZlVs9G8aBSzvtoxR1RZqMo1aIwR+X8BAZSkhfj55fVF2no4IfPXfdFO3I66N+zEQ8nKoB0uTATWrogQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-expect-continue@3.901.0': + resolution: {integrity: sha512-bwq9nj6MH38hlJwOY9QXIDwa6lI48UsaZpaXbdD71BljEIRlxDzfB4JaYb+ZNNK7RIAdzsP/K05mJty6KJAQHw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-flexible-checksums@3.901.0': + resolution: {integrity: sha512-63lcKfggVUFyXhE4SsFXShCTCyh7ZHEqXLyYEL4DwX+VWtxutf9t9m3fF0TNUYDE8eEGWiRXhegj8l4FjuW+wA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-host-header@3.901.0': + resolution: {integrity: sha512-yWX7GvRmqBtbNnUW7qbre3GvZmyYwU0WHefpZzDTYDoNgatuYq6LgUIQ+z5C04/kCRoFkAFrHag8a3BXqFzq5A==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-location-constraint@3.901.0': + resolution: {integrity: sha512-MuCS5R2ngNoYifkVt05CTULvYVWX0dvRT0/Md4jE3a0u0yMygYy31C1zorwfE/SUgAQXyLmUx8ATmPp9PppImQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-logger@3.901.0': + resolution: {integrity: sha512-UoHebjE7el/tfRo8/CQTj91oNUm+5Heus5/a4ECdmWaSCHCS/hXTsU3PTTHAY67oAQR8wBLFPfp3mMvXjB+L2A==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.901.0': + resolution: {integrity: sha512-Wd2t8qa/4OL0v/oDpCHHYkgsXJr8/ttCxrvCKAt0H1zZe2LlRhY9gpDVKqdertfHrHDj786fOvEQA28G1L75Dg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.901.0': + resolution: {integrity: sha512-prgjVC3fDT2VIlmQPiw/cLee8r4frTam9GILRUVQyDdNtshNwV3MiaSCLzzQJjKJlLgnBLNUHJCSmvUVtg+3iA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-ssec@3.901.0': + resolution: {integrity: sha512-YiLLJmA3RvjL38mFLuu8fhTTGWtp2qT24VqpucgfoyziYcTgIQkJJmKi90Xp6R6/3VcArqilyRgM1+x8i/em+Q==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-user-agent@3.901.0': + resolution: {integrity: sha512-Zby4F03fvD9xAgXGPywyk4bC1jCbnyubMEYChLYohD+x20ULQCf+AimF/Btn7YL+hBpzh1+RmqmvZcx+RgwgNQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/nested-clients@3.901.0': + resolution: {integrity: sha512-feAAAMsVwctk2Tms40ONybvpfJPLCmSdI+G+OTrNpizkGLNl6ik2Ng2RzxY6UqOfN8abqKP/DOUj1qYDRDG8ag==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/region-config-resolver@3.901.0': + resolution: {integrity: sha512-7F0N888qVLHo4CSQOsnkZ4QAp8uHLKJ4v3u09Ly5k4AEStrSlFpckTPyUx6elwGL+fxGjNE2aakK8vEgzzCV0A==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.901.0': + resolution: {integrity: sha512-2IWxbll/pRucp1WQkHi2W5E2SVPGBvk4Is923H7gpNksbVFws18ItjMM8ZpGm44cJEoy1zR5gjhLFklatpuoOw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/token-providers@3.901.0': + resolution: {integrity: sha512-pJEr1Ggbc/uVTDqp9IbNu9hdr0eQf3yZix3s4Nnyvmg4xmJSGAlbPC9LrNr5u3CDZoc8Z9CuLrvbP4MwYquNpQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/types@3.901.0': + resolution: {integrity: sha512-FfEM25hLEs4LoXsLXQ/q6X6L4JmKkKkbVFpKD4mwfVHtRVQG6QxJiCPcrkcPISquiy6esbwK2eh64TWbiD60cg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-arn-parser@3.893.0': + resolution: {integrity: sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-endpoints@3.901.0': + resolution: {integrity: sha512-5nZP3hGA8FHEtKvEQf4Aww5QZOkjLW1Z+NixSd+0XKfHvA39Ah5sZboScjLx0C9kti/K3OGW1RCx5K9Zc3bZqg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-locate-window@3.893.0': + resolution: {integrity: sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-user-agent-browser@3.901.0': + resolution: {integrity: sha512-Ntb6V/WFI21Ed4PDgL/8NSfoZQQf9xzrwNgiwvnxgAl/KvAvRBgQtqj5gHsDX8Nj2YmJuVoHfH9BGjL9VQ4WNg==} + + '@aws-sdk/util-user-agent-node@3.901.0': + resolution: {integrity: sha512-l59KQP5TY7vPVUfEURc7P5BJKuNg1RSsAKBQW7LHLECXjLqDUbo2SMLrexLBEoArSt6E8QOrIN0C8z/0Xk0jYw==} + engines: {node: '>=18.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.901.0': + resolution: {integrity: sha512-pxFCkuAP7Q94wMTNPAwi6hEtNrp/BdFf+HOrIEeFQsk4EoOmpKY3I6S+u6A9Wg295J80Kh74LqDWM22ux3z6Aw==} + engines: {node: '>=18.0.0'} + + '@aws/lambda-invoke-store@0.0.1': + resolution: {integrity: sha512-ORHRQ2tmvnBXc8t/X9Z8IcSbBA4xTLKuN873FopzklHMeqBst7YG0d+AX97inkvDX+NChYtSr+qGfcqGFaI8Zw==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -482,6 +652,162 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@esbuild/aix-ppc64@0.25.10': + resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.10': + resolution: {integrity: sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.10': + resolution: {integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.10': + resolution: {integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.10': + resolution: {integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.10': + resolution: {integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.10': + resolution: {integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.10': + resolution: {integrity: sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.10': + resolution: {integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.10': + resolution: {integrity: sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.10': + resolution: {integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.10': + resolution: {integrity: sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.10': + resolution: {integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.10': + resolution: {integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.10': + resolution: {integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.10': + resolution: {integrity: sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.10': + resolution: {integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.10': + resolution: {integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.10': + resolution: {integrity: sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.10': + resolution: {integrity: sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.10': + resolution: {integrity: sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.10': + resolution: {integrity: sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.10': + resolution: {integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.10': + resolution: {integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.10': + resolution: {integrity: sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.10': + resolution: {integrity: sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.7.0': resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1308,6 +1634,222 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@smithy/abort-controller@4.2.0': + resolution: {integrity: sha512-PLUYa+SUKOEZtXFURBu/CNxlsxfaFGxSBPcStL13KpVeVWIfdezWyDqkz7iDLmwnxojXD0s5KzuB5HGHvt4Aeg==} + engines: {node: '>=18.0.0'} + + '@smithy/chunked-blob-reader-native@4.2.0': + resolution: {integrity: sha512-HNbGWdyTfSM1nfrZKQjYTvD8k086+M8s1EYkBUdGC++lhxegUp2HgNf5RIt6oOGVvsC26hBCW/11tv8KbwLn/Q==} + engines: {node: '>=18.0.0'} + + '@smithy/chunked-blob-reader@5.2.0': + resolution: {integrity: sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==} + engines: {node: '>=18.0.0'} + + '@smithy/config-resolver@4.3.0': + resolution: {integrity: sha512-9oH+n8AVNiLPK/iK/agOsoWfrKZ3FGP3502tkksd6SRsKMYiu7AFX0YXo6YBADdsAj7C+G/aLKdsafIJHxuCkQ==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.14.0': + resolution: {integrity: sha512-XJ4z5FxvY/t0Dibms/+gLJrI5niRoY0BCmE02fwmPcRYFPI4KI876xaE79YGWIKnEslMbuQPsIEsoU/DXa0DoA==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.0': + resolution: {integrity: sha512-SOhFVvFH4D5HJZytb0bLKxCrSnwcqPiNlrw+S4ZXjMnsC+o9JcUQzbZOEQcA8yv9wJFNhfsUiIUKiEnYL68Big==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-codec@4.2.0': + resolution: {integrity: sha512-XE7CtKfyxYiNZ5vz7OvyTf1osrdbJfmUy+rbh+NLQmZumMGvY0mT0Cq1qKSfhrvLtRYzMsOBuRpi10dyI0EBPg==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-browser@4.2.0': + resolution: {integrity: sha512-U53p7fcrk27k8irLhOwUu+UYnBqsXNLKl1XevOpsxK3y1Lndk8R7CSiZV6FN3fYFuTPuJy5pP6qa/bjDzEkRvA==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-config-resolver@4.3.0': + resolution: {integrity: sha512-uwx54t8W2Yo9Jr3nVF5cNnkAAnMCJ8Wrm+wDlQY6rY/IrEgZS3OqagtCu/9ceIcZFQ1zVW/zbN9dxb5esuojfA==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-node@4.2.0': + resolution: {integrity: sha512-yjM2L6QGmWgJjVu/IgYd6hMzwm/tf4VFX0lm8/SvGbGBwc+aFl3hOzvO/e9IJ2XI+22Tx1Zg3vRpFRs04SWFcg==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-universal@4.2.0': + resolution: {integrity: sha512-C3jxz6GeRzNyGKhU7oV656ZbuHY93mrfkT12rmjDdZch142ykjn8do+VOkeRNjSGKw01p4g+hdalPYPhmMwk1g==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.0': + resolution: {integrity: sha512-BG3KSmsx9A//KyIfw+sqNmWFr1YBUr+TwpxFT7yPqAk0yyDh7oSNgzfNH7pS6OC099EGx2ltOULvumCFe8bcgw==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-blob-browser@4.2.0': + resolution: {integrity: sha512-MWmrRTPqVKpN8NmxmJPTeQuhewTt8Chf+waB38LXHZoA02+BeWYVQ9ViAwHjug8m7lQb1UWuGqp3JoGDOWvvuA==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.0': + resolution: {integrity: sha512-ugv93gOhZGysTctZh9qdgng8B+xO0cj+zN0qAZ+Sgh7qTQGPOJbMdIuyP89KNfUyfAqFSNh5tMvC+h2uCpmTtA==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-stream-node@4.2.0': + resolution: {integrity: sha512-8dELAuGv+UEjtzrpMeNBZc1sJhO8GxFVV/Yh21wE35oX4lOE697+lsMHBoUIFAUuYkTMIeu0EuJSEsH7/8Y+UQ==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.0': + resolution: {integrity: sha512-ZmK5X5fUPAbtvRcUPtk28aqIClVhbfcmfoS4M7UQBTnDdrNxhsrxYVv0ZEl5NaPSyExsPWqL4GsPlRvtlwg+2A==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.2.0': + resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} + engines: {node: '>=18.0.0'} + + '@smithy/md5-js@4.2.0': + resolution: {integrity: sha512-LFEPniXGKRQArFmDQ3MgArXlClFJMsXDteuQQY8WG1/zzv6gVSo96+qpkuu1oJp4MZsKrwchY0cuAoPKzEbaNA==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.0': + resolution: {integrity: sha512-6ZAnwrXFecrA4kIDOcz6aLBhU5ih2is2NdcZtobBDSdSHtE9a+MThB5uqyK4XXesdOCvOcbCm2IGB95birTSOQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.3.0': + resolution: {integrity: sha512-jFVjuQeV8TkxaRlcCNg0GFVgg98tscsmIrIwRFeC74TIUyLE3jmY9xgc1WXrPQYRjQNK3aRoaIk6fhFRGOIoGw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.4.0': + resolution: {integrity: sha512-yaVBR0vQnOnzex45zZ8ZrPzUnX73eUC8kVFaAAbn04+6V7lPtxn56vZEBBAhgS/eqD6Zm86o6sJs6FuQVoX5qg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.0': + resolution: {integrity: sha512-rpTQ7D65/EAbC6VydXlxjvbifTf4IH+sADKg6JmAvhkflJO2NvDeyU9qsWUNBelJiQFcXKejUHWRSdmpJmEmiw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.0': + resolution: {integrity: sha512-G5CJ//eqRd9OARrQu9MK1H8fNm2sMtqFh6j8/rPozhEL+Dokpvi1Og+aCixTuwDAGZUkJPk6hJT5jchbk/WCyg==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.0': + resolution: {integrity: sha512-5QgHNuWdT9j9GwMPPJCKxy2KDxZ3E5l4M3/5TatSZrqYVoEiqQrDfAq8I6KWZw7RZOHtVtCzEPdYz7rHZixwcA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.3.0': + resolution: {integrity: sha512-RHZ/uWCmSNZ8cneoWEVsVwMZBKy/8123hEpm57vgGXA3Irf/Ja4v9TVshHK2ML5/IqzAZn0WhINHOP9xl+Qy6Q==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.0': + resolution: {integrity: sha512-rV6wFre0BU6n/tx2Ztn5LdvEdNZ2FasQbPQmDOPfV9QQyDmsCkOAB0osQjotRCQg+nSKFmINhyda0D3AnjSBJw==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.0': + resolution: {integrity: sha512-6POSYlmDnsLKb7r1D3SVm7RaYW6H1vcNcTWGWrF7s9+2noNYvUsm7E4tz5ZQ9HXPmKn6Hb67pBDRIjrT4w/d7Q==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.0': + resolution: {integrity: sha512-Q4oFD0ZmI8yJkiPPeGUITZj++4HHYCW3pYBYfIobUCkYpI6mbkzmG1MAQQ3lJYYWj3iNqfzOenUZu+jqdPQ16A==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.0': + resolution: {integrity: sha512-BjATSNNyvVbQxOOlKse0b0pSezTWGMvA87SvoFoFlkRsKXVsN3bEtjCxvsNXJXfnAzlWFPaT9DmhWy1vn0sNEA==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.2.0': + resolution: {integrity: sha512-Ylv1ttUeKatpR0wEOMnHf1hXMktPUMObDClSWl2TpCVT4DwtJhCeighLzSLbgH3jr5pBNM0LDXT5yYxUvZ9WpA==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.3.0': + resolution: {integrity: sha512-VCUPPtNs+rKWlqqntX0CbVvWyjhmX30JCtzO+s5dlzzxrvSfRh5SY0yxnkirvc1c80vdKQttahL71a9EsdolSQ==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.0': + resolution: {integrity: sha512-MKNyhXEs99xAZaFhm88h+3/V+tCRDQ+PrDzRqL0xdDpq4gjxcMmf5rBA3YXgqZqMZ/XwemZEurCBQMfxZOWq/g==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.7.0': + resolution: {integrity: sha512-3BDx/aCCPf+kkinYf5QQhdQ9UAGihgOVqI3QO5xQfSaIWvUE4KYLtiGRWsNe1SR7ijXC0QEPqofVp5Sb0zC8xQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.6.0': + resolution: {integrity: sha512-4lI9C8NzRPOv66FaY1LL1O/0v0aLVrq/mXP/keUa9mJOApEeae43LsLd2kZRUJw91gxOQfLIrV3OvqPgWz1YsA==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.0': + resolution: {integrity: sha512-AlBmD6Idav2ugmoAL6UtR6ItS7jU5h5RNqLMZC7QrLCoITA9NzIN3nx9GWi8g4z1pfWh2r9r96SX/jHiNwPJ9A==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.2.0': + resolution: {integrity: sha512-+erInz8WDv5KPe7xCsJCp+1WCjSbah9gWcmUXc9NqmhyPx59tf7jqFz+za1tRG1Y5KM1Cy1rWCcGypylFp4mvA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.0': + resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.0': + resolution: {integrity: sha512-U8q1WsSZFjXijlD7a4wsDQOvOwV+72iHSfq1q7VD+V75xP/pdtm0WIGuaFJ3gcADDOKj2MIBn4+zisi140HEnQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.0': + resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.0': + resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.2.0': + resolution: {integrity: sha512-qzHp7ZDk1Ba4LDwQVCNp90xPGqSu7kmL7y5toBpccuhi3AH7dcVBIT/pUxYcInK4jOy6FikrcTGq5wxcka8UaQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.0': + resolution: {integrity: sha512-FxUHS3WXgx3bTWR6yQHNHHkQHZm/XKIi/CchTnKvBulN6obWpcbzJ6lDToXn+Wp0QlVKd7uYAz2/CTw1j7m+Kg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.2.0': + resolution: {integrity: sha512-TXeCn22D56vvWr/5xPqALc9oO+LN+QpFjrSM7peG/ckqEPoI3zaKZFp+bFwfmiHhn5MGWPaLCqDOJPPIixk9Wg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.0': + resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.0': + resolution: {integrity: sha512-u9OOfDa43MjagtJZ8AapJcmimP+K2Z7szXn8xbty4aza+7P1wjFmy2ewjSbhEiYQoW1unTlOAIV165weYAaowA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.2.0': + resolution: {integrity: sha512-BWSiuGbwRnEE2SFfaAZEX0TqaxtvtSYPM/J73PFVm+A29Fg1HTPiYFb8TmX1DXp4hgcdyJcNQmprfd5foeORsg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.4.0': + resolution: {integrity: sha512-vtO7ktbixEcrVzMRmpQDnw/Ehr9UWjBvSJ9fyAbadKkC4w5Cm/4lMO8cHz8Ysb8uflvQUNRcuux/oNHKPXkffg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.0': + resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.0': + resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-waiter@4.2.0': + resolution: {integrity: sha512-0Z+nxUU4/4T+SL8BCNN4ztKdQjToNvUYmkF1kXO5T7Yz3Gafzh0HeIG6mrkN8Fz3gn9hSyxuAT+6h4vM+iQSBQ==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.0': + resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} + engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -1887,6 +2429,9 @@ packages: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} + bowser@2.12.1: + resolution: {integrity: sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==} + boxen@5.1.2: resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} engines: {node: '>=10'} @@ -2185,6 +2730,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + dayjs@1.11.18: + resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -2364,6 +2912,11 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + esbuild@0.25.10: + resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -2458,6 +3011,9 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -2522,6 +3078,10 @@ packages: fast-uri@3.0.6: resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} + fast-xml-parser@5.2.5: + resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==} + hasBin: true + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -2680,6 +3240,9 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} + get-tsconfig@4.11.0: + resolution: {integrity: sha512-sNsqf7XKQ38IawiVGPOoAlqZo1DMrO7TU+ZcZwi7yLl7/7S0JwmoBMKz/IkUPhSoXM0Ng3vT0yB1iCe5XavDeQ==} + giget@2.0.0: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true @@ -3375,6 +3938,11 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3736,6 +4304,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve.exports@2.0.3: resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} engines: {node: '>=10'} @@ -3984,6 +4555,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strnum@2.1.1: + resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==} + strtok3@10.3.2: resolution: {integrity: sha512-or9w505RhhY66+uoe5YOC5QO/bRuATaoim3XTh+pGKx5VMWi/HDhMKuCjDLsLJouU2zg9Hf1nLPcNW7IHv80kQ==} engines: {node: '>=18'} @@ -4181,6 +4755,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.20.6: + resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4475,26 +5054,494 @@ snapshots: '@antoniomuso/lz4-napi-linux-arm-gnueabihf@2.9.0': optional: true - '@antoniomuso/lz4-napi-linux-arm64-gnu@2.9.0': - optional: true + '@antoniomuso/lz4-napi-linux-arm64-gnu@2.9.0': + optional: true + + '@antoniomuso/lz4-napi-linux-arm64-musl@2.9.0': + optional: true + + '@antoniomuso/lz4-napi-linux-x64-gnu@2.9.0': + optional: true + + '@antoniomuso/lz4-napi-linux-x64-musl@2.9.0': + optional: true + + '@antoniomuso/lz4-napi-win32-arm64-msvc@2.9.0': + optional: true + + '@antoniomuso/lz4-napi-win32-ia32-msvc@2.9.0': + optional: true + + '@antoniomuso/lz4-napi-win32-x64-msvc@2.9.0': + optional: true + + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.901.0 + tslib: 2.8.1 + + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.901.0 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-locate-window': 3.893.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-locate-window': 3.893.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.901.0 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.901.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.901.0 + '@aws-sdk/credential-provider-node': 3.901.0 + '@aws-sdk/middleware-bucket-endpoint': 3.901.0 + '@aws-sdk/middleware-expect-continue': 3.901.0 + '@aws-sdk/middleware-flexible-checksums': 3.901.0 + '@aws-sdk/middleware-host-header': 3.901.0 + '@aws-sdk/middleware-location-constraint': 3.901.0 + '@aws-sdk/middleware-logger': 3.901.0 + '@aws-sdk/middleware-recursion-detection': 3.901.0 + '@aws-sdk/middleware-sdk-s3': 3.901.0 + '@aws-sdk/middleware-ssec': 3.901.0 + '@aws-sdk/middleware-user-agent': 3.901.0 + '@aws-sdk/region-config-resolver': 3.901.0 + '@aws-sdk/signature-v4-multi-region': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-endpoints': 3.901.0 + '@aws-sdk/util-user-agent-browser': 3.901.0 + '@aws-sdk/util-user-agent-node': 3.901.0 + '@aws-sdk/xml-builder': 3.901.0 + '@smithy/config-resolver': 4.3.0 + '@smithy/core': 3.14.0 + '@smithy/eventstream-serde-browser': 4.2.0 + '@smithy/eventstream-serde-config-resolver': 4.3.0 + '@smithy/eventstream-serde-node': 4.2.0 + '@smithy/fetch-http-handler': 5.3.0 + '@smithy/hash-blob-browser': 4.2.0 + '@smithy/hash-node': 4.2.0 + '@smithy/hash-stream-node': 4.2.0 + '@smithy/invalid-dependency': 4.2.0 + '@smithy/md5-js': 4.2.0 + '@smithy/middleware-content-length': 4.2.0 + '@smithy/middleware-endpoint': 4.3.0 + '@smithy/middleware-retry': 4.4.0 + '@smithy/middleware-serde': 4.2.0 + '@smithy/middleware-stack': 4.2.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/node-http-handler': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/smithy-client': 4.7.0 + '@smithy/types': 4.6.0 + '@smithy/url-parser': 4.2.0 + '@smithy/util-base64': 4.2.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.0 + '@smithy/util-defaults-mode-browser': 4.2.0 + '@smithy/util-defaults-mode-node': 4.2.0 + '@smithy/util-endpoints': 3.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-retry': 4.2.0 + '@smithy/util-stream': 4.4.0 + '@smithy/util-utf8': 4.2.0 + '@smithy/util-waiter': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sso@3.901.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.901.0 + '@aws-sdk/middleware-host-header': 3.901.0 + '@aws-sdk/middleware-logger': 3.901.0 + '@aws-sdk/middleware-recursion-detection': 3.901.0 + '@aws-sdk/middleware-user-agent': 3.901.0 + '@aws-sdk/region-config-resolver': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-endpoints': 3.901.0 + '@aws-sdk/util-user-agent-browser': 3.901.0 + '@aws-sdk/util-user-agent-node': 3.901.0 + '@smithy/config-resolver': 4.3.0 + '@smithy/core': 3.14.0 + '@smithy/fetch-http-handler': 5.3.0 + '@smithy/hash-node': 4.2.0 + '@smithy/invalid-dependency': 4.2.0 + '@smithy/middleware-content-length': 4.2.0 + '@smithy/middleware-endpoint': 4.3.0 + '@smithy/middleware-retry': 4.4.0 + '@smithy/middleware-serde': 4.2.0 + '@smithy/middleware-stack': 4.2.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/node-http-handler': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/smithy-client': 4.7.0 + '@smithy/types': 4.6.0 + '@smithy/url-parser': 4.2.0 + '@smithy/util-base64': 4.2.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.0 + '@smithy/util-defaults-mode-browser': 4.2.0 + '@smithy/util-defaults-mode-node': 4.2.0 + '@smithy/util-endpoints': 3.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-retry': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@aws-sdk/xml-builder': 3.901.0 + '@smithy/core': 3.14.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/property-provider': 4.2.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/signature-v4': 5.3.0 + '@smithy/smithy-client': 4.7.0 + '@smithy/types': 4.6.0 + '@smithy/util-base64': 4.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.901.0': + dependencies: + '@aws-sdk/core': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/property-provider': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.901.0': + dependencies: + '@aws-sdk/core': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/fetch-http-handler': 5.3.0 + '@smithy/node-http-handler': 4.3.0 + '@smithy/property-provider': 4.2.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/smithy-client': 4.7.0 + '@smithy/types': 4.6.0 + '@smithy/util-stream': 4.4.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.901.0': + dependencies: + '@aws-sdk/core': 3.901.0 + '@aws-sdk/credential-provider-env': 3.901.0 + '@aws-sdk/credential-provider-http': 3.901.0 + '@aws-sdk/credential-provider-process': 3.901.0 + '@aws-sdk/credential-provider-sso': 3.901.0 + '@aws-sdk/credential-provider-web-identity': 3.901.0 + '@aws-sdk/nested-clients': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/credential-provider-imds': 4.2.0 + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.901.0': + dependencies: + '@aws-sdk/credential-provider-env': 3.901.0 + '@aws-sdk/credential-provider-http': 3.901.0 + '@aws-sdk/credential-provider-ini': 3.901.0 + '@aws-sdk/credential-provider-process': 3.901.0 + '@aws-sdk/credential-provider-sso': 3.901.0 + '@aws-sdk/credential-provider-web-identity': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/credential-provider-imds': 4.2.0 + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.901.0': + dependencies: + '@aws-sdk/core': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.901.0': + dependencies: + '@aws-sdk/client-sso': 3.901.0 + '@aws-sdk/core': 3.901.0 + '@aws-sdk/token-providers': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.901.0': + dependencies: + '@aws-sdk/core': 3.901.0 + '@aws-sdk/nested-clients': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/middleware-bucket-endpoint@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-arn-parser': 3.893.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-config-provider': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-expect-continue@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.901.0': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/is-array-buffer': 4.2.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-stream': 4.4.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-location-constraint@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@aws/lambda-invoke-store': 0.0.1 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.901.0': + dependencies: + '@aws-sdk/core': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-arn-parser': 3.893.0 + '@smithy/core': 3.14.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/signature-v4': 5.3.0 + '@smithy/smithy-client': 4.7.0 + '@smithy/types': 4.6.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-stream': 4.4.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-ssec@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.901.0': + dependencies: + '@aws-sdk/core': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-endpoints': 3.901.0 + '@smithy/core': 3.14.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.901.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.901.0 + '@aws-sdk/middleware-host-header': 3.901.0 + '@aws-sdk/middleware-logger': 3.901.0 + '@aws-sdk/middleware-recursion-detection': 3.901.0 + '@aws-sdk/middleware-user-agent': 3.901.0 + '@aws-sdk/region-config-resolver': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-endpoints': 3.901.0 + '@aws-sdk/util-user-agent-browser': 3.901.0 + '@aws-sdk/util-user-agent-node': 3.901.0 + '@smithy/config-resolver': 4.3.0 + '@smithy/core': 3.14.0 + '@smithy/fetch-http-handler': 5.3.0 + '@smithy/hash-node': 4.2.0 + '@smithy/invalid-dependency': 4.2.0 + '@smithy/middleware-content-length': 4.2.0 + '@smithy/middleware-endpoint': 4.3.0 + '@smithy/middleware-retry': 4.4.0 + '@smithy/middleware-serde': 4.2.0 + '@smithy/middleware-stack': 4.2.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/node-http-handler': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/smithy-client': 4.7.0 + '@smithy/types': 4.6.0 + '@smithy/url-parser': 4.2.0 + '@smithy/util-base64': 4.2.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.0 + '@smithy/util-defaults-mode-browser': 4.2.0 + '@smithy/util-defaults-mode-node': 4.2.0 + '@smithy/util-endpoints': 3.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-retry': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-middleware': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.901.0': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/signature-v4': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.901.0': + dependencies: + '@aws-sdk/core': 3.901.0 + '@aws-sdk/nested-clients': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.901.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/util-arn-parser@3.893.0': + dependencies: + tslib: 2.8.1 - '@antoniomuso/lz4-napi-linux-arm64-musl@2.9.0': - optional: true + '@aws-sdk/util-endpoints@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/types': 4.6.0 + '@smithy/url-parser': 4.2.0 + '@smithy/util-endpoints': 3.2.0 + tslib: 2.8.1 - '@antoniomuso/lz4-napi-linux-x64-gnu@2.9.0': - optional: true + '@aws-sdk/util-locate-window@3.893.0': + dependencies: + tslib: 2.8.1 - '@antoniomuso/lz4-napi-linux-x64-musl@2.9.0': - optional: true + '@aws-sdk/util-user-agent-browser@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/types': 4.6.0 + bowser: 2.12.1 + tslib: 2.8.1 - '@antoniomuso/lz4-napi-win32-arm64-msvc@2.9.0': - optional: true + '@aws-sdk/util-user-agent-node@3.901.0': + dependencies: + '@aws-sdk/middleware-user-agent': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 - '@antoniomuso/lz4-napi-win32-ia32-msvc@2.9.0': - optional: true + '@aws-sdk/xml-builder@3.901.0': + dependencies: + '@smithy/types': 4.6.0 + fast-xml-parser: 5.2.5 + tslib: 2.8.1 - '@antoniomuso/lz4-napi-win32-x64-msvc@2.9.0': - optional: true + '@aws/lambda-invoke-store@0.0.1': {} '@babel/code-frame@7.27.1': dependencies: @@ -4716,6 +5763,84 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.10': + optional: true + + '@esbuild/android-arm64@0.25.10': + optional: true + + '@esbuild/android-arm@0.25.10': + optional: true + + '@esbuild/android-x64@0.25.10': + optional: true + + '@esbuild/darwin-arm64@0.25.10': + optional: true + + '@esbuild/darwin-x64@0.25.10': + optional: true + + '@esbuild/freebsd-arm64@0.25.10': + optional: true + + '@esbuild/freebsd-x64@0.25.10': + optional: true + + '@esbuild/linux-arm64@0.25.10': + optional: true + + '@esbuild/linux-arm@0.25.10': + optional: true + + '@esbuild/linux-ia32@0.25.10': + optional: true + + '@esbuild/linux-loong64@0.25.10': + optional: true + + '@esbuild/linux-mips64el@0.25.10': + optional: true + + '@esbuild/linux-ppc64@0.25.10': + optional: true + + '@esbuild/linux-riscv64@0.25.10': + optional: true + + '@esbuild/linux-s390x@0.25.10': + optional: true + + '@esbuild/linux-x64@0.25.10': + optional: true + + '@esbuild/netbsd-arm64@0.25.10': + optional: true + + '@esbuild/netbsd-x64@0.25.10': + optional: true + + '@esbuild/openbsd-arm64@0.25.10': + optional: true + + '@esbuild/openbsd-x64@0.25.10': + optional: true + + '@esbuild/openharmony-arm64@0.25.10': + optional: true + + '@esbuild/sunos-x64@0.25.10': + optional: true + + '@esbuild/win32-arm64@0.25.10': + optional: true + + '@esbuild/win32-ia32@0.25.10': + optional: true + + '@esbuild/win32-x64@0.25.10': + optional: true + '@eslint-community/eslint-utils@4.7.0(eslint@9.31.0(jiti@2.5.1))': dependencies: eslint: 9.31.0(jiti@2.5.1) @@ -5541,6 +6666,344 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@smithy/abort-controller@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader-native@4.2.0': + dependencies: + '@smithy/util-base64': 4.2.0 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader@5.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/config-resolver@4.3.0': + dependencies: + '@smithy/node-config-provider': 4.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-middleware': 4.2.0 + tslib: 2.8.1 + + '@smithy/core@3.14.0': + dependencies: + '@smithy/middleware-serde': 4.2.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-base64': 4.2.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-stream': 4.4.0 + '@smithy/util-utf8': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.0': + dependencies: + '@smithy/node-config-provider': 4.3.0 + '@smithy/property-provider': 4.2.0 + '@smithy/types': 4.6.0 + '@smithy/url-parser': 4.2.0 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.2.0': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.6.0 + '@smithy/util-hex-encoding': 4.2.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.2.0': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.3.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.2.0': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-universal@4.2.0': + dependencies: + '@smithy/eventstream-codec': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.0': + dependencies: + '@smithy/protocol-http': 5.3.0 + '@smithy/querystring-builder': 4.2.0 + '@smithy/types': 4.6.0 + '@smithy/util-base64': 4.2.0 + tslib: 2.8.1 + + '@smithy/hash-blob-browser@4.2.0': + dependencies: + '@smithy/chunked-blob-reader': 5.2.0 + '@smithy/chunked-blob-reader-native': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/hash-stream-node@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/md5-js@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.0': + dependencies: + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.3.0': + dependencies: + '@smithy/core': 3.14.0 + '@smithy/middleware-serde': 4.2.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + '@smithy/url-parser': 4.2.0 + '@smithy/util-middleware': 4.2.0 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.4.0': + dependencies: + '@smithy/node-config-provider': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/service-error-classification': 4.2.0 + '@smithy/smithy-client': 4.7.0 + '@smithy/types': 4.6.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-retry': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.0': + dependencies: + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.0': + dependencies: + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.3.0': + dependencies: + '@smithy/abort-controller': 4.2.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/querystring-builder': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + '@smithy/util-uri-escape': 4.2.0 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + + '@smithy/shared-ini-file-loader@4.3.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.0': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-uri-escape': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/smithy-client@4.7.0': + dependencies: + '@smithy/core': 3.14.0 + '@smithy/middleware-endpoint': 4.3.0 + '@smithy/middleware-stack': 4.2.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-stream': 4.4.0 + tslib: 2.8.1 + + '@smithy/types@4.6.0': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.0': + dependencies: + '@smithy/querystring-parser': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/util-base64@4.2.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.0': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.2.0': + dependencies: + '@smithy/property-provider': 4.2.0 + '@smithy/smithy-client': 4.7.0 + '@smithy/types': 4.6.0 + bowser: 2.12.1 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.0': + dependencies: + '@smithy/config-resolver': 4.3.0 + '@smithy/credential-provider-imds': 4.2.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/property-provider': 4.2.0 + '@smithy/smithy-client': 4.7.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.2.0': + dependencies: + '@smithy/node-config-provider': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/util-retry@4.2.0': + dependencies: + '@smithy/service-error-classification': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/util-stream@4.4.0': + dependencies: + '@smithy/fetch-http-handler': 5.3.0 + '@smithy/node-http-handler': 4.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-base64': 4.2.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-waiter@4.2.0': + dependencies: + '@smithy/abort-controller': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/uuid@1.1.0': + dependencies: + tslib: 2.8.1 + '@standard-schema/spec@1.0.0': {} '@swc/cli@0.6.0(@swc/core@1.12.14)(chokidar@4.0.3)': @@ -6244,6 +7707,8 @@ snapshots: transitivePeerDependencies: - supports-color + bowser@2.12.1: {} + boxen@5.1.2: dependencies: ansi-align: 3.0.1 @@ -6567,6 +8032,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + dayjs@1.11.18: {} + debug@4.4.1(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -6702,6 +8169,35 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + esbuild@0.25.10: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.10 + '@esbuild/android-arm': 0.25.10 + '@esbuild/android-arm64': 0.25.10 + '@esbuild/android-x64': 0.25.10 + '@esbuild/darwin-arm64': 0.25.10 + '@esbuild/darwin-x64': 0.25.10 + '@esbuild/freebsd-arm64': 0.25.10 + '@esbuild/freebsd-x64': 0.25.10 + '@esbuild/linux-arm': 0.25.10 + '@esbuild/linux-arm64': 0.25.10 + '@esbuild/linux-ia32': 0.25.10 + '@esbuild/linux-loong64': 0.25.10 + '@esbuild/linux-mips64el': 0.25.10 + '@esbuild/linux-ppc64': 0.25.10 + '@esbuild/linux-riscv64': 0.25.10 + '@esbuild/linux-s390x': 0.25.10 + '@esbuild/linux-x64': 0.25.10 + '@esbuild/netbsd-arm64': 0.25.10 + '@esbuild/netbsd-x64': 0.25.10 + '@esbuild/openbsd-arm64': 0.25.10 + '@esbuild/openbsd-x64': 0.25.10 + '@esbuild/openharmony-arm64': 0.25.10 + '@esbuild/sunos-x64': 0.25.10 + '@esbuild/win32-arm64': 0.25.10 + '@esbuild/win32-ia32': 0.25.10 + '@esbuild/win32-x64': 0.25.10 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -6804,6 +8300,8 @@ snapshots: etag@1.8.1: {} + eventemitter3@5.0.1: {} + events@3.3.0: {} execa@5.1.1: @@ -6903,6 +8401,10 @@ snapshots: fast-uri@3.0.6: {} + fast-xml-parser@5.2.5: + dependencies: + strnum: 2.1.1 + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -7084,6 +8586,10 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 + get-tsconfig@4.11.0: + dependencies: + resolve-pkg-maps: 1.0.0 + giget@2.0.0: dependencies: citty: 0.1.6 @@ -7952,6 +9458,8 @@ snapshots: mute-stream@2.0.0: {} + nanoid@5.1.6: {} + natural-compare@1.4.0: {} negotiator@1.0.0: {} @@ -8288,6 +9796,8 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve.exports@2.0.3: {} resolve@1.22.10: @@ -8571,6 +10081,8 @@ snapshots: strip-json-comments@3.1.1: {} + strnum@2.1.1: {} + strtok3@10.3.2: dependencies: '@tokenizer/token': 0.3.0 @@ -8782,6 +10294,13 @@ snapshots: tslib@2.8.1: {} + tsx@4.20.6: + dependencies: + esbuild: 0.25.10 + get-tsconfig: 4.11.0 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/prisma/challenge.schema.prisma b/prisma/challenge.schema.prisma index ef871a4..0e0af2d 100644 --- a/prisma/challenge.schema.prisma +++ b/prisma/challenge.schema.prisma @@ -591,6 +591,8 @@ model ChallengeReviewer { incrementalPayment Float? type ReviewOpportunityTypeEnum? aiWorkflowId String? @db.VarChar(14) + // If false, Autopilot should not open public review opportunities for this reviewer + shouldOpenOpportunity Boolean @default(true) // Relation to the challenge challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) @@ -624,6 +626,8 @@ model DefaultChallengeReviewer { incrementalPayment Float? opportunityType ReviewOpportunityTypeEnum? isAIReviewer Boolean + // Default for whether to open a public review opportunity for this reviewer when a challenge is created + shouldOpenOpportunity Boolean @default(true) // Relations challengeType ChallengeType @relation(fields: [typeId], references: [id]) diff --git a/scripts/run-autopilot-flows.ts b/scripts/run-autopilot-flows.ts new file mode 100644 index 0000000..8aad482 --- /dev/null +++ b/scripts/run-autopilot-flows.ts @@ -0,0 +1,225 @@ +/* + End-to-end flow runner for Autopilot, based off the code in the autopilot-tester + - Runs selected flows in parallel to allow us to test the various challenge tracks and types + - Fails the process if any flow fails. + - Uses Auth0 M2M credentials from env to write autopilot-tester secrets/m2m.json (if provided). + + Supported env vars: + - AUTH0_CLIENT_ID / TC_M2M_CLIENT_ID + - AUTH0_CLIENT_SECRET / TC_M2M_CLIENT_SECRET + - AUTH0_AUDIENCE / M2M_AUDIENCE (default: https://m2m.topcoder-dev.com/) + - AUTH0_TOKEN_URL / AUTH0_DOMAIN (default: https://topcoder-dev.auth0.com/oauth/token) + - AUTOPILOT_E2E_FLOWS: comma list of flows (full,first2finish,topgear,topgearlate,design) +*/ + +/* eslint-disable no-console */ +import fs from 'fs'; +import path from 'path'; +// Use Node's CommonJS globals for path resolution under tsconfig module=commonjs +// (Avoid import.meta which requires ESM module settings.) + +type StepRequestLog = { + id: string; + method?: string; + endpoint?: string; + status?: number; + message?: string; + requestBody?: unknown; + responseBody?: unknown; + responseHeaders?: Record; + timestamp?: string; + durationMs?: number; + outcome: 'success' | 'failure'; +}; + +type StepEvent = { + type: 'step'; + step: string; + status: 'pending' | 'in-progress' | 'success' | 'failure'; + requests?: StepRequestLog[]; + failedRequests?: StepRequestLog[]; + timestamp: string; +}; + +type LogEvent = { level: 'info' | 'warn' | 'error'; message: string; data?: any; progress?: number }; + +// Lightweight logger compatible with autopilot-tester's RunnerLogger API +function createFlowLogger(prefix: string) { + type Handler = (e: any) => void; + const listeners: Record = { log: [], step: [] }; + const emit = (event: 'log' | 'step', payload: any) => { + for (const h of listeners[event]) { + try { h(payload); } catch {} + } + }; + return { + on(event: 'log' | 'step', handler: Handler) { + (listeners[event] ||= []).push(handler); + }, + off(event: 'log' | 'step', handler: Handler) { + const arr = listeners[event]; + if (!arr) return; + const idx = arr.indexOf(handler); + if (idx !== -1) arr.splice(idx, 1); + }, + log(level: LogEvent['level'], message: string, data?: any, progress?: number) { + emit('log', { level, message, data, progress } satisfies LogEvent); + }, + info(message: string, data?: any, progress?: number) { this.log('info', message, data, progress); }, + warn(message: string, data?: any, progress?: number) { this.log('warn', message, data, progress); }, + error(message: string, data?: any, progress?: number) { this.log('error', message, data, progress); }, + step(event: { step: string; status: StepEvent['status']; requests?: StepRequestLog[]; failedRequests?: StepRequestLog[]; timestamp?: string }) { + const payload: StepEvent = { + type: 'step', + step: event.step, + status: event.status, + requests: event.requests, + failedRequests: event.failedRequests, + timestamp: event.timestamp ?? new Date().toISOString(), + }; + emit('step', payload); + }, + prefix, + } as any; // typed as any to satisfy autopilot-tester signatures +} + +function ensureM2MSecretsFromEnv(): void { + const clientId = process.env.AUTH0_CLIENT_ID || process.env.TC_M2M_CLIENT_ID; + const clientSecret = process.env.AUTH0_CLIENT_SECRET || process.env.TC_M2M_CLIENT_SECRET; + const audience = process.env.AUTH0_AUDIENCE || process.env.M2M_AUDIENCE || 'https://m2m.topcoder-dev.com/'; + const tokenUrl = process.env.AUTH0_TOKEN_URL || (process.env.AUTH0_DOMAIN ? `https://${process.env.AUTH0_DOMAIN}/oauth/token` : 'https://topcoder-dev.auth0.com/oauth/token'); + + // If both clientId and clientSecret are provided, write the secrets file expected by autopilot-tester + if (clientId && clientSecret) { + const targetDir = path.resolve(__dirname, '../../autopilot-tester/server/secrets'); + const targetFile = path.join(targetDir, 'm2m.json'); + fs.mkdirSync(targetDir, { recursive: true }); + const payload = { tokenUrl, audience, clientId, clientSecret }; + fs.writeFileSync(targetFile, JSON.stringify(payload, null, 2)); + console.log(`[flows] Wrote M2M secrets to ${targetFile}`); + } +} + +function readAppConfig() { + // Use autopilot-tester config loader so defaults are applied consistently + const configFile = path.resolve(__dirname, '../../autopilot-tester/server/data/config.json'); + const modPath = path.resolve(__dirname, '../../autopilot-tester/server/src/types/config.ts'); + return import(modPath).then((m) => m.readAppConfigFile(configFile)); +} + +type FlowKey = 'full' | 'first2finish' | 'topgear' | 'topgearlate' | 'design'; + +async function runOneFlow(name: FlowKey) { + const logger = createFlowLogger(name); + const failures: { step?: string; details?: StepRequestLog[]; message?: string }[] = []; + const errors: string[] = []; + + logger.on('log', (e: LogEvent) => { + const head = e.level === 'error' ? '✖' : e.level === 'warn' ? '!' : '•'; + const progress = typeof e.progress === 'number' ? ` ${e.progress.toFixed(0)}%` : ''; + const dataNote = e.data ? ` ${JSON.stringify(e.data)}` : ''; + console.log(`[${name}] ${head} ${e.message}${progress}${dataNote}`); + if (e.level === 'error') { + errors.push(e.message); + } + }); + + logger.on('step', (e: StepEvent) => { + const statusIcon = e.status === 'success' ? '✓' : e.status === 'failure' ? '✖' : e.status === 'in-progress' ? '…' : '·'; + console.log(`[${name}] ${statusIcon} step=${e.step} status=${e.status}`); + if (e.status === 'failure') { + failures.push({ step: e.step, details: e.failedRequests }); + } + }); + + const config = await readAppConfig(); + + const runFullPath = path.resolve(__dirname, '../../autopilot-tester/server/src/services/flowRunner.ts'); + const runF2FPath = path.resolve(__dirname, '../../autopilot-tester/server/src/services/first2finishRunner.ts'); + const runDesignPath = path.resolve(__dirname, '../../autopilot-tester/server/src/services/designRunner.ts'); + + try { + if (name === 'full') { + const { runFlow } = await import(runFullPath); + await runFlow(config.fullChallenge, 'full', undefined, logger); + } else if (name === 'first2finish') { + const { runFirst2FinishFlow } = await import(runF2FPath); + await runFirst2FinishFlow(config.first2finish, 'full', undefined, logger); + } else if (name === 'topgear') { + const { runFirst2FinishFlow } = await import(runF2FPath); + await runFirst2FinishFlow(config.topgear, 'full', undefined, logger, undefined, { submissionPhaseName: 'Topgear Submission' }); + } else if (name === 'topgearlate') { + const { runFirst2FinishFlow } = await import(runF2FPath); + await runFirst2FinishFlow(config.topgear, 'full', undefined, logger, undefined, { submissionPhaseName: 'Topgear Submission', lateSubmission: true, enablePostMortem: true }); + } else if (name === 'design') { + const { runDesignFlow } = await import(runDesignPath); + await runDesignFlow(config.designChallenge, 'full', undefined, logger); + } + const hadFailures = failures.length > 0 || errors.length > 0; + return { name, ok: !hadFailures, failures, errors }; + } catch (err: any) { + const msg = err?.message || String(err); + errors.push(msg); + return { name, ok: false, failures, errors }; + } +} + +function parseFlowList(): FlowKey[] { + const raw = process.env.AUTOPILOT_E2E_FLOWS || ''; + if (raw.trim()) { + const list = raw.split(',').map((s) => s.trim().toLowerCase()); + const allowed: FlowKey[] = ['full', 'first2finish', 'topgear', 'topgearlate', 'design']; + const picked = allowed.filter((f) => list.includes(f)); + if (picked.length) return picked; + } + return ['full', 'first2finish', 'topgear', 'topgearlate', 'design']; +} + +async function main() { + console.log('[flows] Preparing environment…'); + ensureM2MSecretsFromEnv(); + + const flows = parseFlowList(); + console.log(`[flows] Running flows in parallel: ${flows.join(', ')}`); + + const results = await Promise.allSettled(flows.map((f) => runOneFlow(f))); + + // Summarize + console.log('\n[flows] Summary'); + let anyFailed = false; + for (const r of results) { + if (r.status === 'fulfilled') { + const { name, ok, failures, errors } = r.value; + console.log(`- ${name}: ${ok ? 'PASS' : 'FAIL'}`); + if (!ok) { + anyFailed = true; + for (const err of errors) { + console.log(` • Error: ${err}`); + } + for (const f of failures) { + if (!f) continue; + console.log(` • Step failed: ${f.step}`); + for (const req of f.details || []) { + const stat = req.status !== undefined ? ` ${req.status}` : ''; + const meth = req.method ? `${req.method} ` : ''; + console.log(` - ${meth}${req.endpoint || ''}${stat} ${req.message || ''}`); + } + } + } + } else { + anyFailed = true; + console.log(`- (unknown): FAIL`); + console.log(` • ${String(r.reason)}`); + } + } + + if (anyFailed) { + console.log('\n[flows] One or more flows failed'); + process.exitCode = 1; + } else { + console.log('\n[flows] All flows passed'); + } +} + +// Run +void main(); diff --git a/sendgrid/phase_change_template.html b/sendgrid/phase_change_template.html new file mode 100644 index 0000000..447ad8c --- /dev/null +++ b/sendgrid/phase_change_template.html @@ -0,0 +1,69 @@ + + + + + + + + + + + + +
+
+ + + + +
 
+ + + + + + +
+ +
+
+ + + + +
+
+

Hi,

+ +

+ + + Challenge: {{challengeName}} +
+ {{#if phaseOpen}} +

Phase {{phaseOpen}} opened at {{phaseOpenDate}}

+ {{/if}} + {{#if phaseClose}} +

Phase {{phaseClose}} closed at {{phaseCloseDate}}

+ {{/if}} + +
+

If you have any questions or concerns about this request, please feel free to reach out to support@topcoder.com

+

Thanks,

+

Topcoder

+
+ + + + +
 
+ +
+ + + diff --git a/src/autopilot/autopilot-logging.module.ts b/src/autopilot/autopilot-logging.module.ts index 78cd4c0..2ad8548 100644 --- a/src/autopilot/autopilot-logging.module.ts +++ b/src/autopilot/autopilot-logging.module.ts @@ -1,10 +1,15 @@ import { Global, Module } from '@nestjs/common'; import { AutopilotDbLoggerService } from './services/autopilot-db-logger.service'; import { AutopilotPrismaService } from './services/autopilot-prisma.service'; +import { AutopilotActionsCleanupService } from './services/autopilot-actions-cleanup.service'; @Global() @Module({ - providers: [AutopilotPrismaService, AutopilotDbLoggerService], + providers: [ + AutopilotPrismaService, + AutopilotDbLoggerService, + AutopilotActionsCleanupService, + ], exports: [AutopilotDbLoggerService], }) export class AutopilotLoggingModule {} diff --git a/src/autopilot/autopilot.module.ts b/src/autopilot/autopilot.module.ts index bf3e9e3..95656e2 100644 --- a/src/autopilot/autopilot.module.ts +++ b/src/autopilot/autopilot.module.ts @@ -1,4 +1,5 @@ import { Module, forwardRef } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; import { AutopilotService } from './services/autopilot.service'; import { KafkaModule } from '../kafka/kafka.module'; import { SchedulerService } from './services/scheduler.service'; @@ -12,6 +13,10 @@ import { ChallengeCompletionService } from './services/challenge-completion.serv import { PhaseScheduleManager } from './services/phase-schedule-manager.service'; import { ResourceEventHandler } from './services/resource-event-handler.service'; import { First2FinishService } from './services/first2finish.service'; +import { MembersModule } from '../members/members.module'; +import { Auth0Module } from '../auth/auth0.module'; +import { PhaseChangeNotificationService } from './services/phase-change-notification.service'; +import { FinanceModule } from '../finance/finance.module'; @Module({ imports: [ @@ -19,9 +24,13 @@ import { First2FinishService } from './services/first2finish.service'; // Corrected: Removed .forRoot() as it's already called in the root AppModule. // This makes the providers from ScheduleModule available here without re-registering them. ScheduleModule, + HttpModule, ChallengeModule, ReviewModule, ResourcesModule, + MembersModule, + Auth0Module, + FinanceModule, ], providers: [ AutopilotService, @@ -32,6 +41,7 @@ import { First2FinishService } from './services/first2finish.service'; PhaseReviewService, ReviewAssignmentService, ChallengeCompletionService, + PhaseChangeNotificationService, ], exports: [AutopilotService, SchedulerService], }) diff --git a/src/autopilot/constants/review.constants.ts b/src/autopilot/constants/review.constants.ts index 8d93b3c..03b0e1b 100644 --- a/src/autopilot/constants/review.constants.ts +++ b/src/autopilot/constants/review.constants.ts @@ -2,6 +2,7 @@ export const REVIEW_PHASE_NAMES = new Set([ 'Review', 'Iterative Review', 'Post-Mortem', + 'Checkpoint Review', ]); export const ITERATIVE_REVIEW_PHASE_NAME = 'Iterative Review'; @@ -9,10 +10,12 @@ export const POST_MORTEM_PHASE_NAME = 'Post-Mortem'; export const REGISTRATION_PHASE_NAME = 'Registration'; export const SUBMISSION_PHASE_NAME = 'Submission'; export const TOPGEAR_SUBMISSION_PHASE_NAME = 'Topgear Submission'; +export const CHECKPOINT_SUBMISSION_PHASE_NAME = 'Checkpoint Submission'; export const SUBMISSION_PHASE_NAMES = new Set([ SUBMISSION_PHASE_NAME, TOPGEAR_SUBMISSION_PHASE_NAME, + CHECKPOINT_SUBMISSION_PHASE_NAME, ]); export const DEFAULT_APPEALS_PHASE_NAMES = new Set(['Appeals']); @@ -20,12 +23,26 @@ export const DEFAULT_APPEALS_RESPONSE_PHASE_NAMES = new Set([ 'Appeals Response', ]); +// Screening phases that require all scorecards to be submitted before closing +export const SCREENING_PHASE_NAMES = new Set([ + 'Screening', + 'Checkpoint Screening', +]); + +// Approval phases that require all scorecards to be submitted before closing +export const APPROVAL_PHASE_NAMES = new Set(['Approval']); + const DEFAULT_PHASE_ROLES = ['Reviewer', 'Iterative Reviewer']; export const PHASE_ROLE_MAP: Record = { Review: ['Reviewer'], 'Iterative Review': ['Iterative Reviewer'], 'Post-Mortem': ['Reviewer', 'Copilot'], + 'Checkpoint Review': ['Checkpoint Reviewer'], + Screening: ['Screener'], + // Use the specific Checkpoint Screener role for checkpoint screening phases + 'Checkpoint Screening': ['Checkpoint Screener'], + Approval: ['Approver'], }; export function getRoleNamesForPhase(phaseName: string): string[] { diff --git a/src/autopilot/services/autopilot-actions-cleanup.service.ts b/src/autopilot/services/autopilot-actions-cleanup.service.ts new file mode 100644 index 0000000..b46850c --- /dev/null +++ b/src/autopilot/services/autopilot-actions-cleanup.service.ts @@ -0,0 +1,80 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { Prisma } from '@prisma/client'; +import { AutopilotPrismaService } from './autopilot-prisma.service'; + +@Injectable() +export class AutopilotActionsCleanupService { + private readonly logger = new Logger(AutopilotActionsCleanupService.name); + private readonly dbDebugEnabled: boolean; + + constructor( + private readonly configService: ConfigService, + private readonly prisma: AutopilotPrismaService, + ) { + this.dbDebugEnabled = + this.configService.get('autopilot.dbDebug') ?? false; + } + + @Cron(CronExpression.EVERY_DAY_AT_1AM) + async purgeOldActions(): Promise { + if (!this.dbDebugEnabled) { + return; + } + + const databaseUrl = this.configService.get('autopilot.dbUrl'); + if (!databaseUrl) { + this.logger.warn( + 'Skipping autopilot action cleanup because AUTOPILOT_DB_URL is not configured.', + ); + return; + } + + const cutoff = new Date(); + cutoff.setMonth(cutoff.getMonth() - 2); + + try { + await this.ensureSchema(); + + const deletedCount = await this.prisma.$executeRaw( + Prisma.sql` + DELETE FROM "autopilot"."actions" + WHERE "createdAt" < ${cutoff} + `, + ); + + if (deletedCount > 0) { + this.logger.log( + `Removed ${deletedCount} autopilot action records older than ${cutoff.toISOString()}.`, + ); + } + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to purge autopilot actions older than ${cutoff.toISOString()}: ${err.message}`, + err.stack, + ); + } + } + + private async ensureSchema(): Promise { + await this.prisma.$executeRaw( + Prisma.sql`CREATE SCHEMA IF NOT EXISTS "autopilot"`, + ); + + await this.prisma.$executeRaw( + Prisma.sql` + CREATE TABLE IF NOT EXISTS "autopilot"."actions" ( + "id" UUID PRIMARY KEY, + "challengeId" TEXT NULL, + "action" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'SUCCESS', + "source" TEXT NULL, + "details" JSONB NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `, + ); + } +} diff --git a/src/autopilot/services/autopilot.service.spec.ts b/src/autopilot/services/autopilot.service.spec.ts index b3b744c..1f9f443 100644 --- a/src/autopilot/services/autopilot.service.spec.ts +++ b/src/autopilot/services/autopilot.service.spec.ts @@ -14,6 +14,7 @@ import type { First2FinishService } from './first2finish.service'; import type { SchedulerService } from './scheduler.service'; import type { ChallengeApiService } from '../../challenge/challenge-api.service'; import type { ReviewService } from '../../review/review.service'; +import type { PhaseReviewService } from './phase-review.service'; import type { ConfigService } from '@nestjs/config'; import type { IChallenge, @@ -51,6 +52,7 @@ describe('AutopilotService - handleSubmissionNotificationAggregate', () => { let schedulerService: jest.Mocked; let challengeApiService: jest.Mocked; let reviewService: jest.Mocked; + let phaseReviewService: jest.Mocked; let configService: jest.Mocked; let autopilotService: AutopilotService; @@ -111,6 +113,10 @@ describe('AutopilotService - handleSubmissionNotificationAggregate', () => { getPendingReviewCount: jest.fn(), } as unknown as jest.Mocked; + phaseReviewService = { + handlePhaseOpened: jest.fn(), + } as unknown as jest.Mocked; + configService = { get: jest.fn().mockReturnValue(undefined), } as unknown as jest.Mocked; @@ -122,6 +128,7 @@ describe('AutopilotService - handleSubmissionNotificationAggregate', () => { schedulerService, challengeApiService, reviewService, + phaseReviewService, configService, ); @@ -212,7 +219,7 @@ describe('AutopilotService - handleSubmissionNotificationAggregate', () => { status: 'ACTIVE', createdBy: 'tester', updatedBy: 'tester', - metadata: [], + metadata: {}, phases: [reviewPhase, buildAppealsPhase()], reviewers: [ { @@ -429,7 +436,7 @@ describe('AutopilotService - handleSubmissionNotificationAggregate', () => { status: 'ACTIVE', createdBy: 'tester', updatedBy: 'tester', - metadata: [], + metadata: {}, phases: [phase], reviewers: [], winners: [], diff --git a/src/autopilot/services/autopilot.service.ts b/src/autopilot/services/autopilot.service.ts index 1a6c62e..6a198b4 100644 --- a/src/autopilot/services/autopilot.service.ts +++ b/src/autopilot/services/autopilot.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { SchedulerService } from './scheduler.service'; +import { PhaseReviewService } from './phase-review.service'; import { PhaseScheduleManager } from './phase-schedule-manager.service'; import { ResourceEventHandler } from './resource-event-handler.service'; import { First2FinishService } from './first2finish.service'; @@ -25,6 +26,8 @@ import { ITERATIVE_REVIEW_PHASE_NAME, POST_MORTEM_PHASE_NAME, REVIEW_PHASE_NAMES, + SCREENING_PHASE_NAMES, + APPROVAL_PHASE_NAMES, } from '../constants/review.constants'; import { ReviewService } from '../../review/review.service'; import { @@ -47,6 +50,7 @@ export class AutopilotService { private readonly schedulerService: SchedulerService, private readonly challengeApiService: ChallengeApiService, private readonly reviewService: ReviewService, + private readonly phaseReviewService: PhaseReviewService, private readonly configService: ConfigService, ) { this.appealsPhaseNames = new Set( @@ -138,6 +142,54 @@ export class AutopilotService { err.stack, ); } + + // Ensure screening/review records exist when a submission arrives during an open phase + try { + const challenge = await this.challengeApiService.getChallengeById( + challengeId, + ); + + if (!isActiveStatus(challenge.status)) { + return; + } + + const submissionType = (payload.type || '').toString().trim().toUpperCase(); + + // Map submission types to relevant open phases to sync + const targetPhaseNames = new Set(); + if (submissionType === 'CHECKPOINT_SUBMISSION') { + targetPhaseNames.add('Checkpoint Screening'); + } else if (submissionType === 'CONTEST_SUBMISSION' || !submissionType) { + // Fallback: standard contest submission or unspecified type + targetPhaseNames.add('Screening'); + } + + if (!targetPhaseNames.size) { + return; + } + + const openTargets = (challenge.phases ?? []).filter( + (p) => p.isOpen && (SCREENING_PHASE_NAMES.has(p.name) || REVIEW_PHASE_NAMES.has(p.name)) && targetPhaseNames.has(p.name), + ); + + for (const phase of openTargets) { + try { + await this.phaseReviewService.handlePhaseOpened(challengeId, phase.id); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to synchronize pending reviews for challenge ${challengeId}, phase ${phase.id} on submission ${submissionId}: ${err.message}`, + err.stack, + ); + } + } + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to synchronize review records after submission for challenge ${challengeId}: ${err.message}`, + err.stack, + ); + } } async handleResourceCreated(payload: ResourceEventPayload): Promise { @@ -242,7 +294,66 @@ export class AutopilotService { return; } - if (!REVIEW_PHASE_NAMES.has(phase.name)) { + // Special handling: Approval phase pass/fail adds additional Approval if failed + if (APPROVAL_PHASE_NAMES.has(phase.name)) { + const passingScore = await this.reviewService.getScorecardPassingScore( + review.scorecardId, + ); + const rawScore = + typeof review.score === 'number' + ? review.score + : Number(review.score ?? payload.initialScore ?? 0); + const finalScore = Number.isFinite(rawScore) + ? Number(rawScore) + : Number(payload.initialScore ?? 0); + + // Close current approval phase + await this.schedulerService.advancePhase({ + projectId: challenge.projectId, + challengeId: challenge.id, + phaseId: phase.id, + phaseTypeName: phase.name, + state: 'END', + operator: AutopilotOperator.SYSTEM, + projectStatus: challenge.status, + }); + + if (finalScore >= passingScore) { + this.logger.log( + `Approval review passed for challenge ${challenge.id} (score ${finalScore} / passing ${passingScore}).`, + ); + } else { + this.logger.log( + `Approval review failed for challenge ${challenge.id} (score ${finalScore} / passing ${passingScore}). Creating another Approval phase.`, + ); + try { + const nextApproval = + await this.challengeApiService.createIterativeReviewPhase( + challenge.id, + phase.id, + phase.phaseId!, + phase.name, + phase.description ?? null, + Math.max(phase.duration || 0, 1), + ); + + // Create pending reviews for the newly opened Approval + await this.phaseReviewService.handlePhaseOpened( + challenge.id, + nextApproval.id, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to create next Approval phase for challenge ${challenge.id}: ${err.message}`, + err.stack, + ); + } + } + return; + } + + if (!REVIEW_PHASE_NAMES.has(phase.name) && !SCREENING_PHASE_NAMES.has(phase.name)) { return; } @@ -259,7 +370,9 @@ export class AutopilotService { } this.logger.log( - `All reviews completed for phase ${phase.id} on challenge ${challengeId}. Closing Review phase early.`, + `All reviews completed for phase ${phase.id} on challenge ${challengeId}. Closing ${ + SCREENING_PHASE_NAMES.has(phase.name) ? 'Screening' : 'Review' + } phase early.`, ); await this.schedulerService.advancePhase({ @@ -507,30 +620,7 @@ export class AutopilotService { } } - private getStringArray(path: string, fallback: string[]): string[] { - const value = this.configService.get(path); - - if (Array.isArray(value)) { - const normalized = value - .map((item) => (typeof item === 'string' ? item.trim() : String(item))) - .filter((item) => item.length > 0); - if (normalized.length) { - return normalized; - } - } - - if (typeof value === 'string' && value.length > 0) { - const normalized = value - .split(',') - .map((item) => item.trim()) - .filter((item) => item.length > 0); - if (normalized.length) { - return normalized; - } - } - - return fallback; - } + async openAndScheduleNextPhases( challengeId: string, diff --git a/src/autopilot/services/challenge-completion.service.spec.ts b/src/autopilot/services/challenge-completion.service.spec.ts new file mode 100644 index 0000000..152e0fe --- /dev/null +++ b/src/autopilot/services/challenge-completion.service.spec.ts @@ -0,0 +1,424 @@ +import { ChallengeCompletionService } from './challenge-completion.service'; +import { ChallengeStatusEnum, PrizeSetTypeEnum } from '@prisma/client'; +import type { ChallengeApiService } from '../../challenge/challenge-api.service'; +import type { ReviewService, SubmissionSummary } from '../../review/review.service'; +import type { ResourcesService } from '../../resources/resources.service'; +import type { FinanceApiService } from '../../finance/finance-api.service'; +import type { + IChallenge, + IChallengePrizeSet, +} from '../../challenge/interfaces/challenge.interface'; +import type { ConfigService } from '@nestjs/config'; + +describe('ChallengeCompletionService', () => { + let challengeApiService: { + getChallengeById: jest.MockedFunction; + cancelChallenge: jest.MockedFunction; + completeChallenge: jest.MockedFunction; + createPostMortemPhasePreserving: jest.MockedFunction; + }; + let reviewService: { + generateReviewSummaries: jest.MockedFunction; + getScorecardIdByName: jest.MockedFunction; + createPendingReview: jest.MockedFunction; + }; + let resourcesService: { + getMemberHandleMap: jest.MockedFunction; + getResourcesByRoleNames: jest.MockedFunction; + }; + let financeApiService: { + generateChallengePayments: jest.MockedFunction; + }; + let configService: { + get: jest.MockedFunction; + }; + let service: ChallengeCompletionService; + + const baseTimestamp = '2024-01-01T00:00:00.000Z'; + + const buildChallenge = (overrides: Partial): IChallenge => ({ + id: 'challenge-1', + name: 'Sample Challenge', + description: null, + descriptionFormat: 'markdown', + projectId: 123, + typeId: 'type-1', + trackId: 'track-1', + timelineTemplateId: 'timeline-1', + currentPhaseNames: [], + tags: [], + groups: [], + submissionStartDate: baseTimestamp, + submissionEndDate: baseTimestamp, + registrationStartDate: baseTimestamp, + registrationEndDate: baseTimestamp, + startDate: baseTimestamp, + endDate: null, + legacyId: null, + status: ChallengeStatusEnum.ACTIVE, + createdBy: 'tester', + updatedBy: 'tester', + metadata: {}, + phases: [], + reviewers: [], + winners: [], + discussions: [], + events: [], + prizeSets: [], + terms: [], + skills: [], + attachments: [], + track: 'track', + type: 'type', + legacy: {}, + task: { isTask: false, isAssigned: false, memberId: null }, + created: baseTimestamp, + updated: baseTimestamp, + overview: { totalPrizes: 0 }, + numOfSubmissions: 0, + numOfCheckpointSubmissions: 0, + numOfRegistrants: 0, + ...overrides, + }); + + const buildPlacementPrizeSet = (count: number): IChallengePrizeSet => ({ + type: PrizeSetTypeEnum.PLACEMENT, + description: null, + prizes: Array.from({ length: count }, (_, index) => ({ + type: 'USD', + value: 100 - index * 10, + description: null, + })), + }); + + const summaries: SubmissionSummary[] = [ + { + submissionId: 'sub-1', + legacySubmissionId: null, + memberId: '101', + submittedDate: new Date('2024-01-02T10:00:00.000Z'), + aggregateScore: 95, + scorecardId: null, + scorecardLegacyId: null, + passingScore: 75, + isPassing: true, + }, + { + submissionId: 'sub-2', + legacySubmissionId: null, + memberId: '102', + submittedDate: new Date('2024-01-02T09:00:00.000Z'), + aggregateScore: 92, + scorecardId: null, + scorecardLegacyId: null, + passingScore: 75, + isPassing: true, + }, + { + submissionId: 'sub-3', + legacySubmissionId: null, + memberId: '103', + submittedDate: new Date('2024-01-02T11:00:00.000Z'), + aggregateScore: 88, + scorecardId: null, + scorecardLegacyId: null, + passingScore: 75, + isPassing: true, + }, + ]; + + beforeEach(() => { + challengeApiService = { + getChallengeById: jest.fn(), + cancelChallenge: jest.fn().mockResolvedValue(undefined), + completeChallenge: jest.fn().mockResolvedValue(undefined), + createPostMortemPhasePreserving: jest + .fn() + .mockResolvedValue({ + id: 'post-mortem-phase-id', + phaseId: 'post-mortem-template', + name: 'Post-Mortem', + description: null, + isOpen: true, + duration: 0, + scheduledStartDate: baseTimestamp, + scheduledEndDate: baseTimestamp, + actualStartDate: baseTimestamp, + actualEndDate: null, + predecessor: null, + constraints: [], + }), + }; + + reviewService = { + generateReviewSummaries: jest.fn().mockResolvedValue(summaries), + getScorecardIdByName: jest.fn().mockResolvedValue(null), + createPendingReview: jest.fn().mockResolvedValue(true), + }; + + resourcesService = { + getMemberHandleMap: jest + .fn() + .mockResolvedValue( + new Map([ + ['101', 'user101'], + ['102', 'user102'], + ['103', 'user103'], + ]), + ), + getResourcesByRoleNames: jest.fn().mockResolvedValue([]), + }; + + financeApiService = { + generateChallengePayments: jest.fn().mockResolvedValue(true), + } as unknown as { + generateChallengePayments: jest.MockedFunction; + }; + + configService = { + get: jest.fn().mockReturnValue(null), + }; + + service = new ChallengeCompletionService( + challengeApiService as unknown as ChallengeApiService, + reviewService as unknown as ReviewService, + resourcesService as unknown as ResourcesService, + financeApiService as unknown as FinanceApiService, + configService as unknown as ConfigService, + ); + }); + + it('limits winners to the number of placement prizes', async () => { + const challenge = buildChallenge({ + prizeSets: [ + buildPlacementPrizeSet(2), + { + type: PrizeSetTypeEnum.COPILOT, + description: null, + prizes: [], + }, + ], + numOfSubmissions: 3, + }); + + challengeApiService.getChallengeById.mockResolvedValue(challenge); + + const result = await service.finalizeChallenge(challenge.id); + + expect(result).toBe(true); + expect(challengeApiService.completeChallenge).toHaveBeenCalledTimes(1); + expect(financeApiService.generateChallengePayments).toHaveBeenCalledWith( + challenge.id, + ); + + const [, winners] = challengeApiService.completeChallenge.mock.calls[0]; + expect(winners).toHaveLength(2); + expect(winners[0]).toMatchObject({ + userId: 101, + placement: 1, + handle: 'user101', + }); + expect(winners[1]).toMatchObject({ + userId: 102, + placement: 2, + handle: 'user102', + }); + }); + + it('awards only one placement per member even with multiple passing submissions', async () => { + const challenge = buildChallenge({ + prizeSets: [buildPlacementPrizeSet(3)], + numOfSubmissions: 3, + }); + + const duplicateSummaries: SubmissionSummary[] = [ + { + submissionId: 'sub-1', + legacySubmissionId: null, + memberId: '101', + submittedDate: new Date('2024-01-02T08:00:00.000Z'), + aggregateScore: 98, + scorecardId: null, + scorecardLegacyId: null, + passingScore: 75, + isPassing: true, + }, + { + submissionId: 'sub-2', + legacySubmissionId: null, + memberId: '101', + submittedDate: new Date('2024-01-02T09:00:00.000Z'), + aggregateScore: 96, + scorecardId: null, + scorecardLegacyId: null, + passingScore: 75, + isPassing: true, + }, + { + submissionId: 'sub-3', + legacySubmissionId: null, + memberId: '102', + submittedDate: new Date('2024-01-02T10:00:00.000Z'), + aggregateScore: 94, + scorecardId: null, + scorecardLegacyId: null, + passingScore: 75, + isPassing: true, + }, + ]; + + reviewService.generateReviewSummaries.mockResolvedValueOnce(duplicateSummaries); + challengeApiService.getChallengeById.mockResolvedValue(challenge); + + const result = await service.finalizeChallenge(challenge.id); + + expect(result).toBe(true); + expect(challengeApiService.completeChallenge).toHaveBeenCalledTimes(1); + const [, winners] = challengeApiService.completeChallenge.mock.calls[0]; + + expect(winners).toHaveLength(2); + expect(winners.map((winner) => winner.userId)).toEqual([101, 102]); + expect(winners.map((winner) => winner.placement)).toEqual([1, 2]); + expect(financeApiService.generateChallengePayments).toHaveBeenCalledWith( + challenge.id, + ); + }); + + it('falls back to all passing submissions when no placement prizes exist', async () => { + const challenge = buildChallenge({ + prizeSets: [], + numOfSubmissions: 3, + }); + + challengeApiService.getChallengeById.mockResolvedValue(challenge); + + const result = await service.finalizeChallenge(challenge.id); + + expect(result).toBe(true); + expect(challengeApiService.completeChallenge).toHaveBeenCalledTimes(1); + expect(financeApiService.generateChallengePayments).toHaveBeenCalledWith( + challenge.id, + ); + + const [, winners] = challengeApiService.completeChallenge.mock.calls[0]; + expect(winners).toHaveLength(summaries.length); + expect(winners.map((winner) => winner.userId)).toEqual([101, 102, 103]); + }); + + it('creates post-mortem reviews for copilots when zero submissions trigger cancellation', async () => { + const challenge = buildChallenge({ + numOfSubmissions: 0, + phases: [ + { + id: 'submission-phase-id', + phaseId: 'submission-template', + name: 'Submission', + description: null, + isOpen: false, + duration: 0, + scheduledStartDate: baseTimestamp, + scheduledEndDate: baseTimestamp, + actualStartDate: baseTimestamp, + actualEndDate: baseTimestamp, + predecessor: null, + constraints: [], + }, + ], + }); + + const postMortemPhase = { + id: 'post-mortem-phase-id', + phaseId: 'post-mortem-template', + name: 'Post-Mortem', + description: null, + isOpen: true, + duration: 0, + scheduledStartDate: baseTimestamp, + scheduledEndDate: baseTimestamp, + actualStartDate: baseTimestamp, + actualEndDate: null, + predecessor: 'submission-template', + constraints: [], + }; + + challengeApiService.getChallengeById.mockResolvedValue(challenge); + challengeApiService.createPostMortemPhasePreserving.mockResolvedValueOnce( + postMortemPhase, + ); + + reviewService.generateReviewSummaries.mockResolvedValueOnce([]); + reviewService.getScorecardIdByName.mockResolvedValueOnce('scorecard-id'); + + resourcesService.getResourcesByRoleNames.mockResolvedValueOnce([ + { + id: 'copilot-resource-1', + memberId: '201', + memberHandle: 'copilot1', + roleName: 'Copilot', + }, + { + id: 'copilot-resource-2', + memberId: '202', + memberHandle: 'copilot2', + roleName: 'Copilot', + }, + ]); + + const result = await service.finalizeChallenge(challenge.id); + + expect(result).toBe(true); + expect(challengeApiService.cancelChallenge).toHaveBeenCalledWith( + challenge.id, + ChallengeStatusEnum.CANCELLED_ZERO_SUBMISSIONS, + ); + expect(challengeApiService.createPostMortemPhasePreserving).toHaveBeenCalledWith( + challenge.id, + 'submission-phase-id', + expect.any(Number), + true, + ); + expect(reviewService.createPendingReview).toHaveBeenCalledTimes(2); + expect(reviewService.createPendingReview).toHaveBeenCalledWith( + null, + 'copilot-resource-1', + postMortemPhase.id, + 'scorecard-id', + challenge.id, + ); + expect(reviewService.createPendingReview).toHaveBeenCalledWith( + null, + 'copilot-resource-2', + postMortemPhase.id, + 'scorecard-id', + challenge.id, + ); + }); + + it('triggers finance payments on CANCELLED_FAILED_REVIEW', async () => { + const challenge = buildChallenge({ + prizeSets: [buildPlacementPrizeSet(2)], + numOfSubmissions: 2, + }); + + // All summaries are failing + const failingSummaries = summaries.map((s) => ({ + ...s, + isPassing: false as const, + })); + reviewService.generateReviewSummaries.mockResolvedValueOnce( + failingSummaries, + ); + challengeApiService.getChallengeById.mockResolvedValue(challenge); + + const result = await service.finalizeChallenge(challenge.id); + + expect(result).toBe(true); + expect(challengeApiService.cancelChallenge).toHaveBeenCalledWith( + challenge.id, + ChallengeStatusEnum.CANCELLED_FAILED_REVIEW, + ); + expect(financeApiService.generateChallengePayments).toHaveBeenCalledWith( + challenge.id, + ); + }); +}); diff --git a/src/autopilot/services/challenge-completion.service.ts b/src/autopilot/services/challenge-completion.service.ts index 634d93b..e6a1d59 100644 --- a/src/autopilot/services/challenge-completion.service.ts +++ b/src/autopilot/services/challenge-completion.service.ts @@ -1,9 +1,15 @@ import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { ChallengeApiService } from '../../challenge/challenge-api.service'; import { ReviewService } from '../../review/review.service'; import { ResourcesService } from '../../resources/resources.service'; -import { IChallengeWinner } from '../../challenge/interfaces/challenge.interface'; -import { ChallengeStatusEnum } from '@prisma/client'; +import { + IChallengeWinner, + type IChallengePrizeSet, +} from '../../challenge/interfaces/challenge.interface'; +import { ChallengeStatusEnum, PrizeSetTypeEnum } from '@prisma/client'; +import { IPhase } from '../../challenge/interfaces/challenge.interface'; +import { FinanceApiService } from '../../finance/finance-api.service'; @Injectable() export class ChallengeCompletionService { @@ -13,8 +19,128 @@ export class ChallengeCompletionService { private readonly challengeApiService: ChallengeApiService, private readonly reviewService: ReviewService, private readonly resourcesService: ResourcesService, + private readonly financeApiService: FinanceApiService, + private readonly configService: ConfigService, ) {} + private async ensureCancelledPostMortem( + challengeId: string, + ): Promise { + try { + const challenge = + await this.challengeApiService.getChallengeById(challengeId); + + // Resolve scorecard for post-mortem: prefer env var; fallback to name + const configuredScorecardId = + this.configService.get( + 'autopilot.postMortemScorecardId', + ) ?? null; + + let scorecardId: string | null = configuredScorecardId; + + if (!scorecardId) { + try { + scorecardId = await this.reviewService.getScorecardIdByName( + 'Topcoder Post Mortem', + ); + } catch (_) { + // Already logged inside review service; leave as null + } + } + + if (!scorecardId) { + this.logger.warn( + `Post-mortem scorecard 'Topcoder Post Mortem' not found; skipping post-mortem review creation for challenge ${challengeId}.`, + ); + } + + // Determine a reasonable predecessor: last phase that has actually ended, else last phase in list + const phases = challenge.phases ?? []; + let predecessor: IPhase | undefined = phases + .filter((p) => Boolean(p.actualEndDate)) + .sort((a, b) => + (a.actualEndDate ?? '').localeCompare(b.actualEndDate ?? ''), + ) + .at(-1); + if (!predecessor && phases.length) { + predecessor = phases[phases.length - 1]; + } + + if (!predecessor) { + this.logger.warn( + `Unable to determine predecessor phase when creating post-mortem for challenge ${challengeId}; skipping creation.`, + ); + return; + } + + // Create or reuse Post-Mortem, open immediately + const postMortem = + await this.challengeApiService.createPostMortemPhasePreserving( + challengeId, + predecessor.id, + 72, + true, + ); + + // Assign to Copilot(s) if scorecard is available + if (scorecardId) { + const copilots = await this.resourcesService.getResourcesByRoleNames( + challengeId, + ['Copilot'], + ); + + let createdCount = 0; + for (const resource of copilots) { + try { + const created = await this.reviewService.createPendingReview( + null, + resource.id, + postMortem.id, + scorecardId, + challengeId, + ); + if (created) { + createdCount++; + } + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to create post-mortem review for challenge ${challengeId}, resource ${resource.id}: ${err.message}`, + err.stack, + ); + } + } + + if (createdCount > 0) { + this.logger.log( + `Created ${createdCount} post-mortem pending review(s) for challenge ${challengeId} (Copilot).`, + ); + } + } + } catch (error) { + const err = error as Error; + this.logger.error( + `Unable to create post-mortem phase for cancelled challenge ${challengeId}: ${err.message}`, + err.stack, + ); + } + } + + private countPlacementPrizes(prizeSets: IChallengePrizeSet[]): number { + if (!Array.isArray(prizeSets) || prizeSets.length === 0) { + return 0; + } + + return prizeSets.reduce((total, prizeSet) => { + if (!prizeSet || prizeSet.type !== PrizeSetTypeEnum.PLACEMENT) { + return total; + } + + const prizeCount = prizeSet.prizes?.length ?? 0; + return total + prizeCount; + }, 0); + } + async finalizeChallenge(challengeId: string): Promise { const challenge = await this.challengeApiService.getChallengeById(challengeId); @@ -39,6 +165,8 @@ export class ChallengeCompletionService { challengeId, ChallengeStatusEnum.CANCELLED_ZERO_SUBMISSIONS, ); + // Ensure a Post-Mortem exists for the cancelled challenge and assign to Copilot + await this.ensureCancelledPostMortem(challengeId); return true; } @@ -58,6 +186,10 @@ export class ChallengeCompletionService { challengeId, ChallengeStatusEnum.CANCELLED_FAILED_REVIEW, ); + // Ensure a Post-Mortem exists for the cancelled challenge and assign to Copilot + await this.ensureCancelledPostMortem(challengeId); + // Trigger finance payments generation for reviewer payments on failed review cancellation + void this.financeApiService.generateChallengePayments(challengeId); return true; } @@ -75,7 +207,7 @@ export class ChallengeCompletionService { }); const memberIds = sortedSummaries - .map((summary) => summary.memberId) + .map((summary) => summary.memberId?.trim()) .filter((id): id is string => Boolean(id)); const handleMap = await this.resourcesService.getMemberHandleMap( @@ -84,7 +216,19 @@ export class ChallengeCompletionService { ); const winners: IChallengeWinner[] = []; + const seenMembers = new Set(); + const seenSubmissionIds = new Set(); + const placementPrizeLimit = this.countPlacementPrizes( + challenge.prizeSets ?? [], + ); + const maxWinnerCount = + placementPrizeLimit > 0 ? placementPrizeLimit : sortedSummaries.length; + for (const summary of sortedSummaries) { + if (winners.length >= maxWinnerCount) { + break; + } + if (!summary.memberId) { this.logger.warn( `Skipping winner placement for submission ${summary.submissionId} on challenge ${challengeId} because memberId is missing.`, @@ -92,22 +236,48 @@ export class ChallengeCompletionService { continue; } - const numericMemberId = Number(summary.memberId); + if (seenSubmissionIds.has(summary.submissionId)) { + this.logger.warn( + `Skipping winner placement for duplicate submission ${summary.submissionId} on challenge ${challengeId}.`, + ); + continue; + } + + const memberId = summary.memberId.trim(); + if (!memberId) { + this.logger.warn( + `Skipping winner placement for submission ${summary.submissionId} on challenge ${challengeId} because memberId is blank.`, + ); + continue; + } + + if (seenMembers.has(memberId)) { + this.logger.log( + `Skipping additional placement for member ${memberId} on challenge ${challengeId}; already awarded.`, + ); + continue; + } + + const numericMemberId = Number(memberId); if (!Number.isFinite(numericMemberId)) { this.logger.warn( - `Skipping winner placement for submission ${summary.submissionId} on challenge ${challengeId} because memberId ${summary.memberId} is not numeric.`, + `Skipping winner placement for submission ${summary.submissionId} on challenge ${challengeId} because memberId ${memberId} is not numeric.`, ); continue; } winners.push({ userId: numericMemberId, - handle: handleMap.get(summary.memberId) ?? summary.memberId, + handle: handleMap.get(memberId) ?? memberId, placement: winners.length + 1, }); + seenMembers.add(memberId); + seenSubmissionIds.add(summary.submissionId); } await this.challengeApiService.completeChallenge(challengeId, winners); + // Trigger finance payments generation after marking the challenge as completed + void this.financeApiService.generateChallengePayments(challengeId); this.logger.log( `Marked challenge ${challengeId} as COMPLETED with ${winners.length} winner(s).`, ); diff --git a/src/autopilot/services/first2finish.service.spec.ts b/src/autopilot/services/first2finish.service.spec.ts index 77b16ac..0cffed2 100644 --- a/src/autopilot/services/first2finish.service.spec.ts +++ b/src/autopilot/services/first2finish.service.spec.ts @@ -13,7 +13,11 @@ import type { IPhase, IChallengeReviewer, } from '../../challenge/interfaces/challenge.interface'; -import { ITERATIVE_REVIEW_PHASE_NAME } from '../constants/review.constants'; +import { + ITERATIVE_REVIEW_PHASE_NAME, + REGISTRATION_PHASE_NAME, + SUBMISSION_PHASE_NAME, +} from '../constants/review.constants'; const iso = () => new Date().toISOString(); @@ -70,7 +74,7 @@ const buildChallenge = (overrides: Partial = {}): IChallenge => ({ status: 'ACTIVE', createdBy: 'tester', updatedBy: 'tester', - metadata: [], + metadata: {}, phases: [], reviewers: [], winners: [], @@ -107,6 +111,7 @@ describe('First2FinishService', () => { challengeApiService = { getChallengeById: jest.fn(), createIterativeReviewPhase: jest.fn(), + getPhaseDetails: jest.fn(), } as unknown as jest.Mocked; schedulerService = { @@ -117,6 +122,7 @@ describe('First2FinishService', () => { reviewService = { getAllSubmissionIdsOrdered: jest.fn(), getExistingReviewPairs: jest.fn(), + getReviewerSubmissionPairs: jest.fn(), createPendingReview: jest.fn(), getScorecardPassingScore: jest.fn(), } as unknown as jest.Mocked; @@ -144,6 +150,8 @@ describe('First2FinishService', () => { resourcesService, configService, ); + + reviewService.getReviewerSubmissionPairs.mockResolvedValue(new Set()); }); afterEach(() => { @@ -176,6 +184,67 @@ describe('First2FinishService', () => { expect(schedulerService.advancePhase).not.toHaveBeenCalled(); }); + it('reopens the seeded iterative review phase when the first submission arrives', async () => { + const seedPhase = buildIterativePhase({ + isOpen: false, + actualStartDate: null, + actualEndDate: null, + }); + + const challenge = buildChallenge({ + phases: [seedPhase], + reviewers: [buildReviewer()], + }); + + const reopenedPhase: IPhase = { + ...seedPhase, + isOpen: true, + actualStartDate: iso(), + actualEndDate: null, + }; + + challengeApiService.getChallengeById.mockResolvedValue(challenge); + challengeApiService.getPhaseDetails.mockResolvedValue(reopenedPhase); + + resourcesService.getReviewerResources.mockResolvedValue([ + { + id: 'resource-1', + memberId: '2001', + memberHandle: 'iterativeReviewer', + roleName: 'Iterative Reviewer', + }, + ]); + + reviewService.getAllSubmissionIdsOrdered.mockResolvedValue(['sub-123']); + reviewService.getExistingReviewPairs.mockResolvedValue(new Set()); + reviewService.createPendingReview.mockResolvedValue(true); + + await service.handleSubmissionByChallengeId(challenge.id, 'sub-123'); + + expect( + challengeApiService.createIterativeReviewPhase, + ).not.toHaveBeenCalled(); + expect(schedulerService.advancePhase).toHaveBeenCalledWith( + expect.objectContaining({ + challengeId: challenge.id, + phaseId: seedPhase.id, + state: 'START', + }), + ); + expect(challengeApiService.getPhaseDetails).toHaveBeenCalledWith( + challenge.id, + seedPhase.id, + ); + expect(reviewService.createPendingReview).toHaveBeenCalledWith( + 'sub-123', + 'resource-1', + seedPhase.id, + 'iterative-scorecard', + challenge.id, + ); + expect(schedulerService.schedulePhaseTransition).toHaveBeenCalled(); + }); + it('assigns the preferred submission when the list snapshot is empty', async () => { const closedPhase = buildIterativePhase({ isOpen: false }); const challenge = buildChallenge({ @@ -230,4 +299,80 @@ describe('First2FinishService', () => { expect(schedulerService.advancePhase).not.toHaveBeenCalled(); expect(schedulerService.schedulePhaseTransition).toHaveBeenCalled(); }); + + it('closes submission and registration after a passing iterative review', async () => { + const iterativePhase = buildIterativePhase({ + id: 'iter-phase', + isOpen: true, + actualEndDate: null, + }); + const submissionPhase = buildIterativePhase({ + id: 'submission-phase', + name: SUBMISSION_PHASE_NAME, + isOpen: true, + actualEndDate: null, + }); + const registrationPhase = buildIterativePhase({ + id: 'registration-phase', + name: REGISTRATION_PHASE_NAME, + isOpen: true, + actualEndDate: null, + }); + + const challenge = buildChallenge({ + phases: [iterativePhase, submissionPhase, registrationPhase], + reviewers: [buildReviewer()], + }); + + reviewService.getScorecardPassingScore.mockResolvedValue(90); + + await service.handleIterativeReviewCompletion( + challenge, + iterativePhase, + { + score: 95, + scorecardId: 'iterative-scorecard', + resourceId: 'resource-1', + submissionId: 'submission-1', + phaseId: iterativePhase.id, + }, + { + reviewId: 'review-1', + challengeId: challenge.id, + submissionId: 'submission-1', + phaseId: iterativePhase.id, + scorecardId: 'iterative-scorecard', + reviewerResourceId: 'resource-1', + reviewerHandle: 'iterativeReviewer', + reviewerMemberId: '2001', + submitterHandle: 'submitter', + submitterMemberId: '4001', + completedAt: iso(), + initialScore: 95, + }, + ); + + expect(schedulerService.advancePhase).toHaveBeenCalledTimes(3); + expect(schedulerService.advancePhase).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + phaseId: iterativePhase.id, + state: 'END', + }), + ); + expect(schedulerService.advancePhase).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + phaseId: submissionPhase.id, + state: 'END', + }), + ); + expect(schedulerService.advancePhase).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + phaseId: registrationPhase.id, + state: 'END', + }), + ); + }); }); diff --git a/src/autopilot/services/first2finish.service.ts b/src/autopilot/services/first2finish.service.ts index 9d6258d..9796931 100644 --- a/src/autopilot/services/first2finish.service.ts +++ b/src/autopilot/services/first2finish.service.ts @@ -15,6 +15,7 @@ import { import { ITERATIVE_REVIEW_PHASE_NAME, PHASE_ROLE_MAP, + REGISTRATION_PHASE_NAME, SUBMISSION_PHASE_NAME, TOPGEAR_SUBMISSION_PHASE_NAME, isSubmissionPhaseName, @@ -169,6 +170,24 @@ export class First2FinishService { projectStatus: challenge.status, }); } + + const openRegistrationPhases = challenge.phases.filter( + (phaseCandidate) => + phaseCandidate.isOpen && + phaseCandidate.name === REGISTRATION_PHASE_NAME, + ); + + for (const registrationPhase of openRegistrationPhases) { + await this.schedulerService.advancePhase({ + projectId: challenge.projectId, + challengeId: challenge.id, + phaseId: registrationPhase.id, + phaseTypeName: registrationPhase.name, + state: 'END', + operator: AutopilotOperator.SYSTEM, + projectStatus: challenge.status, + }); + } } else { this.logger.log( `Iterative review failed for submission ${payload.submissionId} on challenge ${challenge.id} (score ${finalScore}, passing ${passingScore}).`, @@ -248,6 +267,23 @@ export class First2FinishService { ); return; } + + // Safety: if any non-completed review exists for this phase, do not close or reassign + const existingPairs = await this.reviewService.getExistingReviewPairs( + activePhase.id, + challenge.id, + ); + if (existingPairs.size > 0) { + this.logger.debug( + `Iterative review work detected for challenge ${challenge.id}; deferring.`, + { + submissionId: submissionId ?? null, + activePhaseId: activePhase.id, + existingPairs: existingPairs.size, + }, + ); + return; + } } if (!activePhase) { @@ -263,10 +299,19 @@ export class First2FinishService { } } - activePhase = await this.createNextIterativePhase( - challenge, - latestIterativePhase, - ); + if (this.canReuseSeedIterativePhase(latestIterativePhase)) { + activePhase = await this.reopenSeedIterativePhase( + challenge, + latestIterativePhase, + ); + } + + if (!activePhase) { + activePhase = await this.createNextIterativePhase( + challenge, + latestIterativePhase, + ); + } if (!activePhase) { return; @@ -402,52 +447,47 @@ export class First2FinishService { phase: IPhase, resourceId: string, scorecardId: string, - preferredSubmissionId?: string, + candidateSubmissionIds: string[], + usedPairs: Set, ): Promise { - const submissionIds = - await this.reviewService.getAllSubmissionIdsOrdered(challengeId); - - const orderedIds = preferredSubmissionId - ? [preferredSubmissionId, ...submissionIds] - : submissionIds; - - const seen = new Set(); - const uniqueIds = orderedIds.filter((id) => { - if (!id || seen.has(id)) { - return false; + for (const submissionId of candidateSubmissionIds) { + if (!submissionId) { + continue; } - seen.add(id); - return true; - }); - if (!uniqueIds.length) { - return false; - } - - const existingPairs = await this.reviewService.getExistingReviewPairs( - phase.id, - challengeId, - ); - - for (const submissionId of uniqueIds) { const key = `${resourceId}:${submissionId}`; - if (existingPairs.has(key)) { + if (usedPairs.has(key)) { continue; } - const created = await this.reviewService.createPendingReview( - submissionId, - resourceId, - phase.id, - scorecardId, - challengeId, - ); - - if (created) { - this.logger.log( - `Assigned iterative review for submission ${submissionId} to resource ${resourceId} on challenge ${challengeId}.`, + try { + const created = await this.reviewService.createPendingReview( + submissionId, + resourceId, + phase.id, + scorecardId, + challengeId, ); - return true; + + if (created) { + usedPairs.add(key); + this.logger.log( + `Assigned iterative review for submission ${submissionId} to resource ${resourceId} on challenge ${challengeId}.`, + ); + return true; + } + + usedPairs.add(key); + } catch (error) { + if (this.isDuplicateReviewPairError(error)) { + usedPairs.add(key); + this.logger.debug( + `Skipped duplicate iterative review assignment for submission ${submissionId} and resource ${resourceId} on challenge ${challengeId}.`, + ); + continue; + } + + throw error; } } @@ -498,11 +538,17 @@ export class First2FinishService { latestIterativePhase.id, challengeId, ); + const reviewerHistoryPairs = + await this.reviewService.getReviewerSubmissionPairs(challengeId); + const exclusionPairs = new Set([ + ...recentPairs, + ...reviewerHistoryPairs, + ]); const preferredSubmissionId = this.selectNextIterativeSubmission( reviewers, submissionIds, - recentPairs, + exclusionPairs, lastSubmissionId, ); @@ -525,10 +571,21 @@ export class First2FinishService { return; } - const nextPhase = await this.createNextIterativePhase( - challenge, - latestIterativePhase, - ); + let nextPhase: IPhase | null = null; + + if (this.canReuseSeedIterativePhase(latestIterativePhase)) { + nextPhase = await this.reopenSeedIterativePhase( + challenge, + latestIterativePhase, + ); + } + + if (!nextPhase) { + nextPhase = await this.createNextIterativePhase( + challenge, + latestIterativePhase, + ); + } if (!nextPhase) { return; @@ -567,12 +624,31 @@ export class First2FinishService { challenge: IChallenge, phase: IPhase, ): string | null { - return selectScorecardId( + // Prefer configured scorecard(s) on the challenge for the Iterative Review phase + const selected = selectScorecardId( challenge.reviewers ?? [], () => null, () => null, phase.phaseId, ); + + if (selected) { + return selected; + } + + // Fallback: use a default iterative review scorecard if provided via config + const fallback = this.configService.get( + 'autopilot.iterativeReviewScorecardId', + ); + + if (fallback && typeof fallback === 'string' && fallback.trim().length) { + this.logger.warn( + `Using fallback iterative review scorecard ${fallback} for challenge ${challenge.id} (no phase-specific scorecard configured).`, + ); + return fallback.trim(); + } + + return null; } private getLatestIterativePhase(challenge: IChallenge): IPhase | null { @@ -591,6 +667,56 @@ export class First2FinishService { return sorted.at(-1) ?? null; } + private canReuseSeedIterativePhase(phase: IPhase): boolean { + return ( + !phase.isOpen && + !phase.actualStartDate && + !phase.actualEndDate + ); + } + + private async reopenSeedIterativePhase( + challenge: IChallenge, + phase: IPhase, + ): Promise { + try { + await this.schedulerService.advancePhase({ + projectId: challenge.projectId, + challengeId: challenge.id, + phaseId: phase.id, + phaseTypeName: phase.name, + state: 'START', + operator: AutopilotOperator.SYSTEM, + projectStatus: challenge.status, + }); + + const refreshed = await this.challengeApiService.getPhaseDetails( + challenge.id, + phase.id, + ); + + if (!refreshed?.isOpen) { + this.logger.warn( + `Failed to reopen seeded iterative review phase ${phase.id} for challenge ${challenge.id}; proceeding to create a new phase.`, + ); + return null; + } + + this.logger.log( + `Reopened seeded iterative review phase ${phase.id} for challenge ${challenge.id}.`, + ); + + return refreshed; + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to reopen seeded iterative review phase ${phase.id} on challenge ${challenge.id}: ${err.message}`, + err.stack, + ); + return null; + } + } + private getPhaseStartTime(phase: IPhase): number { const reference = phase.actualStartDate ?? phase.scheduledStartDate; return new Date(reference).getTime(); @@ -599,7 +725,7 @@ export class First2FinishService { private selectNextIterativeSubmission( reviewers: Array<{ id: string }>, submissionIds: string[], - recentPairs: Set, + exclusionPairs: Set, lastSubmissionId?: string, ): string | null { for (const submissionId of submissionIds) { @@ -608,7 +734,7 @@ export class First2FinishService { } const alreadyReviewed = reviewers.some((reviewer) => - recentPairs.has(`${reviewer.id}:${submissionId}`), + exclusionPairs.has(`${reviewer.id}:${submissionId}`), ); if (!alreadyReviewed) { @@ -690,13 +816,43 @@ export class First2FinishService { scorecardId: string, preferredSubmissionId?: string, ): Promise { + const submissionIds = + await this.reviewService.getAllSubmissionIdsOrdered(challengeId); + + const orderedIds = preferredSubmissionId + ? [preferredSubmissionId, ...submissionIds] + : submissionIds; + + const seen = new Set(); + const candidateSubmissionIds = orderedIds.filter((id) => { + if (!id || seen.has(id)) { + return false; + } + seen.add(id); + return true; + }); + + if (!candidateSubmissionIds.length) { + return false; + } + + const pendingPairs = await this.reviewService.getExistingReviewPairs( + phase.id, + challengeId, + ); + const historicalPairs = await this.reviewService.getReviewerSubmissionPairs( + challengeId, + ); + const usedPairs = new Set([...pendingPairs, ...historicalPairs]); + for (const reviewer of reviewers) { const assigned = await this.assignNextIterativeReview( challengeId, phase, reviewer.id, scorecardId, - preferredSubmissionId, + candidateSubmissionIds, + usedPairs, ); if (assigned) { @@ -706,4 +862,27 @@ export class First2FinishService { return false; } + + private isDuplicateReviewPairError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + + const candidate = error as { code?: string; message?: string }; + const code = typeof candidate.code === 'string' ? candidate.code : ''; + + if (code === 'P2002' || code === 'P2034' || code === '23505') { + return true; + } + + if (code.toUpperCase().includes('23505')) { + return true; + } + + const message = typeof candidate.message === 'string' ? candidate.message : ''; + + return ( + message.includes('already exists') || message.includes('duplicate key') + ); + } } diff --git a/src/autopilot/services/phase-change-notification.service.ts b/src/autopilot/services/phase-change-notification.service.ts new file mode 100644 index 0000000..d2cf785 --- /dev/null +++ b/src/autopilot/services/phase-change-notification.service.ts @@ -0,0 +1,384 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; +import { ResourcesService } from '../../resources/resources.service'; +import { MembersService } from '../../members/members.service'; +import { ChallengeApiService } from '../../challenge/challenge-api.service'; +import { Auth0Service } from '../../auth/auth0.service'; +import { AutopilotDbLoggerService } from './autopilot-db-logger.service'; + +export interface PhaseChangeNotificationParams { + challengeId: string; + phaseId: string; + operation: 'open' | 'close'; +} + +interface NotificationPayloadData { + challengeName: string; + challengeURL: string; + phaseOpen: string | null; + phaseOpenDate: string | null; + phaseClose: string | null; + phaseCloseDate: string | null; +} + +@Injectable() +export class PhaseChangeNotificationService { + private readonly logger = new Logger(PhaseChangeNotificationService.name); + private readonly busEventsUrl: string | null; + private readonly timeoutMs: number; + private readonly originator: string; + private readonly reviewAppBaseUrl: string; + private readonly emailDomain: string; + private readonly sendgridTemplateId: string | null; + + constructor( + private readonly resourcesService: ResourcesService, + private readonly membersService: MembersService, + private readonly challengeApiService: ChallengeApiService, + private readonly auth0Service: Auth0Service, + private readonly httpService: HttpService, + private readonly configService: ConfigService, + private readonly dbLogger: AutopilotDbLoggerService, + ) { + const baseUrl = this.configService.get('bus.url')?.trim(); + this.busEventsUrl = baseUrl ? this.buildEventsUrl(baseUrl) : null; + this.timeoutMs = this.configService.get('bus.timeoutMs') ?? 10000; + this.originator = + this.configService.get('bus.originator') ?? 'autopilot-service'; + this.reviewAppBaseUrl = this.resolveReviewAppBaseUrl(); + this.emailDomain = this.resolveEmailDomain(this.reviewAppBaseUrl); + const templateId = this.configService + .get('autopilot.phaseNotificationSendgridTemplateId') + ?.trim(); + this.sendgridTemplateId = templateId && templateId.length > 0 ? templateId : null; + + if (!this.busEventsUrl) { + this.logger.warn( + 'BUS_API_URL is not configured. Phase change notifications are disabled.', + ); + } + + if (!this.sendgridTemplateId) { + this.logger.warn( + 'PHASE_NOTIFICATION_SENDGRID_TEMPLATE is not configured. Phase change notification emails are disabled.', + ); + } + } + + async sendPhaseChangeNotification( + params: PhaseChangeNotificationParams, + ): Promise { + if (!this.busEventsUrl) { + return; + } + + if (!this.sendgridTemplateId) { + await this.dbLogger.logAction('notifications.phaseChange', { + challengeId: params.challengeId, + status: 'ERROR', + source: PhaseChangeNotificationService.name, + details: { + phaseId: params.phaseId, + operation: params.operation, + error: 'PHASE_NOTIFICATION_SENDGRID_TEMPLATE is not configured.', + stage: 'configuration', + }, + }); + return; + } + + const { challengeId, phaseId, operation } = params; + + let resources; + try { + resources = await this.resourcesService.getPhaseChangeNotificationResources( + challengeId, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to load phase change notification resources for challenge ${challengeId}: ${err.message}`, + err.stack, + ); + await this.dbLogger.logAction('notifications.phaseChange', { + challengeId, + status: 'ERROR', + source: PhaseChangeNotificationService.name, + details: { + phaseId, + operation, + error: err.message, + stage: 'load-resources', + }, + }); + throw err; + } + + if (!resources.length) { + await this.dbLogger.logAction('notifications.phaseChange', { + challengeId, + status: 'INFO', + source: PhaseChangeNotificationService.name, + details: { + phaseId, + operation, + note: 'No resources opted in for phase change notifications.', + }, + }); + return; + } + + const memberIds = resources.map((resource) => resource.memberId ?? ''); + const handles = resources.map((resource) => resource.memberHandle ?? ''); + + let recipientEmails: string[] = []; + try { + const { idToEmail, handleToEmail } = await this.membersService.getMemberEmails({ + memberIds, + handles, + }); + + const emailSet = new Set(); + for (const resource of resources) { + const normalizedId = resource.memberId?.trim(); + const normalizedHandle = resource.memberHandle?.trim().toLowerCase(); + + const email = + (normalizedId && idToEmail.get(normalizedId)) || + (normalizedHandle && handleToEmail.get(normalizedHandle)); + + if (email) { + emailSet.add(email.trim()); + } + } + + recipientEmails = Array.from(emailSet); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to resolve member emails for challenge ${challengeId}: ${err.message}`, + err.stack, + ); + await this.dbLogger.logAction('notifications.phaseChange', { + challengeId, + status: 'ERROR', + source: PhaseChangeNotificationService.name, + details: { + phaseId, + operation, + error: err.message, + stage: 'resolve-emails', + }, + }); + throw err; + } + + if (!recipientEmails.length) { + await this.dbLogger.logAction('notifications.phaseChange', { + challengeId, + status: 'INFO', + source: PhaseChangeNotificationService.name, + details: { + phaseId, + operation, + note: 'No email addresses resolved for opted-in resources.', + }, + }); + return; + } + + let challenge; + try { + challenge = await this.challengeApiService.getChallengeById(challengeId); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to load challenge ${challengeId} when preparing notifications: ${err.message}`, + err.stack, + ); + await this.dbLogger.logAction('notifications.phaseChange', { + challengeId, + status: 'ERROR', + source: PhaseChangeNotificationService.name, + details: { + phaseId, + operation, + error: err.message, + stage: 'load-challenge', + }, + }); + throw err; + } + + if (!challenge) { + await this.dbLogger.logAction('notifications.phaseChange', { + challengeId, + status: 'INFO', + source: PhaseChangeNotificationService.name, + details: { + phaseId, + operation, + note: 'Challenge not found when preparing notifications.', + }, + }); + return; + } + + const phase = challenge.phases.find( + (candidate) => + candidate.id === phaseId || candidate.phaseId === phaseId, + ); + + if (!phase) { + await this.dbLogger.logAction('notifications.phaseChange', { + challengeId, + status: 'INFO', + source: PhaseChangeNotificationService.name, + details: { + phaseId, + operation, + note: 'Phase not found on challenge after transition.', + }, + }); + return; + } + + const payloadData: NotificationPayloadData = { + challengeName: challenge.name, + challengeURL: this.buildChallengeUrl(challengeId), + phaseOpen: operation === 'open' ? phase.name : null, + phaseOpenDate: + operation === 'open' + ? phase.actualStartDate ?? new Date().toISOString() + : null, + phaseClose: operation === 'close' ? phase.name : null, + phaseCloseDate: + operation === 'close' + ? phase.actualEndDate ?? new Date().toISOString() + : null, + }; + + const defaultNotificationEmail = `no-reply@${this.emailDomain}.com`; + + const message = { + topic: 'external.action.email', + originator: this.originator, + timestamp: new Date().toISOString(), + 'mime-type': 'application/json', + payload: { + from: defaultNotificationEmail, + replyTo: defaultNotificationEmail, + recipients: recipientEmails, + data: payloadData, + sendgrid_template_id: this.sendgridTemplateId, + version: 'v3', + }, + }; + + try { + const token = await this.auth0Service.getAccessToken(); + + await firstValueFrom( + this.httpService.post(this.busEventsUrl, message, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + timeout: this.timeoutMs, + }), + ); + + await this.dbLogger.logAction('notifications.phaseChange', { + challengeId, + status: 'SUCCESS', + source: PhaseChangeNotificationService.name, + details: { + phaseId, + operation, + recipients: recipientEmails.length, + payload: payloadData, + }, + }); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to publish phase change notification for challenge ${challengeId}: ${err.message}`, + err.stack, + ); + await this.dbLogger.logAction('notifications.phaseChange', { + challengeId, + status: 'ERROR', + source: PhaseChangeNotificationService.name, + details: { + phaseId, + operation, + recipients: recipientEmails.length, + error: err.message, + stage: 'publish', + }, + }); + throw err; + } + } + + private resolveReviewAppBaseUrl(): string { + const configured = this.configService + .get('app.reviewAppUrl') + ?.trim(); + + if (configured && configured.length > 0) { + return this.normalizeBaseUrl(configured); + } + + const domain = this.resolveDefaultDomain(); + return `https://review.${domain}.com/`; + } + + private resolveEmailDomain(baseUrl: string): string { + try { + const host = new URL(`${baseUrl}/`).hostname; + const hostParts = host.split('.'); + if (hostParts.length >= 2) { + return hostParts[hostParts.length - 2]; + } + } catch (error) { + this.logger.warn( + `Unable to parse review app URL "${baseUrl}" for email domain resolution: ${(error as Error).message}`, + ); + } + + return this.resolveDefaultDomain(); + } + + private resolveDefaultDomain(): string { + const auth0Domain = + this.configService.get('auth0.domain')?.toLowerCase() ?? ''; + + if (auth0Domain.includes('topcoder-dev')) { + return 'topcoder-dev'; + } + + return 'topcoder'; + } + + private buildChallengeUrl(challengeId: string): string { + const base = this.reviewAppBaseUrl.endsWith('/') + ? this.reviewAppBaseUrl.slice(0, -1) + : this.reviewAppBaseUrl; + + return `${base}/active-challenges/${challengeId}/challenge-details`; + } + + private normalizeBaseUrl(value: string): string { + return value.endsWith('/') ? value.slice(0, -1) : value; + } + + private buildEventsUrl(baseUrl: string): string { + if (!baseUrl.endsWith('/')) { + baseUrl = `${baseUrl}/`; + } + return new URL('v5/bus/events', baseUrl).toString(); + } +} diff --git a/src/autopilot/services/phase-review.service.spec.ts b/src/autopilot/services/phase-review.service.spec.ts new file mode 100644 index 0000000..c23f37e --- /dev/null +++ b/src/autopilot/services/phase-review.service.spec.ts @@ -0,0 +1,403 @@ +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PhaseReviewService } from './phase-review.service'; +import { ChallengeApiService } from '../../challenge/challenge-api.service'; +import type { IChallenge } from '../../challenge/interfaces/challenge.interface'; +import { ReviewService, ActiveContestSubmission } from '../../review/review.service'; +import { ResourcesService } from '../../resources/resources.service'; + +const basePhase = { + id: 'phase-1', + phaseId: 'template-1', + name: 'Review', + description: null, + isOpen: true, + duration: 1000, + scheduledStartDate: new Date().toISOString(), + scheduledEndDate: new Date().toISOString(), + actualStartDate: null, + actualEndDate: null, + predecessor: null, + constraints: [], +}; + +const buildChallenge = ( + metadata: Record, +): IChallenge => ({ + id: 'challenge-1', + name: 'Test Challenge', + description: null, + descriptionFormat: 'markdown', + projectId: 1, + typeId: 'type-1', + trackId: 'track-1', + timelineTemplateId: 'timeline-1', + currentPhaseNames: [], + tags: [], + groups: [], + submissionStartDate: new Date().toISOString(), + submissionEndDate: new Date().toISOString(), + registrationStartDate: new Date().toISOString(), + registrationEndDate: new Date().toISOString(), + startDate: new Date().toISOString(), + endDate: null, + legacyId: null, + status: 'ACTIVE', + createdBy: 'test', + updatedBy: 'test', + metadata, + phases: [{ ...basePhase }], + reviewers: [ + { + id: 'reviewer-config', + scorecardId: 'scorecard-1', + isMemberReview: true, + memberReviewerCount: 1, + phaseId: basePhase.phaseId, + basePayment: null, + incrementalPayment: null, + type: null, + aiWorkflowId: null, + shouldOpenOpportunity: true, + }, + ], + winners: [], + discussions: [], + events: [], + prizeSets: [], + terms: [], + skills: [], + attachments: [], + track: 'Development', + type: 'Development', + legacy: {}, + task: {}, + created: new Date().toISOString(), + updated: new Date().toISOString(), + overview: {}, + numOfSubmissions: 0, + numOfCheckpointSubmissions: 0, + numOfRegistrants: 0, +}); + +describe('PhaseReviewService', () => { + let loggerLogSpy: jest.SpyInstance; + let loggerWarnSpy: jest.SpyInstance; + let service: PhaseReviewService; + let challengeApiService: jest.Mocked; + let reviewService: jest.Mocked; + let resourcesService: jest.Mocked; + let configService: jest.Mocked; + + beforeAll(() => { + loggerLogSpy = jest + .spyOn(Logger.prototype, 'log') + .mockImplementation(() => undefined); + loggerWarnSpy = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + }); + + beforeEach(() => { + challengeApiService = { + getChallengeById: jest.fn(), + } as unknown as jest.Mocked; + + reviewService = { + getActiveContestSubmissions: jest.fn(), + getExistingReviewPairs: jest.fn(), + createPendingReview: jest.fn(), + getFailedScreeningSubmissionIds: jest.fn(), + getCheckpointPassedSubmissionIds: jest.fn(), + } as unknown as jest.Mocked; + + resourcesService = { + getReviewerResources: jest.fn(), + getResourcesByRoleNames: jest.fn(), + getResourceById: jest.fn(), + getRoleNameById: jest.fn(), + hasSubmitterResource: jest.fn(), + getMemberHandleMap: jest.fn(), + getResourceByMemberHandle: jest.fn(), + } as unknown as jest.Mocked; + + configService = { + get: jest.fn(), + } as unknown as jest.Mocked; + + reviewService.getExistingReviewPairs.mockResolvedValue(new Set()); + resourcesService.getReviewerResources.mockResolvedValue([{ id: 'resource-1' }] as any); + reviewService.createPendingReview.mockResolvedValue(true); + reviewService.getFailedScreeningSubmissionIds.mockResolvedValue( + new Set(), + ); + + service = new PhaseReviewService( + challengeApiService, + reviewService, + resourcesService, + configService, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + loggerLogSpy?.mockRestore(); + loggerWarnSpy?.mockRestore(); + }); + + it('creates reviews only for latest submissions when submissionLimit metadata is not set', async () => { + const challenge = buildChallenge({}); + challengeApiService.getChallengeById.mockResolvedValue(challenge); + + const submissions: ActiveContestSubmission[] = [ + { id: 'old-submission', memberId: '123', isLatest: false }, + { id: 'latest-submission', memberId: '123', isLatest: true }, + { id: 'unique-submission', memberId: null, isLatest: true }, + ]; + + reviewService.getActiveContestSubmissions.mockResolvedValue(submissions); + + await service.handlePhaseOpened(challenge.id, challenge.phases[0].id); + + expect(reviewService.getActiveContestSubmissions).toHaveBeenCalledWith( + challenge.id, + ); + + const createdSubmissionIds = + reviewService.createPendingReview.mock.calls.map( + (callArgs) => callArgs[0], + ); + + expect(createdSubmissionIds).toEqual([ + 'latest-submission', + 'unique-submission', + ]); + }); + + it('includes all submissions when submissionLimit metadata indicates unlimited', async () => { + const submissionLimit = JSON.stringify({ + unlimited: 'true', + limit: 'false', + count: '', + }); + const challenge = buildChallenge({ submissionLimit }); + challengeApiService.getChallengeById.mockResolvedValue(challenge); + + const submissions: ActiveContestSubmission[] = [ + { id: 'old-submission', memberId: '123', isLatest: false }, + { id: 'latest-submission', memberId: '123', isLatest: true }, + ]; + + reviewService.getActiveContestSubmissions.mockResolvedValue(submissions); + + await service.handlePhaseOpened(challenge.id, challenge.phases[0].id); + + const createdSubmissionIds = + reviewService.createPendingReview.mock.calls.map( + (callArgs) => callArgs[0], + ); + + expect(createdSubmissionIds).toEqual([ + 'old-submission', + 'latest-submission', + ]); + }); + + it('creates reviews only for latest submissions when the challenge enforces a submission limit', async () => { + const submissionLimit = JSON.stringify({ limit: 'true', count: 2 }); + const challenge = buildChallenge({ submissionLimit }); + challengeApiService.getChallengeById.mockResolvedValue(challenge); + + const submissions: ActiveContestSubmission[] = [ + { id: 'old-submission', memberId: '123', isLatest: false }, + { id: 'latest-submission', memberId: '123', isLatest: true }, + ]; + + reviewService.getActiveContestSubmissions.mockResolvedValue(submissions); + + await service.handlePhaseOpened(challenge.id, challenge.phases[0].id); + + const createdSubmissionIds = + reviewService.createPendingReview.mock.calls.map( + (callArgs) => callArgs[0], + ); + + expect(createdSubmissionIds).toEqual([ + 'latest-submission', + ]); + expect(createdSubmissionIds).not.toContain('old-submission'); + }); + + it.each([ + ['string "null"', 'null'], + ['malformed JSON string', '{"limit": }'], + ['boolean true', true], + ])( + 'treats %s submissionLimit as limited', + async (_description, submissionLimit) => { + const challenge = buildChallenge({ + submissionLimit, + } as any); + challengeApiService.getChallengeById.mockResolvedValue(challenge); + + const submissions: ActiveContestSubmission[] = [ + { id: 'old-submission', memberId: '123', isLatest: false }, + { id: 'latest-submission', memberId: '123', isLatest: true }, + ]; + + reviewService.getActiveContestSubmissions.mockResolvedValue(submissions); + + await service.handlePhaseOpened(challenge.id, challenge.phases[0].id); + + const createdSubmissionIds = + reviewService.createPendingReview.mock.calls.map( + (callArgs) => callArgs[0], + ); + + expect(createdSubmissionIds).toEqual(['latest-submission']); + + const warningMessages = loggerWarnSpy.mock.calls.map( + ([message]) => message as string, + ); + expect(warningMessages).toEqual( + expect.arrayContaining([ + expect.stringContaining('defaulting to limited submissions.'), + ]), + ); + }, + ); + + it('skips non-latest submissions without member IDs when a limit is enforced', async () => { + const submissionLimit = JSON.stringify({ limit: 'true', count: 1 }); + const challenge = buildChallenge({ submissionLimit }); + challengeApiService.getChallengeById.mockResolvedValue(challenge); + + const submissions: ActiveContestSubmission[] = [ + { id: 'legacy-submission', memberId: null, isLatest: false }, + { id: 'latest-submission', memberId: null, isLatest: true }, + ]; + + reviewService.getActiveContestSubmissions.mockResolvedValue(submissions); + + await service.handlePhaseOpened(challenge.id, challenge.phases[0].id); + + const createdSubmissionIds = + reviewService.createPendingReview.mock.calls.map( + (callArgs) => callArgs[0], + ); + + expect(createdSubmissionIds).toEqual(['latest-submission']); + }); + + it('omits submissions that failed screening', async () => { + const challenge = buildChallenge({}); + const screeningPhase = { + ...basePhase, + id: 'phase-screening', + phaseId: 'template-screening', + name: 'Screening', + }; + challenge.phases = [{ ...basePhase }, screeningPhase]; + challenge.reviewers.push({ + id: 'screening-config', + scorecardId: 'screening-scorecard', + isMemberReview: false, + memberReviewerCount: 1, + phaseId: screeningPhase.phaseId, + basePayment: null, + incrementalPayment: null, + type: null, + aiWorkflowId: null, + shouldOpenOpportunity: true, + }); + + challengeApiService.getChallengeById.mockResolvedValue(challenge); + + const submissions: ActiveContestSubmission[] = [ + { id: 'failed-submission', memberId: '123', isLatest: true }, + { id: 'passed-submission', memberId: '456', isLatest: true }, + ]; + + reviewService.getActiveContestSubmissions.mockResolvedValue(submissions); + reviewService.getFailedScreeningSubmissionIds.mockResolvedValue( + new Set(['failed-submission']), + ); + + await service.handlePhaseOpened(challenge.id, basePhase.id); + + expect( + reviewService.getFailedScreeningSubmissionIds, + ).toHaveBeenCalledWith(challenge.id, ['screening-scorecard']); + + const createdSubmissionIds = + reviewService.createPendingReview.mock.calls.map( + (callArgs) => callArgs[0], + ); + + expect(createdSubmissionIds).toEqual(['passed-submission']); + }); + + it('uses checkpoint reviewer resources when checkpoint review phase opens', async () => { + const challenge = buildChallenge({}); + const checkpointPhase = { + ...basePhase, + id: 'phase-checkpoint-review', + phaseId: 'template-checkpoint-review', + name: 'Checkpoint Review', + }; + const screeningPhase = { + ...basePhase, + id: 'phase-screening', + phaseId: 'template-screening', + name: 'Checkpoint Screening', + }; + + challenge.phases = [checkpointPhase, screeningPhase]; + + const baseReviewerConfig = challenge.reviewers[0]; + challenge.reviewers = [ + { + ...baseReviewerConfig, + id: 'checkpoint-config', + phaseId: checkpointPhase.phaseId, + scorecardId: 'checkpoint-scorecard', + }, + { + ...baseReviewerConfig, + id: 'screening-config', + phaseId: screeningPhase.phaseId, + scorecardId: 'screening-scorecard', + isMemberReview: false, + }, + ]; + + challengeApiService.getChallengeById.mockResolvedValue(challenge); + resourcesService.getReviewerResources.mockResolvedValue([ + { id: 'checkpoint-resource' }, + ] as any); + reviewService.getCheckpointPassedSubmissionIds.mockResolvedValue([ + 'submission-1', + ]); + + await service.handlePhaseOpened(challenge.id, checkpointPhase.id); + + expect(resourcesService.getReviewerResources).toHaveBeenCalledWith( + challenge.id, + ['Checkpoint Reviewer'], + ); + expect( + reviewService.getCheckpointPassedSubmissionIds, + ).toHaveBeenCalledWith(challenge.id, 'screening-scorecard'); + expect(reviewService.createPendingReview).toHaveBeenCalledWith( + 'submission-1', + 'checkpoint-resource', + checkpointPhase.id, + 'checkpoint-scorecard', + challenge.id, + ); + }); +}); diff --git a/src/autopilot/services/phase-review.service.ts b/src/autopilot/services/phase-review.service.ts index 69e8df3..3927b8d 100644 --- a/src/autopilot/services/phase-review.service.ts +++ b/src/autopilot/services/phase-review.service.ts @@ -1,15 +1,23 @@ import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { ChallengeApiService } from '../../challenge/challenge-api.service'; +import type { IChallenge } from '../../challenge/interfaces/challenge.interface'; import { ReviewService } from '../../review/review.service'; +import type { ActiveContestSubmission } from '../../review/review.service'; import { ResourcesService } from '../../resources/resources.service'; import { getRoleNamesForPhase, REVIEW_PHASE_NAMES, + SCREENING_PHASE_NAMES, + APPROVAL_PHASE_NAMES, + POST_MORTEM_PHASE_NAME, } from '../constants/review.constants'; import { getMemberReviewerConfigs, + getReviewerConfigsForPhase, selectScorecardId, } from '../utils/reviewer.utils'; +import { isTopgearTaskChallenge } from '../constants/challenge.constants'; @Injectable() export class PhaseReviewService { @@ -19,6 +27,7 @@ export class PhaseReviewService { private readonly challengeApiService: ChallengeApiService, private readonly reviewService: ReviewService, private readonly resourcesService: ResourcesService, + private readonly configService: ConfigService, ) {} async handlePhaseOpened(challengeId: string, phaseId: string): Promise { @@ -33,13 +42,109 @@ export class PhaseReviewService { return; } - if (!REVIEW_PHASE_NAMES.has(phase.name)) { + const allowUnlimitedSubmissions = + this.challengeAllowsUnlimitedSubmissions(challenge); + + const isReviewPhase = REVIEW_PHASE_NAMES.has(phase.name); + const isScreeningPhase = SCREENING_PHASE_NAMES.has(phase.name); + const isApprovalPhase = APPROVAL_PHASE_NAMES.has(phase.name); + + if (!isReviewPhase && !isScreeningPhase && !isApprovalPhase) { + return; + } + + // Special handling for Post-Mortem: create challenge-level pending reviews (no submissions) + if (phase.name === POST_MORTEM_PHASE_NAME) { + // Determine scorecard + let scorecardId: string | null = null; + if (isTopgearTaskChallenge(challenge.type)) { + scorecardId = + this.configService.get( + 'autopilot.topgearPostMortemScorecardId', + ) ?? null; + if (!scorecardId) { + try { + scorecardId = await this.reviewService.getScorecardIdByName( + 'Topgear Task Post Mortem', + ); + } catch (_) { + // Logged inside review service; continue with null + } + } + } else { + scorecardId = + this.configService.get( + 'autopilot.postMortemScorecardId', + ) ?? null; + + // Fallback to the standard Topcoder Post Mortem scorecard by name + if (!scorecardId) { + try { + scorecardId = await this.reviewService.getScorecardIdByName( + 'Topcoder Post Mortem', + ); + } catch (_) { + // Logged inside review service; continue with null + } + } + } + + if (!scorecardId) { + this.logger.warn( + `Post-mortem scorecard is not configured; skipping review creation for challenge ${challengeId}, phase ${phase.id}.`, + ); + return; + } + + const roleNames = getRoleNamesForPhase(phase.name); + const reviewerResources = await this.resourcesService.getReviewerResources( + challengeId, + roleNames, + ); + + if (!reviewerResources.length) { + this.logger.log( + `No resources found for post-mortem roles on challenge ${challengeId}; skipping review creation for phase ${phase.id}.`, + ); + return; + } + + let createdCount = 0; + for (const resource of reviewerResources) { + try { + const created = await this.reviewService.createPendingReview( + null, + resource.id, + phase.id, + scorecardId, + challengeId, + ); + if (created) { + createdCount++; + } + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to create post-mortem review for challenge ${challengeId}, phase ${phase.id}, resource ${resource.id}: ${err.message}`, + err.stack, + ); + } + } + + if (createdCount > 0) { + this.logger.log( + `Created ${createdCount} post-mortem pending review(s) for challenge ${challengeId}, phase ${phase.id}.`, + ); + } return; } - const reviewerConfigs = getMemberReviewerConfigs( - challenge.reviewers, - phase.phaseId, + // Determine reviewer configs for scorecard selection. + // For screening phases, configs may not be marked isMemberReview, so include all for the phase. + const reviewerConfigs = ( + isScreeningPhase + ? getReviewerConfigsForPhase(challenge.reviewers, phase.phaseId) + : getMemberReviewerConfigs(challenge.reviewers, phase.phaseId) ).filter((config) => Boolean(config.scorecardId)); if (!reviewerConfigs.length) { @@ -49,17 +154,46 @@ export class PhaseReviewService { return; } - const scorecardId = selectScorecardId( - reviewerConfigs, - () => - this.logger.warn( - `Member reviewer configs missing scorecard IDs for challenge ${challengeId}, phase ${phase.id}`, + // Select scorecard + // For screening phases, reviewer configs may not be flagged as member reviews. + // Use any configured scorecard IDs for the phase as-is (without re-filtering by isMemberReview). + let scorecardId: string | null; + if (isScreeningPhase) { + const uniqueScorecards = Array.from( + new Set( + reviewerConfigs + .map((config) => config.scorecardId) + .filter((id): id is string => Boolean(id)), ), - (choices) => + ); + + if (uniqueScorecards.length === 0) { this.logger.warn( - `Multiple scorecard IDs detected for challenge ${challengeId}, phase ${phase.id}. Using ${choices[0]} for pending reviews`, - ), - ); + `Reviewer configs missing scorecard IDs for challenge ${challengeId}, phase ${phase.id}`, + ); + return; + } + + if (uniqueScorecards.length > 1) { + this.logger.warn( + `Multiple scorecard IDs detected for challenge ${challengeId}, phase ${phase.id}. Using ${uniqueScorecards[0]} for pending reviews`, + ); + } + + scorecardId = uniqueScorecards[0] ?? null; + } else { + scorecardId = selectScorecardId( + reviewerConfigs, + () => + this.logger.warn( + `Member reviewer configs missing scorecard IDs for challenge ${challengeId}, phase ${phase.id}`, + ), + (choices) => + this.logger.warn( + `Multiple scorecard IDs detected for challenge ${challengeId}, phase ${phase.id}. Using ${choices[0]} for pending reviews`, + ), + ); + } if (!scorecardId) { return; } @@ -77,8 +211,111 @@ export class PhaseReviewService { return; } - const submissionIds = - await this.reviewService.getActiveSubmissionIds(challengeId); + let submissionIds: string[] = []; + if (isApprovalPhase) { + // Only the top final-scoring submission (winner) should be reviewed + const winners = await this.reviewService.getTopFinalReviewScores( + challengeId, + 1, + ); + submissionIds = winners.map((w) => w.submissionId); + } else if (phase.name === 'Checkpoint Screening') { + // For checkpoint screening, review all active checkpoint submissions + submissionIds = await this.reviewService.getActiveCheckpointSubmissionIds( + challengeId, + ); + } else if (phase.name === 'Checkpoint Review') { + // For checkpoint review, only review submissions that PASSED checkpoint screening + // Find the screening phase template and its configured scorecard + const screeningPhase = (challenge.phases ?? []).find( + (p) => p.name === 'Checkpoint Screening', + ); + + if (!screeningPhase?.phaseId) { + this.logger.warn( + `Checkpoint Review opened, but no Checkpoint Screening phase found for challenge ${challengeId}; skipping review creation for phase ${phase.id}`, + ); + return; + } + + const screeningConfigs = getReviewerConfigsForPhase( + challenge.reviewers, + screeningPhase.phaseId, + ).filter((c) => Boolean(c.scorecardId)); + + // Unique screening scorecard(s) configured + const screeningScorecardId = Array.from( + new Set( + screeningConfigs + .map((c) => c.scorecardId) + .filter((id): id is string => Boolean(id)), + ), + )[0]; + + if (!screeningScorecardId) { + this.logger.warn( + `Checkpoint Review opened, but no screening scorecard configured for challenge ${challengeId}; skipping review creation for phase ${phase.id}`, + ); + return; + } + + try { + submissionIds = + await this.reviewService.getCheckpointPassedSubmissionIds( + challengeId, + screeningScorecardId, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to resolve checkpoint-passing submissions for challenge ${challengeId}, phase ${phase.id}: ${err.message}`, + err.stack, + ); + throw err; + } + } else { + const activeSubmissions = + await this.reviewService.getActiveContestSubmissions(challengeId); + + let filteredSubmissions: ActiveContestSubmission[]; + + if (allowUnlimitedSubmissions) { + filteredSubmissions = activeSubmissions; + } else { + filteredSubmissions = this.selectLatestSubmissions(activeSubmissions); + + if (!filteredSubmissions.length && activeSubmissions.length) { + this.logger.warn( + `No latest submissions found for challenge ${challengeId} in phase ${phase.id}; skipping review creation because only the latest submission per member is reviewed when a submission limit is enforced.`, + ); + } + } + + if (!allowUnlimitedSubmissions) { + const skipped = + activeSubmissions.length - filteredSubmissions.length; + if (skipped > 0 && filteredSubmissions.length > 0) { + this.logger.log( + `Skipping ${skipped} older submission(s) for challenge ${challengeId} in phase ${phase.id} because only the latest submissions are reviewed when the submission limit is enforced.`, + ); + } + } + + submissionIds = Array.from( + new Set(filteredSubmissions.map((submission) => submission.id)), + ); + } + if ( + submissionIds.length && + (isApprovalPhase || + (isReviewPhase && phase.name !== 'Checkpoint Review')) + ) { + submissionIds = await this.excludeFailedScreeningSubmissions( + challenge, + submissionIds, + ); + } + if (!submissionIds.length) { this.logger.log( `No submissions found for challenge ${challengeId}; skipping review creation for phase ${phase.name}`, @@ -127,4 +364,280 @@ export class PhaseReviewService { ); } } + + private async excludeFailedScreeningSubmissions( + challenge: IChallenge, + submissionIds: string[], + ): Promise { + if (!submissionIds.length) { + return submissionIds; + } + + const screeningScorecardIds = this.getScreeningScorecardIds(challenge); + if (!screeningScorecardIds.length) { + return submissionIds; + } + + try { + const failedIds = + await this.reviewService.getFailedScreeningSubmissionIds( + challenge.id, + screeningScorecardIds, + ); + + if (!failedIds.size) { + return submissionIds; + } + + const filtered = submissionIds.filter((id) => !failedIds.has(id)); + const removedCount = submissionIds.length - filtered.length; + + if (removedCount > 0) { + this.logger.log( + `Excluded ${removedCount} submission(s) for challenge ${challenge.id} due to failed screening.`, + ); + } + + return filtered; + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to filter screened submissions for challenge ${challenge.id}: ${err.message}`, + err.stack, + ); + return submissionIds; + } + } + + private getScreeningScorecardIds(challenge: IChallenge): string[] { + const screeningTemplateIds = (challenge.phases ?? []) + .filter((phase) => phase?.phaseId && phase.name === 'Screening') + .map((phase) => phase.phaseId); + + const scorecardIds = new Set(); + + for (const templateId of screeningTemplateIds) { + const configs = getReviewerConfigsForPhase( + challenge.reviewers, + templateId, + ); + + for (const config of configs) { + if (config.scorecardId) { + scorecardIds.add(config.scorecardId); + } + } + } + + if (!scorecardIds.size) { + const legacy = challenge.legacy as + | { screeningScorecardId?: unknown } + | undefined; + const legacyScorecardId = + legacy && typeof legacy === 'object' + ? (legacy as Record).screeningScorecardId + : undefined; + + if ( + typeof legacyScorecardId === 'string' && + legacyScorecardId.trim() + ) { + scorecardIds.add(legacyScorecardId.trim()); + } + } + + return Array.from(scorecardIds); + } + + private selectLatestSubmissions( + submissions: ActiveContestSubmission[], + ): ActiveContestSubmission[] { + if (!submissions.length) { + return []; + } + + const selected: ActiveContestSubmission[] = []; + const addedKeys = new Set(); + + for (const submission of submissions) { + if (!submission.isLatest) { + continue; + } + + const key = submission.memberId ?? submission.id; + if (addedKeys.has(key)) { + continue; + } + + selected.push(submission); + addedKeys.add(key); + } + + return selected; + } + + private challengeAllowsUnlimitedSubmissions( + challenge: IChallenge, + ): boolean { + const metadata = challenge.metadata ?? {}; + const rawValue = metadata['submissionLimit']; + + if (rawValue == null) { + return false; + } + + const warnUnrecognized = (value: unknown) => + this.warnUnrecognizedSubmissionLimit(challenge, value); + + let parsed: unknown = rawValue; + + if (typeof rawValue === 'string') { + const trimmed = rawValue.trim(); + if (!trimmed) { + warnUnrecognized(rawValue); + return false; + } + + try { + parsed = JSON.parse(trimmed); + } catch { + const numericValue = Number(trimmed); + if (Number.isFinite(numericValue) && numericValue > 0) { + return false; + } + const normalized = trimmed.toLowerCase(); + if (['unlimited', 'false', '0', 'no', 'none'].includes(normalized)) { + return true; + } + warnUnrecognized(trimmed); + return false; + } + } + + if (typeof parsed === 'number') { + return !(Number.isFinite(parsed) && parsed > 0); + } + + if (typeof parsed === 'string') { + const numericValue = Number(parsed); + if (Number.isFinite(numericValue) && numericValue > 0) { + return false; + } + const normalized = parsed.trim().toLowerCase(); + if (['unlimited', 'false', '0', 'no', 'none'].includes(normalized)) { + return true; + } + warnUnrecognized(parsed); + return false; + } + + if (parsed && typeof parsed === 'object') { + const record = parsed as Record; + + const unlimited = this.parseBooleanFlag(record.unlimited); + if (unlimited === true) { + return true; + } + + const candidates = [ + record.count, + record.max, + record.maximum, + record.limitCount, + record.value, + ]; + + for (const candidate of candidates) { + if (candidate === undefined || candidate === null) { + continue; + } + const numericValue = Number(candidate); + if (Number.isFinite(numericValue) && numericValue > 0) { + return false; + } + } + + const limitFlag = this.parseBooleanFlag(record.limit); + if (limitFlag === true) { + return false; + } + if (limitFlag === false) { + return true; + } + + warnUnrecognized(record); + return false; + } + + warnUnrecognized(parsed); + return false; + } + + private warnUnrecognizedSubmissionLimit( + challenge: IChallenge, + value: unknown, + ): void { + const valueDescription = this.describeSubmissionLimitValue(value); + this.logger.warn( + `Unrecognized submissionLimit metadata value ${valueDescription} for challenge ${challenge.id}; defaulting to limited submissions.`, + ); + } + + private describeSubmissionLimitValue(value: unknown): string { + if (value === undefined) { + return 'undefined'; + } + if (value === null) { + return 'null'; + } + if (typeof value === 'string') { + return value.trim().length ? `"${value}"` : '(empty string)'; + } + if (typeof value === 'number') { + if (Number.isNaN(value)) { + return 'NaN'; + } + return value.toString(); + } + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + if (typeof value === 'object') { + try { + return JSON.stringify(value); + } catch { + return Object.prototype.toString.call(value); + } + } + return String(value); + } + + private parseBooleanFlag(value: unknown): boolean | null { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (['true', 'yes', '1'].includes(normalized)) { + return true; + } + if (['false', 'no', '0'].includes(normalized)) { + return false; + } + return null; + } + + if (typeof value === 'number') { + if (value === 1) { + return true; + } + if (value === 0) { + return false; + } + return null; + } + + return null; + } } diff --git a/src/autopilot/services/phase-schedule-manager.service.ts b/src/autopilot/services/phase-schedule-manager.service.ts index 600748c..226156e 100644 --- a/src/autopilot/services/phase-schedule-manager.service.ts +++ b/src/autopilot/services/phase-schedule-manager.service.ts @@ -7,22 +7,31 @@ import { import { SchedulerService } from './scheduler.service'; import { PhaseReviewService } from './phase-review.service'; import { ReviewAssignmentService } from './review-assignment.service'; +import { ReviewService } from '../../review/review.service'; import { AutopilotOperator, ChallengeUpdatePayload, PhaseTransitionPayload, } from '../interfaces/autopilot.interface'; -import { REVIEW_PHASE_NAMES } from '../constants/review.constants'; +import { + DEFAULT_APPEALS_RESPONSE_PHASE_NAMES, + REVIEW_PHASE_NAMES, +} from '../constants/review.constants'; +import { getNormalizedStringArray, isActiveStatus } from '../utils/config.utils'; +import { ConfigService } from '@nestjs/config'; @Injectable() export class PhaseScheduleManager { private readonly logger = new Logger(PhaseScheduleManager.name); + private readonly appealsResponsePhaseNames: Set; constructor( private readonly schedulerService: SchedulerService, private readonly challengeApiService: ChallengeApiService, private readonly phaseReviewService: PhaseReviewService, private readonly reviewAssignmentService: ReviewAssignmentService, + private readonly reviewService: ReviewService, + private readonly configService: ConfigService, ) { this.schedulerService.setPhaseChainCallback( ( @@ -39,6 +48,13 @@ export class PhaseScheduleManager { ); }, ); + + this.appealsResponsePhaseNames = new Set( + getNormalizedStringArray( + this.configService.get('autopilot.appealsResponsePhaseNames'), + Array.from(DEFAULT_APPEALS_RESPONSE_PHASE_NAMES), + ), + ); } async schedulePhaseTransition( @@ -145,7 +161,7 @@ export class PhaseScheduleManager { `Consumed phase transition event: ${JSON.stringify(message)}`, ); - if (!this.isChallengeActive(message.projectStatus)) { + if (!isActiveStatus(message.projectStatus)) { this.logger.log( `Ignoring phase transition for challenge ${message.challengeId} with status ${message.projectStatus}; only ACTIVE challenges are processed.`, ); @@ -188,7 +204,7 @@ export class PhaseScheduleManager { challenge.id, ); - if (!this.isChallengeActive(challengeDetails.status)) { + if (!isActiveStatus(challengeDetails.status)) { this.logger.log( `Skipping challenge ${challenge.id} with status ${challengeDetails.status}; only ACTIVE challenges are processed.`, ); @@ -239,7 +255,7 @@ export class PhaseScheduleManager { message.id, ); - if (!this.isChallengeActive(challengeDetails.status)) { + if (!isActiveStatus(challengeDetails.status)) { this.logger.log( `Skipping challenge ${message.id} update with status ${challengeDetails.status}; only ACTIVE challenges are processed.`, ); @@ -476,7 +492,7 @@ export class PhaseScheduleManager { projectStatus: string, nextPhases: IPhase[], ): Promise { - if (!this.isChallengeActive(projectStatus)) { + if (!isActiveStatus(projectStatus)) { this.logger.log( `[PHASE CHAIN] Challenge ${challengeId} is not ACTIVE (status: ${projectStatus}), skipping phase chain processing.`, ); @@ -552,7 +568,7 @@ export class PhaseScheduleManager { projectStatus: string, phase: IPhase, ): Promise { - if (!this.isChallengeActive(projectStatus)) { + if (!isActiveStatus(projectStatus)) { this.logger.log( `[PHASE CHAIN] Challenge ${challengeId} is not ACTIVE (status: ${projectStatus}); skipping phase ${phase.name} (${phase.id}).`, ); @@ -582,16 +598,16 @@ export class PhaseScheduleManager { `[PHASE CHAIN] Successfully opened phase ${phase.name} (${phase.id}) for challenge ${challengeId}`, ); - if (REVIEW_PHASE_NAMES.has(phase.name)) { - try { - await this.phaseReviewService.handlePhaseOpened(challengeId, phase.id); - } catch (error) { - const err = error as Error; - this.logger.error( - `[PHASE CHAIN] Failed to prepare review records for phase ${phase.name} (${phase.id}) on challenge ${challengeId}: ${err.message}`, - err.stack, - ); - } + // Create pending reviews for any review-related phases (Review, Screening, Approval). + // PhaseReviewService will ignore non review phases. + try { + await this.phaseReviewService.handlePhaseOpened(challengeId, phase.id); + } catch (error) { + const err = error as Error; + this.logger.error( + `[PHASE CHAIN] Failed to prepare review records for phase ${phase.name} (${phase.id}) on challenge ${challengeId}: ${err.message}`, + err.stack, + ); } const updatedPhase = @@ -604,6 +620,39 @@ export class PhaseScheduleManager { return false; } + if (this.isAppealsResponsePhaseName(updatedPhase.name)) { + try { + const totalAppeals = + await this.reviewService.getTotalAppealCount(challengeId); + + if (totalAppeals === 0) { + this.logger.log( + `[APPEALS RESPONSE] No appeals detected for challenge ${challengeId}; closing phase ${updatedPhase.id} immediately after open.`, + ); + + const closePayload: PhaseTransitionPayload = { + projectId, + challengeId, + phaseId: updatedPhase.id, + phaseTypeName: updatedPhase.name, + state: 'END', + operator: AutopilotOperator.SYSTEM_PHASE_CHAIN, + projectStatus, + date: new Date().toISOString(), + }; + + await this.schedulerService.advancePhase(closePayload); + return true; + } + } catch (error) { + const err = error as Error; + this.logger.error( + `[APPEALS RESPONSE] Unable to auto-close phase ${updatedPhase.id} for challenge ${challengeId}: ${err.message}`, + err.stack, + ); + } + } + const existingJobId = this.schedulerService.buildJobId( challengeId, phase.id, @@ -633,7 +682,16 @@ export class PhaseScheduleManager { return true; } - private isChallengeActive(status?: string): boolean { - return (status ?? '').toUpperCase() === 'ACTIVE'; + // isActiveStatus utility now centralizes active-status checks + + private isAppealsResponsePhaseName( + phaseName?: string | null, + ): boolean { + const normalized = phaseName?.trim(); + if (!normalized) { + return false; + } + + return this.appealsResponsePhaseNames.has(normalized); } } diff --git a/src/autopilot/services/resource-event-handler.service.ts b/src/autopilot/services/resource-event-handler.service.ts index 50aa3a0..f67808a 100644 --- a/src/autopilot/services/resource-event-handler.service.ts +++ b/src/autopilot/services/resource-event-handler.service.ts @@ -13,6 +13,7 @@ import { ITERATIVE_REVIEW_PHASE_NAME, PHASE_ROLE_MAP, REVIEW_PHASE_NAMES, + SCREENING_PHASE_NAMES, } from '../constants/review.constants'; import { First2FinishService } from './first2finish.service'; import { SchedulerService } from './scheduler.service'; @@ -92,10 +93,12 @@ export class ResourceEventHandler { await this.maybeOpenDeferredReviewPhases(challenge); + // Sync pending reviews for any open Review or Screening phases const openReviewPhases = challenge.phases?.filter( (phase) => phase.isOpen && - REVIEW_PHASE_NAMES.has(phase.name) && + (REVIEW_PHASE_NAMES.has(phase.name) || + SCREENING_PHASE_NAMES.has(phase.name)) && phase.name !== ITERATIVE_REVIEW_PHASE_NAME, ); @@ -159,8 +162,8 @@ export class ResourceEventHandler { return; } - const reviewPhases = challenge.phases?.filter((phase) => - REVIEW_PHASE_NAMES.has(phase.name), + const reviewPhases = challenge.phases?.filter( + (phase) => REVIEW_PHASE_NAMES.has(phase.name) || SCREENING_PHASE_NAMES.has(phase.name), ); if (reviewPhases?.length) { diff --git a/src/autopilot/services/scheduler.service.spec.ts b/src/autopilot/services/scheduler.service.spec.ts index 30539d1..a850749 100644 --- a/src/autopilot/services/scheduler.service.spec.ts +++ b/src/autopilot/services/scheduler.service.spec.ts @@ -11,6 +11,7 @@ import { PhaseReviewService } from './phase-review.service'; import { ChallengeCompletionService } from './challenge-completion.service'; import { ReviewService } from '../../review/review.service'; import { ResourcesService } from '../../resources/resources.service'; +import { PhaseChangeNotificationService } from './phase-change-notification.service'; import { ConfigService } from '@nestjs/config'; import type { IPhase } from '../../challenge/interfaces/challenge.interface'; import { @@ -34,6 +35,8 @@ type ChallengeApiServiceMock = { type ReviewServiceMock = { getPendingReviewCount: MockedMethod; + getPendingAppealCount: MockedMethod; + getTotalAppealCount: MockedMethod; }; type KafkaServiceMock = { @@ -82,6 +85,7 @@ describe('SchedulerService (review phase deferral)', () => { let challengeCompletionService: jest.Mocked; let reviewService: ReviewServiceMock; let resourcesService: jest.Mocked; + let phaseChangeNotificationService: jest.Mocked; let configService: jest.Mocked; beforeEach(() => { @@ -108,7 +112,13 @@ describe('SchedulerService (review phase deferral)', () => { reviewService = { getPendingReviewCount: createMockMethod(), + getPendingAppealCount: + createMockMethod(), + getTotalAppealCount: + createMockMethod(), }; + reviewService.getTotalAppealCount.mockResolvedValue(1); + reviewService.getPendingAppealCount.mockResolvedValue(0); resourcesService = { hasSubmitterResource: jest.fn().mockResolvedValue(true), @@ -116,6 +126,10 @@ describe('SchedulerService (review phase deferral)', () => { getReviewerResources: jest.fn().mockResolvedValue([]), } as unknown as jest.Mocked; + phaseChangeNotificationService = { + sendPhaseChangeNotification: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + configService = { get: jest.fn().mockReturnValue(undefined), } as unknown as jest.Mocked; @@ -127,6 +141,7 @@ describe('SchedulerService (review phase deferral)', () => { challengeCompletionService, reviewService as unknown as ReviewService, resourcesService, + phaseChangeNotificationService, configService, ); }); @@ -208,4 +223,206 @@ describe('SchedulerService (review phase deferral)', () => { 'close', ); }); + + it('defers closing appeals phases when pending appeal responses exist', async () => { + const payload = createPayload({ + phaseId: 'appeals-phase', + phaseTypeName: 'Appeals Response', + }); + const phaseDetails = createPhase({ + id: payload.phaseId, + phaseId: payload.phaseId, + name: 'Appeals Response', + isOpen: true, + }); + + challengeApiService.getPhaseDetails.mockResolvedValue(phaseDetails); + reviewService.getPendingAppealCount.mockResolvedValue(2); + + const scheduleSpy = jest + .spyOn(scheduler, 'schedulePhaseTransition') + .mockResolvedValue('appeals-rescheduled'); + + await scheduler.advancePhase(payload); + + expect(reviewService.getPendingAppealCount).toHaveBeenCalledWith( + payload.challengeId, + ); + expect(reviewService.getPendingReviewCount).not.toHaveBeenCalled(); + expect(challengeApiService.advancePhase).not.toHaveBeenCalled(); + expect(scheduleSpy).toHaveBeenCalledTimes(1); + }); + + it('closes appeals phase without pending appeal check', async () => { + const payload = createPayload({ + phaseId: 'appeals-phase', + phaseTypeName: 'Appeals', + }); + const phaseDetails = createPhase({ + id: payload.phaseId, + phaseId: payload.phaseId, + name: 'Appeals', + isOpen: true, + }); + + challengeApiService.getPhaseDetails.mockResolvedValue(phaseDetails); + reviewService.getPendingAppealCount.mockResolvedValue(0); + + const advancePhaseResponse: Awaited< + ReturnType + > = { + success: true, + message: 'closed appeals', + updatedPhases: [ + createPhase({ + id: payload.phaseId, + phaseId: payload.phaseId, + name: 'Appeals', + isOpen: false, + actualEndDate: new Date().toISOString(), + }), + ], + }; + + challengeApiService.advancePhase.mockResolvedValue(advancePhaseResponse); + + await scheduler.advancePhase(payload); + + expect(reviewService.getPendingAppealCount).not.toHaveBeenCalled(); + expect(challengeApiService.advancePhase).toHaveBeenCalledWith( + payload.challengeId, + payload.phaseId, + 'close', + ); + }); + + it('closes appeals response phase when all responses are in', async () => { + const payload = createPayload({ + phaseId: 'appeals-response-phase', + phaseTypeName: 'Appeals Response', + }); + const phaseDetails = createPhase({ + id: payload.phaseId, + phaseId: payload.phaseId, + name: 'Appeals Response', + isOpen: true, + }); + + challengeApiService.getPhaseDetails.mockResolvedValue(phaseDetails); + reviewService.getPendingAppealCount.mockResolvedValue(0); + + const advancePhaseResponse: Awaited< + ReturnType + > = { + success: true, + message: 'closed appeals response', + updatedPhases: [ + createPhase({ + id: payload.phaseId, + phaseId: payload.phaseId, + name: 'Appeals Response', + isOpen: false, + actualEndDate: new Date().toISOString(), + }), + ], + }; + + challengeApiService.advancePhase.mockResolvedValue(advancePhaseResponse); + + await scheduler.advancePhase(payload); + + expect(reviewService.getPendingAppealCount).toHaveBeenCalledWith( + payload.challengeId, + ); + expect(challengeApiService.advancePhase).toHaveBeenCalledWith( + payload.challengeId, + payload.phaseId, + 'close', + ); + }); + + it('auto-closes appeals response phase immediately when no appeals exist on open', async () => { + const payload = createPayload({ + state: 'START', + phaseId: 'appeals-response-phase', + phaseTypeName: 'Appeals Response', + }); + + const closedPhaseDetails = createPhase({ + id: payload.phaseId, + phaseId: payload.phaseId, + name: 'Appeals Response', + isOpen: false, + }); + const openPhaseDetails = createPhase({ + id: payload.phaseId, + phaseId: payload.phaseId, + name: 'Appeals Response', + isOpen: true, + }); + + challengeApiService.getPhaseDetails + .mockResolvedValueOnce(closedPhaseDetails) + .mockResolvedValueOnce(openPhaseDetails); + + reviewService.getTotalAppealCount.mockResolvedValue(0); + reviewService.getPendingAppealCount.mockResolvedValue(0); + + const openResponse: Awaited< + ReturnType + > = { + success: true, + message: 'opened appeals response', + updatedPhases: [ + createPhase({ + id: payload.phaseId, + phaseId: payload.phaseId, + name: 'Appeals Response', + isOpen: true, + actualStartDate: new Date().toISOString(), + }), + ], + }; + + const closeResponse: Awaited< + ReturnType + > = { + success: true, + message: 'closed appeals response', + updatedPhases: [ + createPhase({ + id: payload.phaseId, + phaseId: payload.phaseId, + name: 'Appeals Response', + isOpen: false, + actualEndDate: new Date().toISOString(), + }), + ], + }; + + challengeApiService.advancePhase + .mockResolvedValueOnce(openResponse) + .mockResolvedValueOnce(closeResponse); + + await scheduler.advancePhase(payload); + + expect(challengeApiService.advancePhase).toHaveBeenNthCalledWith( + 1, + payload.challengeId, + payload.phaseId, + 'open', + ); + expect(reviewService.getTotalAppealCount).toHaveBeenCalledWith( + payload.challengeId, + ); + expect(challengeApiService.advancePhase).toHaveBeenNthCalledWith( + 2, + payload.challengeId, + payload.phaseId, + 'close', + ); + expect(reviewService.getPendingAppealCount).toHaveBeenCalledWith( + payload.challengeId, + ); + }); }); diff --git a/src/autopilot/services/scheduler.service.ts b/src/autopilot/services/scheduler.service.ts index 72eca1e..55c897e 100644 --- a/src/autopilot/services/scheduler.service.ts +++ b/src/autopilot/services/scheduler.service.ts @@ -22,8 +22,11 @@ import { POST_MORTEM_PHASE_NAME, REGISTRATION_PHASE_NAME, REVIEW_PHASE_NAMES, + SCREENING_PHASE_NAMES, + APPROVAL_PHASE_NAMES, SUBMISSION_PHASE_NAME, TOPGEAR_SUBMISSION_PHASE_NAME, + getRoleNamesForPhase, } from '../constants/review.constants'; import { ResourcesService } from '../../resources/resources.service'; import { isTopgearTaskChallenge } from '../constants/challenge.constants'; @@ -31,6 +34,12 @@ import { IChallenge, IPhase, } from '../../challenge/interfaces/challenge.interface'; +import { PhaseChangeNotificationService } from './phase-change-notification.service'; +import { getNormalizedStringArray } from '../utils/config.utils'; +import { + getMemberReviewerConfigs, + getReviewerConfigsForPhase, +} from '../utils/reviewer.utils'; const PHASE_QUEUE_NAME = 'autopilot-phase-transitions'; const PHASE_QUEUE_PREFIX = '{autopilot-phase-transitions}'; @@ -38,6 +47,7 @@ const PHASE_QUEUE_PREFIX = '{autopilot-phase-transitions}'; @Injectable() export class SchedulerService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(SchedulerService.name); + private topgearPostMortemLocks = new Set(); private scheduledJobs = new Map(); private phaseChainCallback: | (( @@ -58,11 +68,25 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { private readonly registrationCloseRetryAttempts = new Map(); private readonly registrationCloseRetryBaseDelayMs = 5 * 60 * 1000; private readonly registrationCloseRetryMaxDelayMs = 30 * 60 * 1000; + private readonly appealsCloseRetryAttempts = new Map(); + private readonly appealsCloseRetryBaseDelayMs = 10 * 60 * 1000; + private readonly appealsCloseRetryMaxDelayMs = 60 * 60 * 1000; + private readonly appealsOpenRetryAttempts = new Map(); + private readonly appealsOpenRetryBaseDelayMs = 10 * 60 * 1000; + private readonly appealsOpenRetryMaxDelayMs = 60 * 60 * 1000; + private readonly screeningCloseRetryAttempts = new Map(); + private readonly screeningCloseRetryBaseDelayMs = 10 * 60 * 1000; + private readonly screeningCloseRetryMaxDelayMs = 60 * 60 * 1000; + private readonly approvalCloseRetryAttempts = new Map(); + private readonly approvalCloseRetryBaseDelayMs = 10 * 60 * 1000; + private readonly approvalCloseRetryMaxDelayMs = 60 * 60 * 1000; private readonly submitterRoles: string[]; private readonly postMortemRoles: string[]; private readonly postMortemScorecardId: string | null; private readonly postMortemDurationHours: number; private readonly topgearPostMortemScorecardId: string | null; + private readonly appealsPhaseNames: Set; + private readonly appealsResponsePhaseNames: Set; private redisConnection?: RedisOptions; private phaseQueue?: Queue; @@ -76,15 +100,17 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { private readonly challengeCompletionService: ChallengeCompletionService, private readonly reviewService: ReviewService, private readonly resourcesService: ResourcesService, + private readonly phaseChangeNotificationService: PhaseChangeNotificationService, private readonly configService: ConfigService, ) { - this.submitterRoles = this.getStringArray('autopilot.submitterRoles', [ - 'Submitter', - ]); - this.postMortemRoles = this.getStringArray('autopilot.postMortemRoles', [ - 'Reviewer', - 'Copilot', - ]); + this.submitterRoles = getNormalizedStringArray( + this.configService.get('autopilot.submitterRoles'), + ['Submitter'], + ); + this.postMortemRoles = getNormalizedStringArray( + this.configService.get('autopilot.postMortemRoles'), + ['Reviewer', 'Copilot'], + ); this.postMortemScorecardId = this.configService.get( 'autopilot.postMortemScorecardId', @@ -95,6 +121,18 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { ) ?? null; this.postMortemDurationHours = this.configService.get('autopilot.postMortemDurationHours') ?? 72; + this.appealsPhaseNames = new Set( + getNormalizedStringArray( + this.configService.get('autopilot.appealsPhaseNames'), + ['Appeals'], + ), + ); + this.appealsResponsePhaseNames = new Set( + getNormalizedStringArray( + this.configService.get('autopilot.appealsResponsePhaseNames'), + ['Appeals Response'], + ), + ); } private async ensureInitialized(): Promise { @@ -281,10 +319,19 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { return; } - await this.triggerKafkaEvent(phaseData); + // Normalize the payload to reflect a scheduler-initiated transition + const effectiveData: PhaseTransitionPayload = { + ...phaseData, + state: phaseData.state || 'END', + operator: AutopilotOperator.SYSTEM_SCHEDULER, + date: new Date().toISOString(), + }; + + await this.triggerKafkaEvent(effectiveData); // Call advancePhase method when phase transition is triggered - await this.advancePhase(phaseData); + // Use the normalized operator so scheduler-specific rules apply + await this.advancePhase(effectiveData); } catch (error) { this.logger.error( `Failed to trigger Kafka event for job ${jobId}`, @@ -410,18 +457,32 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { ); if (!phaseDetails) { - this.logger.error( - `Phase ${data.phaseId} not found in challenge ${data.challengeId}`, - ); - return; - } + this.logger.error( + `Phase ${data.phaseId} not found in challenge ${data.challengeId}`, + ); + return; + } - const phaseName = phaseDetails.name; - const isTopgearSubmissionPhase = - phaseName === TOPGEAR_SUBMISSION_PHASE_NAME; - const isSchedulerInitiated = this.isSchedulerInitiatedOperator( - data.operator, - ); + const phaseName = phaseDetails.name; + const isTopgearSubmissionPhase = + phaseName === TOPGEAR_SUBMISSION_PHASE_NAME; + const isSchedulerInitiated = this.isSchedulerInitiatedOperator( + data.operator, + ); + const isAppealsPhase = + this.isAppealsPhaseName(phaseName) || + this.isAppealsPhaseName(data.phaseTypeName); + const isAppealsResponsePhase = + this.isAppealsResponsePhaseName(phaseName) || + this.isAppealsResponsePhaseName(data.phaseTypeName); + const isAppealsRelatedPhase = + isAppealsPhase || isAppealsResponsePhase; + const isScreeningPhase = + SCREENING_PHASE_NAMES.has(phaseName) || + SCREENING_PHASE_NAMES.has(data.phaseTypeName); + const isApprovalPhase = + APPROVAL_PHASE_NAMES.has(phaseName) || + APPROVAL_PHASE_NAMES.has(data.phaseTypeName); // Determine operation based on transition state and current phase state let operation: 'open' | 'close'; @@ -454,7 +515,29 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { REVIEW_PHASE_NAMES.has(phaseName) || REVIEW_PHASE_NAMES.has(data.phaseTypeName); + // Registration close handling if (operation === 'close' && phaseName === REGISTRATION_PHASE_NAME) { + try { + // Only defer scheduler-initiated closes for Topgear tasks + if (isSchedulerInitiated) { + const regChallenge = await this.challengeApiService.getChallengeById( + data.challengeId, + ); + if (isTopgearTaskChallenge(regChallenge.type)) { + await this.deferTopgearRegistrationPhaseClosure(data); + return; + } + } + } catch (error) { + const err = error as Error; + this.logger.error( + `[REGISTRATION] Failed Topgear registration check for challenge ${data.challengeId}: ${err.message}`, + err.stack, + ); + // Fall through to legacy behavior below on error + } + // Do NOT defer closing Registration when there are no submitters. + // We will close Registration now and trigger the zero-registrations Post-Mortem workflow after close. try { const hasSubmitter = await this.resourcesService.hasSubmitterResource( data.challengeId, @@ -462,8 +545,9 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { ); if (!hasSubmitter) { - await this.deferRegistrationPhaseClosure(data); - return; + this.logger.log( + `[ZERO REGISTRATIONS] No registered submitters detected for challenge ${data.challengeId} at Registration close; will create Post-Mortem and cancel after completion.`, + ); } } catch (error) { const err = error as Error; @@ -471,13 +555,28 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { `[REGISTRATION] Failed to verify submitter resources for challenge ${data.challengeId}: ${err.message}`, err.stack, ); - await this.deferRegistrationPhaseClosure(data); - return; + // Proceed with close; post-close handler will attempt the zero-registrations flow. } } if (operation === 'close' && isReviewPhase) { try { + const coverage = await this.verifyReviewerCoverage( + data.challengeId, + data.phaseId, + phaseName, + true, + ); + + if (!coverage.satisfied) { + await this.deferReviewPhaseClosure( + data, + undefined, + `insufficient reviewer coverage (${coverage.actual}/${coverage.expected} assigned)`, + ); + return; + } + const pendingReviews = await this.reviewService.getPendingReviewCount( data.phaseId, data.challengeId, @@ -490,29 +589,206 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { } catch (error) { const err = error as Error; this.logger.error( - `[REVIEW LATE] Unable to verify pending reviews for phase ${data.phaseId} on challenge ${data.challengeId}: ${err.message}`, + `[REVIEW LATE] Unable to verify review readiness for phase ${data.phaseId} on challenge ${data.challengeId}: ${err.message}`, err.stack, ); - await this.deferReviewPhaseClosure(data); + await this.deferReviewPhaseClosure( + data, + undefined, + 'unable to verify review readiness', + ); return; } } - if ( - operation === 'close' && - isTopgearSubmissionPhase && - isSchedulerInitiated - ) { - const handled = await this.handleTopgearSubmissionLate( - data, - phaseDetails, - ); + // Block closing Screening until all screening scorecards are submitted + if (operation === 'close' && isScreeningPhase) { + try { + const coverage = await this.verifyReviewerCoverage( + data.challengeId, + data.phaseId, + phaseName, + false, + ); + + if (!coverage.satisfied) { + await this.deferScreeningPhaseClosure( + data, + undefined, + `insufficient screening coverage (${coverage.actual}/${coverage.expected} assigned)`, + ); + return; + } + + const pendingScreening = await this.reviewService.getPendingReviewCount( + data.phaseId, + data.challengeId, + ); + + if (pendingScreening > 0) { + await this.deferScreeningPhaseClosure(data, pendingScreening); + return; + } + } catch (error) { + const err = error as Error; + this.logger.error( + `[SCREENING LATE] Unable to verify screening readiness for phase ${data.phaseId} on challenge ${data.challengeId}: ${err.message}`, + err.stack, + ); + + await this.deferScreeningPhaseClosure( + data, + undefined, + 'unable to verify screening readiness', + ); + return; + } + } + + // Block closing Approval until all approval scorecards are submitted + if (operation === 'close' && isApprovalPhase) { + try { + const coverage = await this.verifyReviewerCoverage( + data.challengeId, + data.phaseId, + phaseName, + true, + ); + + if (!coverage.satisfied) { + await this.deferApprovalPhaseClosure( + data, + undefined, + `insufficient approval coverage (${coverage.actual}/${coverage.expected} assigned)`, + ); + return; + } + + const pendingApproval = await this.reviewService.getPendingReviewCount( + data.phaseId, + data.challengeId, + ); + + if (pendingApproval > 0) { + await this.deferApprovalPhaseClosure(data, pendingApproval); + return; + } + + // If there are zero pending reviews, ensure at least one approval review exists + const completedCount = + await this.reviewService.getCompletedReviewCountForPhase( + data.phaseId, + ); + if (completedCount === 0) { + await this.deferApprovalPhaseClosure( + data, + 0, + 'no completed approval reviews detected', + ); + return; + } + } catch (error) { + const err = error as Error; + this.logger.error( + `[APPROVAL LATE] Unable to verify approval readiness for phase ${data.phaseId} on challenge ${data.challengeId}: ${err.message}`, + err.stack, + ); + + await this.deferApprovalPhaseClosure( + data, + undefined, + 'unable to verify approval readiness', + ); + return; + } + } + + if (operation === 'close' && isTopgearSubmissionPhase && isSchedulerInitiated) { + const handled = await this.handleTopgearSubmissionLate(data, phaseDetails); if (handled) { return; } } + if (operation === 'close' && isAppealsResponsePhase) { + try { + const pendingAppeals = + await this.reviewService.getPendingAppealCount(data.challengeId); + + if (pendingAppeals > 0) { + await this.deferAppealsPhaseClosure(data, pendingAppeals); + return; + } + } catch (error) { + const err = error as Error; + this.logger.error( + `[APPEALS LATE] Unable to verify pending appeals for challenge ${data.challengeId}: ${err.message}`, + err.stack, + ); + + await this.deferAppealsPhaseClosure(data); + return; + } + } + + // Safety check: do not open Appeals if predecessor review has pending reviews + if (operation === 'open' && isAppealsPhase) { + try { + const challenge = + await this.challengeApiService.getChallengeById(data.challengeId); + + const phaseToOpen = challenge.phases?.find( + (p) => p.id === data.phaseId, + ); + + // Identify predecessor phase; if not present, try to find the last review phase + let predecessor: IPhase | undefined; + if (phaseToOpen?.predecessor) { + predecessor = challenge.phases?.find( + (p) => + p.phaseId === phaseToOpen.predecessor || + p.id === phaseToOpen.predecessor, + ); + } + + // Fallback: choose the most recent review phase by scheduledEndDate + if (!predecessor) { + const reviewPhases = (challenge.phases || []).filter((p) => + REVIEW_PHASE_NAMES.has(p.name), + ); + predecessor = reviewPhases + .filter((p) => Boolean(p.actualEndDate) || Boolean(p.isOpen)) + .sort( + (a, b) => + new Date(a.scheduledEndDate).getTime() - + new Date(b.scheduledEndDate).getTime(), + ) + .pop(); + } + + if (predecessor && REVIEW_PHASE_NAMES.has(predecessor.name)) { + const pendingReviews = await this.reviewService.getPendingReviewCount( + predecessor.id, + data.challengeId, + ); + + if (pendingReviews > 0) { + await this.deferAppealsPhaseOpen(data, pendingReviews); + return; + } + } + } catch (error) { + const err = error as Error; + this.logger.error( + `[APPEALS OPEN] Unable to verify predecessor reviews before opening appeals for challenge ${data.challengeId}, phase ${data.phaseId}: ${err.message}`, + err.stack, + ); + await this.deferAppealsPhaseOpen(data); + return; + } + } + this.logger.log( `Phase ${data.phaseId} is currently ${phaseDetails.isOpen ? 'open' : 'closed'}, will ${operation} it`, ); @@ -528,6 +804,20 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { `Successfully advanced phase ${data.phaseId} for challenge ${data.challengeId}: ${result.message}`, ); + try { + await this.phaseChangeNotificationService.sendPhaseChangeNotification({ + challengeId: data.challengeId, + phaseId: data.phaseId, + operation, + }); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to send phase change notification for challenge ${data.challengeId}, phase ${data.phaseId}: ${err.message}`, + err.stack, + ); + } + let skipPhaseChain = false; let skipFinalization = false; @@ -537,6 +827,23 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { ); } + if (operation === 'close' && isAppealsRelatedPhase) { + this.appealsCloseRetryAttempts.delete( + this.buildAppealsPhaseKey(data.challengeId, data.phaseId), + ); + } + + if (operation === 'close' && isScreeningPhase) { + this.screeningCloseRetryAttempts.delete( + this.buildScreeningPhaseKey(data.challengeId, data.phaseId), + ); + } + if (operation === 'close' && isApprovalPhase) { + this.approvalCloseRetryAttempts.delete( + this.buildApprovalPhaseKey(data.challengeId, data.phaseId), + ); + } + if (operation === 'close' && phaseName === REGISTRATION_PHASE_NAME) { this.registrationCloseRetryAttempts.delete( this.buildRegistrationPhaseKey(data.challengeId, data.phaseId), @@ -558,6 +865,35 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { } } + if (operation === 'open' && isAppealsResponsePhase && isSchedulerInitiated) { + try { + const totalAppeals = + await this.reviewService.getTotalAppealCount(data.challengeId); + + if (totalAppeals === 0) { + this.logger.log( + `[APPEALS RESPONSE] No appeals detected for challenge ${data.challengeId}; closing phase ${data.phaseId} immediately after open.`, + ); + + const closePayload: PhaseTransitionPayload = { + ...data, + state: 'END', + operator: AutopilotOperator.SYSTEM_SCHEDULER, + date: new Date().toISOString(), + }; + + await this.advancePhase(closePayload); + return; + } + } catch (error) { + const err = error as Error; + this.logger.error( + `[APPEALS RESPONSE] Unable to auto-close phase ${data.phaseId} for challenge ${data.challengeId}: ${err.message}`, + err.stack, + ); + } + } + if (operation === 'close') { if (phaseName === SUBMISSION_PHASE_NAME) { try { @@ -573,6 +909,20 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { err.stack, ); } + } else if (phaseName === REGISTRATION_PHASE_NAME) { + try { + const handled = await this.handleRegistrationPhaseClosed(data); + if (handled) { + skipPhaseChain = true; + skipFinalization = true; + } + } catch (error) { + const err = error as Error; + this.logger.error( + `[ZERO REGISTRATIONS] Unable to process post-registration workflow for challenge ${data.challengeId}: ${err.message}`, + err.stack, + ); + } } else if (phaseName === POST_MORTEM_PHASE_NAME) { try { await this.handlePostMortemPhaseClosed(data); @@ -580,7 +930,21 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { } catch (error) { const err = error as Error; this.logger.error( - `[ZERO SUBMISSIONS] Failed to cancel challenge ${data.challengeId} after post-mortem closure: ${err.message}`, + `Failed to cancel challenge ${data.challengeId} after post-mortem closure: ${err.message}`, + err.stack, + ); + } + } + + // For Topgear tasks, finalize immediately after Topgear Submission closes, + // even if Post-Mortem was opened and remains active. + if (phaseName === TOPGEAR_SUBMISSION_PHASE_NAME) { + try { + await this.attemptChallengeFinalization(data.challengeId); + } catch (error) { + const err = error as Error; + this.logger.error( + `[TOPGEAR] Failed to attempt finalization for challenge ${data.challengeId} after closing Topgear Submission phase ${data.phaseId}: ${err.message}`, err.stack, ); } @@ -750,9 +1114,11 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { } private computeFinalizationDelay(attempt: number): number { - const multiplier = Math.max(attempt, 1); - const delay = this.finalizationRetryBaseDelayMs * multiplier; - return Math.min(delay, this.finalizationRetryMaxDelayMs); + return this.computeBackoffDelay( + attempt, + this.finalizationRetryBaseDelayMs, + this.finalizationRetryMaxDelayMs, + ); } private clearFinalizationRetry(challengeId: string): void { @@ -763,29 +1129,15 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { this.finalizationRetryTimers.delete(challengeId); } - private getStringArray(path: string, fallback: string[]): string[] { - const value = this.configService.get(path); - - if (Array.isArray(value)) { - const normalized = value - .map((item) => (typeof item === 'string' ? item.trim() : String(item))) - .filter((item) => item.length > 0); - if (normalized.length) { - return normalized; - } - } - - if (typeof value === 'string' && value.length > 0) { - const normalized = value - .split(',') - .map((item) => item.trim()) - .filter((item) => item.length > 0); - if (normalized.length) { - return normalized; - } - } - - return fallback; + // Centralized linear backoff helper used by various deferral strategies + private computeBackoffDelay( + attempt: number, + baseDelayMs: number, + maxDelayMs: number, + ): number { + const multiplier = Math.max(attempt, 1); + const delay = baseDelayMs * multiplier; + return Math.min(delay, maxDelayMs); } private isSchedulerInitiatedOperator( @@ -796,10 +1148,11 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { } const candidate = operator.toString().toLowerCase(); - return ( - candidate === AutopilotOperator.SYSTEM_SCHEDULER || - candidate === AutopilotOperator.SYSTEM_PHASE_CHAIN - ); + // Treat all system-scheduled operators as scheduler-initiated. + // Includes: system-scheduler, system-phase-chain, system-new-challenge, + // system-sync, system-recovery (and other future system-* operators). + // Intentionally excludes bare 'system' to allow explicit closes (e.g., after passing review). + return candidate.startsWith('system-'); } private async handleTopgearSubmissionLate( @@ -815,17 +1168,9 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { } this.logger.log( - `[TOPGEAR] Keeping submission phase ${phase.id} open for challenge ${data.challengeId}; awaiting passing submission.`, - ); - - const submissionCount = await this.reviewService.getActiveSubmissionCount( - data.challengeId, + `[TOPGEAR] Keeping submission phase ${phase.id} open for challenge ${data.challengeId}; awaiting passing iterative review. No post-mortem will be created.`, ); - if (submissionCount === 0) { - await this.ensureTopgearPostMortemReview(challenge); - } - return true; } catch (error) { const err = error as Error; @@ -837,71 +1182,107 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { } } - private async ensureTopgearPostMortemReview( - challenge: IChallenge, + private async deferTopgearRegistrationPhaseClosure( + data: PhaseTransitionPayload, ): Promise { - if (!this.topgearPostMortemScorecardId) { + const key = this.buildRegistrationPhaseKey(data.challengeId, data.phaseId); + const attempt = (this.registrationCloseRetryAttempts.get(key) ?? 0) + 1; + this.registrationCloseRetryAttempts.set(key, attempt); + + const delay = this.computeRegistrationCloseRetryDelay(attempt); + const nextRun = new Date(Date.now() + delay).toISOString(); + + const payload: PhaseTransitionPayload = { + ...data, + date: nextRun, + operator: data.operator ?? AutopilotOperator.SYSTEM_SCHEDULER, + }; + + try { + await this.schedulePhaseTransition(payload); this.logger.warn( - `[TOPGEAR] topgearPostMortemScorecardId is not configured; unable to create creator review for challenge ${challenge.id}.`, + `[TOPGEAR][REGISTRATION] Deferred closing registration phase ${data.phaseId} for challenge ${data.challengeId}; awaiting passing iterative review. Retrying in ${Math.round( + delay / 60000, + )} minute(s).`, ); - return; + } catch (error) { + const err = error as Error; + this.registrationCloseRetryAttempts.delete(key); + this.logger.error( + `[TOPGEAR][REGISTRATION] Failed to reschedule registration closure for challenge ${data.challengeId}: ${err.message}`, + err.stack, + ); + throw err; } + } - const postMortemPhase = - challenge.phases?.find((phase) => phase.name === POST_MORTEM_PHASE_NAME) ?? - null; - - if (!postMortemPhase) { - this.logger.warn( - `[TOPGEAR] Post-Mortem phase not found on challenge ${challenge.id}; creator review cannot be created.`, + private async ensureTopgearPostMortemReview( + challenge: IChallenge, + submissionPhase: IPhase, + data: PhaseTransitionPayload, + ): Promise { + // Prevent concurrent duplicate creations for the same challenge within this process + this.topgearPostMortemLocks = this.topgearPostMortemLocks || new Set(); + if (this.topgearPostMortemLocks.has(challenge.id)) { + this.logger.debug?.( + `[TOPGEAR] Post-Mortem creation already in progress for challenge ${challenge.id}; skipping.`, ); return; } + this.topgearPostMortemLocks.add(challenge.id); + try { + // Determine scorecard to use: env var or fallback by name + let scorecardId = this.topgearPostMortemScorecardId; + if (!scorecardId) { + try { + scorecardId = await this.reviewService.getScorecardIdByName( + 'Topgear Task Post Mortem', + ); + } catch (err) { + // Already logged in review service + } + } - const creatorHandle = challenge.createdBy?.trim(); - if (!creatorHandle) { + if (!scorecardId) { this.logger.warn( - `[TOPGEAR] Challenge ${challenge.id} missing creator handle; post-mortem review not created.`, + `[TOPGEAR] Post-mortem scorecard is not configured or found by name; skipping creation for challenge ${challenge.id}.`, ); return; } - try { - const creatorResource = await this.resourcesService.getResourceByMemberHandle( - challenge.id, - creatorHandle, - ); + let postMortemPhase = + challenge.phases?.find((p) => p.name === POST_MORTEM_PHASE_NAME) ?? null; - if (!creatorResource) { - this.logger.warn( - `[TOPGEAR] Unable to locate resource for creator ${creatorHandle} on challenge ${challenge.id}; post-mortem review not created.`, + if (!postMortemPhase) { + try { + // Create a Post-Mortem phase chained after the submission phase, but keep it closed. + // We preserve future phases (e.g., Iterative Review) and let phase chain open Post-Mortem + // only after the submission phase is closed (which will happen after a successful IR). + postMortemPhase = + await this.challengeApiService.createPostMortemPhasePreserving( + challenge.id, + submissionPhase.id, + this.postMortemDurationHours, + false, + ); + this.logger.log( + `[TOPGEAR] Created Post-Mortem phase ${postMortemPhase.id} for challenge ${challenge.id} due to late submission.`, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `[TOPGEAR] Failed to create Post-Mortem phase for challenge ${challenge.id}: ${err.message}`, + err.stack, ); return; } + } - const created = await this.reviewService.createPendingReview( - null, - creatorResource.id, - postMortemPhase.id, - this.topgearPostMortemScorecardId, - challenge.id, - ); - - if (created) { - this.logger.log( - `[TOPGEAR] Created post-mortem review for challenge ${challenge.id} assigned to creator ${creatorHandle}.`, - ); - } else { - this.logger.debug?.( - `[TOPGEAR] Post-mortem review already exists for challenge ${challenge.id}, creator ${creatorHandle}.`, - ); - } - } catch (error) { - const err = error as Error; - this.logger.error( - `[TOPGEAR] Failed to create post-mortem review for challenge ${challenge.id}: ${err.message}`, - err.stack, - ); + // Do NOT pre-create pending reviews or schedule closure here. + // The phase chain will open Post-Mortem only after submission closes (after successful IR), + // and standard open-phase handling will create pending reviews and schedule closure. + } finally { + this.topgearPostMortemLocks.delete(challenge.id); } } @@ -909,11 +1290,13 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { data: PhaseTransitionPayload, ): Promise { try { - const submissionCount = await this.reviewService.getActiveSubmissionCount( - data.challengeId, - ); + // Only consider active contest submissions (include null type as contest) + const contestSubmissionIds = + await this.reviewService.getActiveContestSubmissionIds( + data.challengeId, + ); - if (submissionCount > 0) { + if (contestSubmissionIds.length > 0) { return false; } @@ -967,6 +1350,69 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { } } + private async handleRegistrationPhaseClosed( + data: PhaseTransitionPayload, + ): Promise { + try { + const hasSubmitter = await this.resourcesService.hasSubmitterResource( + data.challengeId, + this.submitterRoles, + ); + + if (hasSubmitter) { + // Nothing to do; proceed with normal chain. + return false; + } + + this.logger.log( + `[ZERO REGISTRATIONS] No registered submitters found for challenge ${data.challengeId}; transitioning to Post-Mortem phase.`, + ); + + const postMortemPhase = await this.challengeApiService.createPostMortemPhase( + data.challengeId, + data.phaseId, + this.postMortemDurationHours, + ); + + await this.createPostMortemPendingReviewsForCopilot( + data.challengeId, + postMortemPhase.id, + ); + + if (!postMortemPhase.scheduledEndDate) { + this.logger.warn( + `[ZERO REGISTRATIONS] Created Post-Mortem phase ${postMortemPhase.id} for challenge ${data.challengeId} without a scheduled end date. Manual intervention required to close the phase.`, + ); + return true; + } + + const payload: PhaseTransitionPayload = { + projectId: data.projectId, + challengeId: data.challengeId, + phaseId: postMortemPhase.id, + phaseTypeName: postMortemPhase.name, + state: 'END', + operator: AutopilotOperator.SYSTEM_PHASE_CHAIN, + projectStatus: data.projectStatus, + date: postMortemPhase.scheduledEndDate, + }; + + await this.schedulePhaseTransition(payload); + this.logger.log( + `[ZERO REGISTRATIONS] Scheduled Post-Mortem phase ${postMortemPhase.id} closure for challenge ${data.challengeId} at ${postMortemPhase.scheduledEndDate}.`, + ); + + return true; + } catch (error) { + const err = error as Error; + this.logger.error( + `[ZERO REGISTRATIONS] Failed to prepare Post-Mortem workflow for challenge ${data.challengeId}: ${err.message}`, + err.stack, + ); + throw err; + } + } + private async createPostMortemPendingReviews( challengeId: string, phaseId: string, @@ -1027,17 +1473,92 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { } } + private async createPostMortemPendingReviewsForCopilot( + challengeId: string, + phaseId: string, + ): Promise { + if (!this.postMortemScorecardId) { + this.logger.warn( + `[ZERO REGISTRATIONS] Post-mortem scorecard ID is not configured; skipping review creation for challenge ${challengeId}.`, + ); + return; + } + + try { + const copilots = await this.resourcesService.getResourcesByRoleNames( + challengeId, + ['Copilot'], + ); + + if (!copilots.length) { + this.logger.log( + `[ZERO REGISTRATIONS] No Copilot resource found on challenge ${challengeId}; skipping review creation.`, + ); + return; + } + + let createdCount = 0; + for (const resource of copilots) { + try { + const created = await this.reviewService.createPendingReview( + null, + resource.id, + phaseId, + this.postMortemScorecardId, + challengeId, + ); + + if (created) { + createdCount++; + } + } catch (error) { + const err = error as Error; + this.logger.error( + `[ZERO REGISTRATIONS] Failed to create post-mortem review for challenge ${challengeId}, resource ${resource.id}: ${err.message}`, + err.stack, + ); + } + } + + this.logger.log( + `[ZERO REGISTRATIONS] Created ${createdCount} post-mortem pending review(s) for challenge ${challengeId} (Copilot).`, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `[ZERO REGISTRATIONS] Unable to prepare Copilot post-mortem reviewers for challenge ${challengeId}: ${err.message}`, + err.stack, + ); + throw err; + } + } + private async handlePostMortemPhaseClosed( data: PhaseTransitionPayload, ): Promise { - await this.challengeApiService.cancelChallenge( - data.challengeId, - ChallengeStatusEnum.CANCELLED_ZERO_SUBMISSIONS, - ); + try { + const hasSubmitter = await this.resourcesService.hasSubmitterResource( + data.challengeId, + this.submitterRoles, + ); - this.logger.log( - `[ZERO SUBMISSIONS] Marked challenge ${data.challengeId} as CANCELLED_ZERO_SUBMISSIONS after Post-Mortem completion.`, - ); + const status = hasSubmitter + ? ChallengeStatusEnum.CANCELLED_ZERO_SUBMISSIONS + : ChallengeStatusEnum.CANCELLED_ZERO_REGISTRATIONS; + + await this.challengeApiService.cancelChallenge(data.challengeId, status); + + this.logger.log( + `${hasSubmitter ? '[ZERO SUBMISSIONS]' : '[ZERO REGISTRATIONS]'} Marked challenge ${data.challengeId} as ${status} after Post-Mortem completion.`, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to cancel challenge ${data.challengeId} after Post-Mortem completion: ${err.message}`, + err.stack, + ); + throw err; + } } private async deferRegistrationPhaseClosure( @@ -1073,9 +1594,11 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { } private computeRegistrationCloseRetryDelay(attempt: number): number { - const multiplier = Math.max(attempt, 1); - const delay = this.registrationCloseRetryBaseDelayMs * multiplier; - return Math.min(delay, this.registrationCloseRetryMaxDelayMs); + return this.computeBackoffDelay( + attempt, + this.registrationCloseRetryBaseDelayMs, + this.registrationCloseRetryMaxDelayMs, + ); } private buildRegistrationPhaseKey( @@ -1085,9 +1608,71 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { return `${challengeId}|${phaseId}|registration-close`; } + private async verifyReviewerCoverage( + challengeId: string, + phaseId: string, + phaseName: string, + useMemberConfigs: boolean, + ): Promise<{ satisfied: boolean; expected: number; actual: number }> { + try { + const challenge = await this.challengeApiService.getChallengeById( + challengeId, + ); + + const phase = challenge?.phases?.find((p) => p.id === phaseId); + + if (!phase?.phaseId) { + this.logger.warn( + `[REVIEW COVERAGE] Unable to locate phase ${phaseId} in challenge ${challengeId} when validating reviewer coverage.`, + ); + return { satisfied: true, expected: 0, actual: 0 }; + } + + const reviewerConfigs = useMemberConfigs + ? getMemberReviewerConfigs(challenge.reviewers, phase.phaseId) + : getReviewerConfigsForPhase(challenge.reviewers, phase.phaseId); + + const expected = reviewerConfigs.reduce((total, config) => { + const count = Math.max(config.memberReviewerCount ?? 1, 0); + return total + count; + }, 0); + + if (expected <= 0) { + return { satisfied: true, expected, actual: 0 }; + } + + const roleNames = + phaseName === POST_MORTEM_PHASE_NAME && this.postMortemRoles.length + ? this.postMortemRoles + : getRoleNamesForPhase(phaseName); + + const effectiveRoles = roleNames.length ? roleNames : ['Reviewer']; + + const reviewers = + await this.resourcesService.getReviewerResources( + challengeId, + effectiveRoles, + ); + + return { + satisfied: reviewers.length >= expected, + expected, + actual: reviewers.length, + }; + } catch (error) { + const err = error as Error; + this.logger.error( + `[REVIEW COVERAGE] Failed to verify reviewer coverage for challenge ${challengeId}, phase ${phaseId}: ${err.message}`, + err.stack, + ); + throw err; + } + } + private async deferReviewPhaseClosure( data: PhaseTransitionPayload, pendingCount?: number, + reason?: string, ): Promise { const key = this.buildReviewPhaseKey(data.challengeId, data.phaseId); const attempt = (this.reviewCloseRetryAttempts.get(key) ?? 0) + 1; @@ -1109,8 +1694,11 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { ? pendingCount : 'unknown'; + const reasonMessage = + reason ?? `${pendingDescription} incomplete review(s) detected`; + this.logger.warn( - `[REVIEW LATE] Deferred closing review phase ${data.phaseId} for challenge ${data.challengeId}; ${pendingDescription} incomplete review(s) detected. Retrying in ${Math.round(delay / 60000)} minute(s).`, + `[REVIEW LATE] Deferred closing review phase ${data.phaseId} for challenge ${data.challengeId}; ${reasonMessage}. Retrying in ${Math.round(delay / 60000)} minute(s).`, ); } catch (error) { const err = error as Error; @@ -1132,4 +1720,235 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { private buildReviewPhaseKey(challengeId: string, phaseId: string): string { return `${challengeId}|${phaseId}`; } + + private async deferScreeningPhaseClosure( + data: PhaseTransitionPayload, + pendingCount?: number, + reason?: string, + ): Promise { + const key = this.buildScreeningPhaseKey(data.challengeId, data.phaseId); + const attempt = (this.screeningCloseRetryAttempts.get(key) ?? 0) + 1; + this.screeningCloseRetryAttempts.set(key, attempt); + + const delay = this.computeScreeningCloseRetryDelay(attempt); + const nextRun = new Date(Date.now() + delay).toISOString(); + + const payload: PhaseTransitionPayload = { + ...data, + date: nextRun, + operator: data.operator ?? AutopilotOperator.SYSTEM_SCHEDULER, + }; + + try { + await this.schedulePhaseTransition(payload); + const pendingDescription = + typeof pendingCount === 'number' && pendingCount >= 0 + ? pendingCount + : 'unknown'; + + const reasonMessage = + reason ?? `${pendingDescription} incomplete screening review(s) detected`; + + this.logger.warn( + `[SCREENING LATE] Deferred closing screening phase ${data.phaseId} for challenge ${data.challengeId}; ${reasonMessage}. Retrying in ${Math.round(delay / 60000)} minute(s).`, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `[SCREENING LATE] Failed to reschedule close for screening phase ${data.phaseId} on challenge ${data.challengeId}: ${err.message}`, + err.stack, + ); + this.screeningCloseRetryAttempts.delete(key); + throw err; + } + } + + private computeScreeningCloseRetryDelay(attempt: number): number { + return this.computeBackoffDelay( + attempt, + this.screeningCloseRetryBaseDelayMs, + this.screeningCloseRetryMaxDelayMs, + ); + } + + private buildScreeningPhaseKey( + challengeId: string, + phaseId: string, + ): string { + return `${challengeId}|${phaseId}|screening-close`; + } + + private async deferApprovalPhaseClosure( + data: PhaseTransitionPayload, + pendingCount?: number, + reason?: string, + ): Promise { + const key = this.buildApprovalPhaseKey(data.challengeId, data.phaseId); + const attempt = (this.approvalCloseRetryAttempts.get(key) ?? 0) + 1; + this.approvalCloseRetryAttempts.set(key, attempt); + + const delay = this.computeApprovalCloseRetryDelay(attempt); + const nextRun = new Date(Date.now() + delay).toISOString(); + + const payload: PhaseTransitionPayload = { + ...data, + date: nextRun, + operator: data.operator ?? AutopilotOperator.SYSTEM_SCHEDULER, + }; + + try { + await this.schedulePhaseTransition(payload); + const pendingDescription = + typeof pendingCount === 'number' && pendingCount >= 0 + ? pendingCount + : 'unknown'; + + const reasonMessage = + reason ?? `${pendingDescription} incomplete approval review(s) detected`; + + this.logger.warn( + `[APPROVAL LATE] Deferred closing approval phase ${data.phaseId} for challenge ${data.challengeId}; ${reasonMessage}. Retrying in ${Math.round(delay / 60000)} minute(s).`, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `[APPROVAL LATE] Failed to reschedule close for approval phase ${data.phaseId} on challenge ${data.challengeId}: ${err.message}`, + err.stack, + ); + this.approvalCloseRetryAttempts.delete(key); + throw err; + } + } + + private computeApprovalCloseRetryDelay(attempt: number): number { + return this.computeBackoffDelay( + attempt, + this.approvalCloseRetryBaseDelayMs, + this.approvalCloseRetryMaxDelayMs, + ); + } + + private buildApprovalPhaseKey( + challengeId: string, + phaseId: string, + ): string { + return `${challengeId}|${phaseId}|approval-close`; + } + + private async deferAppealsPhaseClosure( + data: PhaseTransitionPayload, + pendingCount?: number, + ): Promise { + const key = this.buildAppealsPhaseKey(data.challengeId, data.phaseId); + const attempt = (this.appealsCloseRetryAttempts.get(key) ?? 0) + 1; + this.appealsCloseRetryAttempts.set(key, attempt); + + const delay = this.computeAppealsCloseRetryDelay(attempt); + const nextRun = new Date(Date.now() + delay).toISOString(); + + const payload: PhaseTransitionPayload = { + ...data, + date: nextRun, + operator: data.operator ?? AutopilotOperator.SYSTEM_SCHEDULER, + }; + + try { + await this.schedulePhaseTransition(payload); + const pendingDescription = + typeof pendingCount === 'number' && pendingCount >= 0 + ? pendingCount + : 'unknown'; + + this.logger.warn( + `[APPEALS LATE] Deferred closing appeals phase ${data.phaseId} for challenge ${data.challengeId}; ${pendingDescription} pending appeal response(s). Retrying in ${Math.round(delay / 60000)} minute(s).`, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `[APPEALS LATE] Failed to reschedule close for appeals phase ${data.phaseId} on challenge ${data.challengeId}: ${err.message}`, + err.stack, + ); + this.appealsCloseRetryAttempts.delete(key); + throw err; + } + } + + private computeAppealsCloseRetryDelay(attempt: number): number { + return this.computeBackoffDelay( + attempt, + this.appealsCloseRetryBaseDelayMs, + this.appealsCloseRetryMaxDelayMs, + ); + } + + private buildAppealsPhaseKey(challengeId: string, phaseId: string): string { + return `${challengeId}|${phaseId}|appeals-close`; + } + + private isAppealsPhaseName(phaseName?: string | null): boolean { + const normalized = phaseName?.trim(); + if (!normalized) { + return false; + } + + return this.appealsPhaseNames.has(normalized); + } + + private isAppealsResponsePhaseName( + phaseName?: string | null, + ): boolean { + const normalized = phaseName?.trim(); + if (!normalized) { + return false; + } + + return this.appealsResponsePhaseNames.has(normalized); + } + + private async deferAppealsPhaseOpen( + data: PhaseTransitionPayload, + pendingCount?: number, + ): Promise { + const key = `${data.challengeId}|${data.phaseId}|appeals-open`; + const attempt = (this.appealsOpenRetryAttempts.get(key) ?? 0) + 1; + this.appealsOpenRetryAttempts.set(key, attempt); + + const delay = this.computeAppealsOpenRetryDelay(attempt); + const nextRun = new Date(Date.now() + delay).toISOString(); + + const payload: PhaseTransitionPayload = { + ...data, + state: 'START', + date: nextRun, + operator: data.operator ?? AutopilotOperator.SYSTEM_SCHEDULER, + }; + + try { + await this.schedulePhaseTransition(payload); + const pendingDescription = + typeof pendingCount === 'number' && pendingCount >= 0 + ? pendingCount + : 'unknown'; + + this.logger.warn( + `[APPEALS OPEN] Deferred opening appeals phase ${data.phaseId} for challenge ${data.challengeId}; ${pendingDescription} pending review(s) in predecessor. Retrying in ${Math.round(delay / 60000)} minute(s).`, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `[APPEALS OPEN] Failed to reschedule open for appeals phase ${data.phaseId} on challenge ${data.challengeId}: ${err.message}`, + err.stack, + ); + this.appealsOpenRetryAttempts.delete(key); + throw err; + } + } + + private computeAppealsOpenRetryDelay(attempt: number): number { + return this.computeBackoffDelay( + attempt, + this.appealsOpenRetryBaseDelayMs, + this.appealsOpenRetryMaxDelayMs, + ); + } } diff --git a/src/autopilot/utils/reviewer.utils.ts b/src/autopilot/utils/reviewer.utils.ts index e0ab2e4..8f87c86 100644 --- a/src/autopilot/utils/reviewer.utils.ts +++ b/src/autopilot/utils/reviewer.utils.ts @@ -10,7 +10,26 @@ export function getMemberReviewerConfigs( return reviewers.filter( (reviewer) => - reviewer.isMemberReview && reviewer.phaseId === phaseTemplateId, + reviewer.isMemberReview && + reviewer.phaseId === phaseTemplateId && + reviewer.shouldOpenOpportunity !== false, + ); +} + +// For screening phases (including Checkpoint Screening), reviewer configs may not be flagged as member reviews. +// This helper returns all reviewer configs for the given phase template regardless of isMemberReview. +export function getReviewerConfigsForPhase( + reviewers: IChallengeReviewer[] | undefined, + phaseTemplateId: string, +): IChallengeReviewer[] { + if (!reviewers?.length) { + return []; + } + + return reviewers.filter( + (reviewer) => + reviewer.phaseId === phaseTemplateId && + reviewer.shouldOpenOpportunity !== false, ); } @@ -24,10 +43,12 @@ export function getRequiredReviewerCountForPhase( return 0; } - return configs.reduce((total, config) => { - const count = config.memberReviewerCount ?? 1; - return total + Math.max(count, 0); - }, 0); + return configs + .filter((c) => c.shouldOpenOpportunity !== false) + .reduce((total, config) => { + const count = config.memberReviewerCount ?? 1; + return total + Math.max(count, 0); + }, 0); } export function selectScorecardId( diff --git a/src/challenge/challenge-api.service.spec.ts b/src/challenge/challenge-api.service.spec.ts index 9de76ce..c08410a 100644 --- a/src/challenge/challenge-api.service.spec.ts +++ b/src/challenge/challenge-api.service.spec.ts @@ -2,6 +2,7 @@ import { ChallengeApiService } from './challenge-api.service'; import type { ChallengePrismaService } from './challenge-prisma.service'; import type { AutopilotDbLoggerService } from '../autopilot/services/autopilot-db-logger.service'; import { ChallengeStatusEnum } from '@prisma/client'; +import type { ConfigService } from '@nestjs/config'; describe('ChallengeApiService - advancePhase scheduling', () => { const fixedNow = new Date('2025-09-27T06:00:00.000Z'); @@ -12,17 +13,28 @@ describe('ChallengeApiService - advancePhase scheduling', () => { let prisma: jest.Mocked; let dbLogger: jest.Mocked; let challengePhaseUpdate: jest.Mock; + let challengePhaseCreate: jest.Mock; + let challengePhaseDeleteMany: jest.Mock; let challengeUpdate: jest.Mock; let challengeFindUnique: jest.Mock; + let txChallengeFindUnique: jest.Mock; + let txPhaseFindUnique: jest.Mock; + let txQueryRaw: jest.Mock; let service: ChallengeApiService; + let configService: jest.Mocked; beforeEach(() => { jest.useFakeTimers().setSystemTime(fixedNow); challengePhaseUpdate = jest.fn().mockResolvedValue(undefined); + challengePhaseCreate = jest.fn().mockResolvedValue({ id: 'new-phase' }); + challengePhaseDeleteMany = jest.fn().mockResolvedValue({ count: 0 }); challengeUpdate = jest.fn().mockResolvedValue(undefined); challengeFindUnique = jest.fn(); + txChallengeFindUnique = jest.fn(); + txPhaseFindUnique = jest.fn(); + txQueryRaw = jest.fn().mockResolvedValue(undefined); prisma = { challenge: { @@ -30,14 +42,27 @@ describe('ChallengeApiService - advancePhase scheduling', () => { }, challengePhase: { update: challengePhaseUpdate, + create: challengePhaseCreate, + deleteMany: challengePhaseDeleteMany, }, $transaction: jest.fn(), } as unknown as jest.Mocked; prisma.$transaction.mockImplementation(async (cb) => { - await cb({ - challengePhase: { update: challengePhaseUpdate }, - challenge: { update: challengeUpdate }, + return cb({ + $queryRaw: txQueryRaw, + challenge: { + findUnique: txChallengeFindUnique, + update: challengeUpdate, + }, + phase: { + findUnique: txPhaseFindUnique, + }, + challengePhase: { + update: challengePhaseUpdate, + create: challengePhaseCreate, + deleteMany: challengePhaseDeleteMany, + }, } as unknown as ChallengePrismaService); }); @@ -45,7 +70,11 @@ describe('ChallengeApiService - advancePhase scheduling', () => { logAction: jest.fn(), } as unknown as jest.Mocked; - service = new ChallengeApiService(prisma, dbLogger); + configService = { + get: jest.fn().mockReturnValue(undefined), + } as unknown as jest.Mocked; + + service = new ChallengeApiService(prisma, dbLogger, configService); }); afterEach(() => { @@ -113,7 +142,7 @@ describe('ChallengeApiService - advancePhase scheduling', () => { status: ChallengeStatusEnum.ACTIVE, createdBy: 'tester', updatedBy: 'tester', - metadata: [], + metadata: {}, phases: [reviewPhase, appealsPhase], reviewers: [], winners: [], @@ -149,6 +178,382 @@ describe('ChallengeApiService - advancePhase scheduling', () => { }), }); }); + + it('extends the appeals phase duration when opening late', async () => { + const lateNow = new Date('2025-09-27T09:30:00.000Z'); + jest.setSystemTime(lateNow); + + const appealsPhase = { + id: 'appeals-phase', + phaseId: 'template-appeals', + name: 'Appeals', + description: null, + isOpen: false, + predecessor: 'review-template', + duration: 3600, + scheduledStartDate: new Date('2025-09-27T08:30:00.000Z'), + scheduledEndDate: new Date('2025-09-27T09:30:00.000Z'), + actualStartDate: null, + actualEndDate: null, + constraints: [], + createdAt: lateNow, + createdBy: 'tester', + updatedAt: lateNow, + updatedBy: 'tester', + }; + + const challengeRecord = { + id: 'challenge-appeals', + name: 'Appeals Challenge', + description: null, + descriptionFormat: 'markdown', + projectId: 456, + typeId: 'type-appeals', + trackId: 'track-appeals', + timelineTemplateId: 'timeline-appeals', + currentPhaseNames: [], + tags: [], + groups: [], + submissionStartDate: lateNow, + submissionEndDate: lateNow, + registrationStartDate: lateNow, + registrationEndDate: lateNow, + startDate: lateNow, + endDate: null, + legacyId: null, + status: ChallengeStatusEnum.ACTIVE, + createdBy: 'tester', + updatedBy: 'tester', + metadata: {}, + phases: [appealsPhase], + reviewers: [], + winners: [], + track: { name: 'DEVELOP' }, + type: { name: 'Standard' }, + legacyRecord: null, + discussions: [], + events: [], + prizeSets: [], + terms: [], + skills: [], + attachments: [], + overview: {}, + numOfSubmissions: 0, + numOfCheckpointSubmissions: 0, + numOfRegistrants: 0, + createdAt: lateNow, + }; + + challengeFindUnique + .mockResolvedValueOnce(challengeRecord as any) + .mockResolvedValueOnce(challengeRecord as any); + + await service.advancePhase('challenge-appeals', 'appeals-phase', 'open'); + + expect(challengePhaseUpdate).toHaveBeenCalledWith({ + where: { id: appealsPhase.id }, + data: expect.objectContaining({ + scheduledStartDate: lateNow, + scheduledEndDate: new Date(lateNow.getTime() + 3600 * 1000), + duration: appealsPhase.duration, + }), + }); + }); + + it('extends a non-appeals phase opened late when remaining time is shorter than the configured duration', async () => { + const lateNow = new Date('2025-09-27T10:00:00.000Z'); + jest.setSystemTime(lateNow); + + const submissionPhase = { + id: 'phase-submission', + phaseId: 'template-submission', + name: 'Submission', + description: null, + isOpen: false, + predecessor: null, + duration: 7200, + scheduledStartDate: new Date('2025-09-27T06:00:00.000Z'), + scheduledEndDate: new Date('2025-09-27T10:05:00.000Z'), + actualStartDate: null, + actualEndDate: null, + constraints: [], + createdAt: lateNow, + createdBy: 'tester', + updatedAt: lateNow, + updatedBy: 'tester', + }; + + const challengeRecord = { + id: 'challenge-late-phase', + name: 'Late Phase Challenge', + description: null, + descriptionFormat: 'markdown', + projectId: 789, + typeId: 'type-late', + trackId: 'track-late', + timelineTemplateId: 'timeline-late', + currentPhaseNames: [], + tags: [], + groups: [], + submissionStartDate: lateNow, + submissionEndDate: lateNow, + registrationStartDate: lateNow, + registrationEndDate: lateNow, + startDate: lateNow, + endDate: null, + legacyId: null, + status: ChallengeStatusEnum.ACTIVE, + createdBy: 'tester', + updatedBy: 'tester', + metadata: {}, + phases: [submissionPhase], + reviewers: [], + winners: [], + track: { name: 'DEVELOP' }, + type: { name: 'Standard' }, + legacyRecord: null, + discussions: [], + events: [], + prizeSets: [], + terms: [], + skills: [], + attachments: [], + overview: {}, + numOfSubmissions: 0, + numOfCheckpointSubmissions: 0, + numOfRegistrants: 0, + createdAt: lateNow, + }; + + challengeFindUnique + .mockResolvedValueOnce(challengeRecord as any) + .mockResolvedValueOnce(challengeRecord as any); + + await service.advancePhase('challenge-late-phase', submissionPhase.id, 'open'); + + expect(challengePhaseUpdate).toHaveBeenCalledWith({ + where: { id: submissionPhase.id }, + data: expect.objectContaining({ + scheduledStartDate: lateNow, + scheduledEndDate: new Date(lateNow.getTime() + submissionPhase.duration * 1000), + duration: submissionPhase.duration, + }), + }); + }); + + describe('createPostMortemPhase', () => { + const buildPhase = (overrides: Partial = {}) => { + const scheduledStart = new Date('2025-09-26T00:00:00.000Z'); + return { + id: 'phase-default', + phaseId: 'template-default', + name: 'Generic Phase', + description: null, + isOpen: false, + predecessor: null, + duration: 3600, + scheduledStartDate: scheduledStart, + scheduledEndDate: new Date(scheduledStart.getTime() + 3600 * 1000), + actualStartDate: null, + actualEndDate: null, + constraints: [], + ...overrides, + }; + }; + + it('reuses an existing Post-Mortem phase when one already exists after submission', async () => { + const challengeId = 'challenge-reuse'; + const registrationPhase = buildPhase({ + id: 'phase-registration', + phaseId: 'template-registration', + name: 'Registration', + }); + const submissionPhase = buildPhase({ + id: 'phase-submission', + phaseId: 'template-submission', + name: 'Submission', + isOpen: true, + }); + const reviewPhase = buildPhase({ + id: 'phase-review', + phaseId: 'template-review', + name: 'Review', + }); + const existingPostMortem = buildPhase({ + id: 'phase-postmortem', + phaseId: 'template-postmortem', + name: 'Post-Mortem', + scheduledStartDate: new Date('2025-09-28T00:00:00.000Z'), + scheduledEndDate: new Date('2025-09-30T00:00:00.000Z'), + actualStartDate: null, + actualEndDate: new Date('2025-09-29T00:00:00.000Z'), + isOpen: false, + }); + + txChallengeFindUnique.mockResolvedValueOnce({ + id: challengeId, + phases: [ + registrationPhase, + submissionPhase, + reviewPhase, + existingPostMortem, + ], + currentPhaseNames: [], + } as any); + + challengeFindUnique.mockResolvedValueOnce({ + id: challengeId, + phases: [ + registrationPhase, + submissionPhase, + { + ...existingPostMortem, + scheduledStartDate: fixedNow, + scheduledEndDate: new Date(fixedNow.getTime() + 72 * 60 * 60 * 1000), + actualStartDate: fixedNow, + actualEndDate: null, + isOpen: true, + }, + ], + } as any); + + const result = await service.createPostMortemPhase( + challengeId, + submissionPhase.id, + 72, + ); + + expect(txQueryRaw).toHaveBeenCalledTimes(1); + expect(challengePhaseDeleteMany).toHaveBeenCalledWith({ + where: { id: { in: [reviewPhase.id] } }, + }); + expect(challengePhaseUpdate).toHaveBeenCalledWith({ + where: { id: existingPostMortem.id }, + data: expect.objectContaining({ + predecessor: submissionPhase.phaseId, + isOpen: true, + actualEndDate: null, + }), + }); + const reopenArgs = challengePhaseUpdate.mock.calls[0][0].data; + expect(reopenArgs.actualStartDate).toBeInstanceOf(Date); + expect(reopenArgs.actualStartDate?.toISOString()).toBe( + fixedNow.toISOString(), + ); + expect(reopenArgs.duration).toBe(72 * 60 * 60); + expect(challengePhaseCreate).not.toHaveBeenCalled(); + expect(txPhaseFindUnique).not.toHaveBeenCalled(); + expect(challengeUpdate).toHaveBeenCalledWith({ + where: { id: challengeId }, + data: { currentPhaseNames: [existingPostMortem.name] }, + }); + expect(result.id).toBe(existingPostMortem.id); + expect(dbLogger.logAction).toHaveBeenLastCalledWith( + 'challenge.createPostMortemPhase', + expect.objectContaining({ + status: 'SUCCESS', + details: expect.objectContaining({ + reusedExisting: true, + postMortemPhaseId: existingPostMortem.id, + }), + }), + ); + }); + + it('creates a new Post-Mortem phase when none exists after submission', async () => { + const challengeId = 'challenge-new'; + const submissionPhase = buildPhase({ + id: 'phase-submission', + phaseId: 'template-submission', + name: 'Submission', + }); + const iterativeReviewPhase = buildPhase({ + id: 'phase-iterative', + phaseId: 'template-iterative', + name: 'Iterative Review', + }); + + const postMortemPhaseType = { + id: 'template-postmortem', + name: 'Post-Mortem', + description: 'Post-Mortem phase', + }; + + txChallengeFindUnique.mockResolvedValueOnce({ + id: challengeId, + phases: [submissionPhase, iterativeReviewPhase], + currentPhaseNames: [], + } as any); + txPhaseFindUnique.mockResolvedValueOnce(postMortemPhaseType as any); + + const createdPostMortem = { + id: 'phase-postmortem', + }; + challengePhaseCreate.mockResolvedValueOnce(createdPostMortem as any); + + challengeFindUnique.mockResolvedValueOnce({ + id: challengeId, + phases: [ + submissionPhase, + { + ...createdPostMortem, + phaseId: postMortemPhaseType.id, + name: postMortemPhaseType.name, + description: postMortemPhaseType.description, + isOpen: true, + duration: 72 * 60 * 60, + scheduledStartDate: fixedNow, + scheduledEndDate: new Date(fixedNow.getTime() + 72 * 60 * 60 * 1000), + actualStartDate: fixedNow, + actualEndDate: null, + predecessor: submissionPhase.phaseId, + constraints: [], + }, + ], + } as any); + + const result = await service.createPostMortemPhase( + challengeId, + submissionPhase.id, + 72, + ); + + expect(challengePhaseDeleteMany).toHaveBeenCalledWith({ + where: { id: { in: [iterativeReviewPhase.id] } }, + }); + expect(txPhaseFindUnique).toHaveBeenCalledWith({ + where: { name: 'Post-Mortem' }, + }); + expect(challengePhaseCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + challengeId, + phaseId: postMortemPhaseType.id, + name: postMortemPhaseType.name, + predecessor: submissionPhase.phaseId, + }), + }); + const createArgs = challengePhaseCreate.mock.calls[0][0].data; + expect(createArgs.scheduledStartDate).toBeInstanceOf(Date); + expect(createArgs.isOpen).toBe(true); + expect(createArgs.duration).toBe(72 * 60 * 60); + expect(challengePhaseUpdate).not.toHaveBeenCalled(); + expect(challengeUpdate).toHaveBeenCalledWith({ + where: { id: challengeId }, + data: { currentPhaseNames: [postMortemPhaseType.name] }, + }); + expect(result.id).toBe(createdPostMortem.id); + expect(dbLogger.logAction).toHaveBeenLastCalledWith( + 'challenge.createPostMortemPhase', + expect.objectContaining({ + status: 'SUCCESS', + details: expect.objectContaining({ + reusedExisting: false, + postMortemPhaseId: createdPostMortem.id, + }), + }), + ); + }); + }); }); describe('ChallengeApiService - end date handling', () => { @@ -160,6 +565,7 @@ describe('ChallengeApiService - end date handling', () => { let challengeUpdate: jest.Mock; let challengeWinnerDeleteMany: jest.Mock; let challengeWinnerCreateMany: jest.Mock; + let configService: jest.Mocked; beforeEach(() => { jest.useFakeTimers().setSystemTime(fixedNow); @@ -193,7 +599,11 @@ describe('ChallengeApiService - end date handling', () => { logAction: jest.fn(), } as unknown as jest.Mocked; - service = new ChallengeApiService(prisma, dbLogger); + configService = { + get: jest.fn().mockReturnValue(undefined), + } as unknown as jest.Mocked; + + service = new ChallengeApiService(prisma, dbLogger, configService); }); afterEach(() => { diff --git a/src/challenge/challenge-api.service.ts b/src/challenge/challenge-api.service.ts index ec37187..2a20962 100644 --- a/src/challenge/challenge-api.service.ts +++ b/src/challenge/challenge-api.service.ts @@ -1,12 +1,18 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Prisma, ChallengeStatusEnum, PrizeSetTypeEnum } from '@prisma/client'; +import { ConfigService } from '@nestjs/config'; import { ChallengePrismaService } from './challenge-prisma.service'; import { AutopilotDbLoggerService } from '../autopilot/services/autopilot-db-logger.service'; import { IPhase, IChallenge, IChallengeWinner, + IChallengePrizeSet, } from './interfaces/challenge.interface'; +import { + DEFAULT_APPEALS_PHASE_NAMES, + DEFAULT_APPEALS_RESPONSE_PHASE_NAMES, +} from '../autopilot/constants/review.constants'; // DTO for filtering challenges interface ChallengeFiltersDto { @@ -35,7 +41,11 @@ const challengeWithRelationsArgs = include: { constraints: true }, orderBy: { scheduledStartDate: 'asc' as const }, }, + metadata: true, winners: true, + prizeSets: { + include: { prizes: true }, + }, track: true, type: true, legacyRecord: true, @@ -48,16 +58,29 @@ type ChallengeWithRelations = Prisma.ChallengeGetPayload< >; type ChallengePhaseWithConstraints = ChallengeWithRelations['phases'][number]; +type ChallengePrizeSetWithPrizes = ChallengeWithRelations['prizeSets'][number]; @Injectable() export class ChallengeApiService { private readonly logger = new Logger(ChallengeApiService.name); private readonly defaultPageSize = 50; + private readonly appealsPhaseNames: Set; + private readonly appealsResponsePhaseNames: Set; constructor( private readonly prisma: ChallengePrismaService, private readonly dbLogger: AutopilotDbLoggerService, - ) {} + private readonly configService: ConfigService, + ) { + this.appealsPhaseNames = this.buildPhaseNameSet( + this.configService.get('autopilot.appealsPhaseNames'), + DEFAULT_APPEALS_PHASE_NAMES, + ); + this.appealsResponsePhaseNames = this.buildPhaseNameSet( + this.configService.get('autopilot.appealsResponsePhaseNames'), + DEFAULT_APPEALS_RESPONSE_PHASE_NAMES, + ); + } async getAllActiveChallenges( filters: ChallengeFiltersDto = {}, @@ -339,15 +362,82 @@ export class ChallengeApiService { const scheduledStartDate = targetPhase.scheduledStartDate ? new Date(targetPhase.scheduledStartDate) : null; + const scheduledEndDate = targetPhase.scheduledEndDate + ? new Date(targetPhase.scheduledEndDate) + : null; const durationSeconds = this.computePhaseDurationSeconds(targetPhase); - const shouldAdjustSchedule = + const isAppealsPhase = this.isAppealsPhaseName(targetPhase.name); + const isOpeningLateAppeals = operation === 'open' && + isAppealsPhase && + durationSeconds !== null && scheduledStartDate !== null && + now.getTime() - scheduledStartDate.getTime() > 1000; + const isOpeningEarly = + operation === 'open' && durationSeconds !== null && + scheduledStartDate !== null && scheduledStartDate.getTime() - now.getTime() > 1000; - const adjustedEndDate = shouldAdjustSchedule - ? new Date(now.getTime() + durationSeconds * 1000) - : null; + const isOpeningAfterScheduledEnd = + operation === 'open' && + durationSeconds !== null && + scheduledEndDate !== null && + now.getTime() - scheduledEndDate.getTime() > 1000; + + const minimumEndTime = + durationSeconds !== null + ? now.getTime() + durationSeconds * 1000 + : null; + const openedLate = + operation === 'open' && + scheduledStartDate !== null && + now.getTime() - scheduledStartDate.getTime() > 1000; + const hasInsufficientRemainingDuration = + openedLate && + minimumEndTime !== null && + (scheduledEndDate === null || + scheduledEndDate.getTime() < minimumEndTime); + + let shouldAdjustSchedule = false; + let adjustedEndDate: Date | null = null; + + if ( + minimumEndTime !== null && + (isOpeningEarly || isOpeningLateAppeals || isOpeningAfterScheduledEnd) + ) { + shouldAdjustSchedule = true; + adjustedEndDate = new Date(minimumEndTime); + } + + if (minimumEndTime !== null && hasInsufficientRemainingDuration) { + shouldAdjustSchedule = true; + if (!adjustedEndDate || adjustedEndDate.getTime() < minimumEndTime) { + adjustedEndDate = new Date(minimumEndTime); + } + } + + if (isOpeningLateAppeals && adjustedEndDate) { + this.logger.log( + `Extending appeals phase ${targetPhase.id} to preserve duration. New end: ${adjustedEndDate.toISOString()}.`, + ); + } + + if (isOpeningAfterScheduledEnd && adjustedEndDate && !isAppealsPhase) { + this.logger.log( + `Extending phase ${targetPhase.id} (${targetPhase.name}) opened after its scheduled end. New end: ${adjustedEndDate.toISOString()}.`, + ); + } + + if ( + hasInsufficientRemainingDuration && + adjustedEndDate && + !isOpeningLateAppeals && + !isOpeningAfterScheduledEnd + ) { + this.logger.log( + `Extending phase ${targetPhase.id} (${targetPhase.name}) opened late to preserve configured duration (${durationSeconds}s). New end: ${adjustedEndDate.toISOString()}.`, + ); + } try { await this.prisma.$transaction(async (tx) => { @@ -513,7 +603,73 @@ export class ChallengeApiService { return null; } + private buildPhaseNameSet( + source: unknown, + fallback: Set, + ): Set { + const resolved = this.normalizeStringArray(source, Array.from(fallback)); + return new Set( + resolved + .map((value) => value.trim()) + .filter((value) => value.length > 0), + ); + } + + private normalizeStringArray( + source: unknown, + fallback: string[], + ): string[] { + if (Array.isArray(source)) { + const normalized = source + .map((item) => + typeof item === 'string' ? item.trim() : String(item ?? '').trim(), + ) + .filter((item) => item.length > 0); + + if (normalized.length > 0) { + return normalized; + } + } + + if (typeof source === 'string' && source.length > 0) { + const normalized = source + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); + + if (normalized.length > 0) { + return normalized; + } + } + + return fallback; + } + + private isAppealsPhaseName(phaseName?: string | null): boolean { + const normalized = phaseName?.trim(); + if (!normalized) { + return false; + } + + return ( + this.appealsPhaseNames.has(normalized) || + this.appealsResponsePhaseNames.has(normalized) + ); + } + private mapChallenge(challenge: ChallengeWithRelations): IChallenge { + const metadata = (challenge.metadata ?? []).reduce< + Record + >((acc, entry) => { + const key = entry?.name?.trim(); + if (!key) { + return acc; + } + + acc[key] = entry.value ?? ''; + return acc; + }, {}); + return { id: challenge.id, name: challenge.name, @@ -538,7 +694,7 @@ export class ChallengeApiService { status: challenge.status, createdBy: challenge.createdBy, updatedBy: challenge.updatedBy, - metadata: [], + metadata, phases: challenge.phases.map((phase) => this.mapPhase(phase)), reviewers: challenge.reviewers?.map((reviewer) => this.mapReviewer(reviewer)) || @@ -546,7 +702,8 @@ export class ChallengeApiService { winners: challenge.winners?.map((winner) => this.mapWinner(winner)) || [], discussions: [], events: [], - prizeSets: [], + prizeSets: + challenge.prizeSets?.map((prizeSet) => this.mapPrizeSet(prizeSet)) || [], terms: [], skills: [], attachments: [], @@ -620,6 +777,8 @@ export class ChallengeApiService { incrementalPayment: reviewer.incrementalPayment ?? null, type: reviewer.type ?? null, aiWorkflowId: reviewer.aiWorkflowId ?? null, + shouldOpenOpportunity: + reviewer.shouldOpenOpportunity === false ? false : true, }; } @@ -634,13 +793,217 @@ export class ChallengeApiService { }; } + private mapPrizeSet( + prizeSet: ChallengePrizeSetWithPrizes, + ): IChallengePrizeSet { + return { + type: prizeSet.type, + description: prizeSet.description ?? null, + prizes: + prizeSet.prizes?.map((prize) => ({ + type: prize.type, + value: prize.value, + description: prize.description ?? null, + })) ?? [], + }; + } + async createPostMortemPhase( challengeId: string, submissionPhaseId: string, durationHours: number, ): Promise { const now = new Date(); - const end = new Date(now.getTime() + durationHours * 60 * 60 * 1000); + const clampedDurationSeconds = Math.max( + Math.round((durationHours || 0) * 60 * 60), + 1, + ); + const end = new Date(now.getTime() + clampedDurationSeconds * 1000); + + let reusedExisting = false; + + try { + const { postMortemPhaseId } = await this.prisma.$transaction( + async (tx) => { + // Lock the challenge row to avoid concurrent duplicate creations. + await tx.$queryRaw(Prisma.sql` + SELECT 1 + FROM "Challenge" + WHERE "id" = ${challengeId} + FOR UPDATE + `); + + const challenge = await tx.challenge.findUnique({ + ...challengeWithRelationsArgs, + where: { id: challengeId }, + }); + + if (!challenge) { + throw new NotFoundException( + `Challenge with ID ${challengeId} not found when creating post-mortem phase.`, + ); + } + + const submissionPhaseIndex = challenge.phases.findIndex( + (phase) => phase.id === submissionPhaseId, + ); + + if (submissionPhaseIndex === -1) { + throw new NotFoundException( + `Submission phase ${submissionPhaseId} not found for challenge ${challengeId}.`, + ); + } + + const submissionPhase = challenge.phases[submissionPhaseIndex]; + + const futurePhases = challenge.phases.slice(submissionPhaseIndex + 1); + const postMortemPhases = futurePhases.filter( + (phase) => phase.name === 'Post-Mortem', + ); + const existingPostMortem = postMortemPhases[0] ?? null; + + if (postMortemPhases.length > 1) { + this.logger.warn( + `Detected ${postMortemPhases.length} Post-Mortem phases on challenge ${challengeId}; reusing the first and preserving the rest for manual reconciliation.`, + ); + } + + const phasesToDelete = futurePhases + .filter((phase) => phase.name !== 'Post-Mortem') + .map((phase) => phase.id); + + if (phasesToDelete.length) { + await tx.challengePhase.deleteMany({ + where: { id: { in: phasesToDelete } }, + }); + } + + if (existingPostMortem) { + reusedExisting = true; + await tx.challengePhase.update({ + where: { id: existingPostMortem.id }, + data: { + predecessor: submissionPhase.phaseId ?? submissionPhase.id, + duration: clampedDurationSeconds, + scheduledStartDate: now, + scheduledEndDate: end, + actualStartDate: now, + actualEndDate: null, + isOpen: true, + updatedBy: 'Autopilot', + }, + }); + + await tx.challenge.update({ + where: { id: challengeId }, + data: { + currentPhaseNames: [existingPostMortem.name], + }, + }); + + return { postMortemPhaseId: existingPostMortem.id }; + } + + const postMortemPhaseType = await tx.phase.findUnique({ + where: { name: 'Post-Mortem' }, + }); + + if (!postMortemPhaseType) { + throw new NotFoundException( + 'Phase type "Post-Mortem" is not configured in the system.', + ); + } + + const created = await tx.challengePhase.create({ + data: { + challengeId, + phaseId: postMortemPhaseType.id, + name: postMortemPhaseType.name, + description: postMortemPhaseType.description, + predecessor: submissionPhase.phaseId ?? submissionPhase.id, + duration: clampedDurationSeconds, + scheduledStartDate: now, + scheduledEndDate: end, + actualStartDate: now, + isOpen: true, + createdBy: 'Autopilot', + updatedBy: 'Autopilot', + }, + }); + + await tx.challenge.update({ + where: { id: challengeId }, + data: { + currentPhaseNames: [postMortemPhaseType.name], + }, + }); + + return { postMortemPhaseId: created.id }; + }, + { + isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, + }, + ); + + const refreshed = await this.prisma.challenge.findUnique({ + ...challengeWithRelationsArgs, + where: { id: challengeId }, + }); + + const phaseRecord = refreshed?.phases.find( + (phase) => phase.id === postMortemPhaseId, + ); + + if (!phaseRecord) { + throw new Error( + `Post-mortem phase ${postMortemPhaseId} not found after processing for challenge ${challengeId}.`, + ); + } + + const mapped = this.mapPhase(phaseRecord); + + void this.dbLogger.logAction('challenge.createPostMortemPhase', { + challengeId, + status: 'SUCCESS', + source: ChallengeApiService.name, + details: { + submissionPhaseId, + postMortemPhaseId: mapped.id, + durationHours, + reusedExisting, + }, + }); + + return mapped; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('challenge.createPostMortemPhase', { + challengeId, + status: 'ERROR', + source: ChallengeApiService.name, + details: { + submissionPhaseId, + durationHours, + reusedExisting, + error: err.message, + }, + }); + throw err; + } + } + + /** + * Create a Post-Mortem phase without deleting any subsequent phases and without forcing it open. + * Used for Topgear late submission flow where Post-Mortem must exist but only open later. + */ + async createPostMortemPhasePreserving( + challengeId: string, + predecessorPhaseId: string, + durationHours: number, + openImmediately = false, + ): Promise { + const now = new Date(); + const end = new Date(now.getTime() + Math.max(durationHours, 1) * 60 * 60 * 1000); try { const { createdPhaseId } = await this.prisma.$transaction(async (tx) => { @@ -651,30 +1014,26 @@ export class ChallengeApiService { if (!challenge) { throw new NotFoundException( - `Challenge with ID ${challengeId} not found when creating post-mortem phase.`, + `Challenge with ID ${challengeId} not found when creating post-mortem phase (preserving).`, ); } - const submissionPhaseIndex = challenge.phases.findIndex( - (phase) => phase.id === submissionPhaseId, + const predecessorPhase = challenge.phases.find( + (phase) => phase.id === predecessorPhaseId, ); - if (submissionPhaseIndex === -1) { + if (!predecessorPhase) { throw new NotFoundException( - `Submission phase ${submissionPhaseId} not found for challenge ${challengeId}.`, + `Predecessor phase ${predecessorPhaseId} not found for challenge ${challengeId}.`, ); } - const submissionPhase = challenge.phases[submissionPhaseIndex]; - - const futurePhaseIds = challenge.phases - .slice(submissionPhaseIndex + 1) - .map((phase) => phase.id); - - if (futurePhaseIds.length) { - await tx.challengePhase.deleteMany({ - where: { id: { in: futurePhaseIds } }, - }); + // If a Post-Mortem already exists, return it idempotently. + const existing = challenge.phases.find( + (phase) => phase.name === 'Post-Mortem', + ); + if (existing) { + return { createdPhaseId: existing.id }; } const postMortemPhaseType = await tx.phase.findUnique({ @@ -693,26 +1052,26 @@ export class ChallengeApiService { phaseId: postMortemPhaseType.id, name: postMortemPhaseType.name, description: postMortemPhaseType.description, - predecessor: submissionPhase.phaseId ?? submissionPhase.id, - duration: Math.max( - Math.round((end.getTime() - now.getTime()) / 1000), - 1, - ), + predecessor: predecessorPhase.phaseId ?? predecessorPhase.id, + duration: Math.max(Math.round((end.getTime() - now.getTime()) / 1000), 1), scheduledStartDate: now, scheduledEndDate: end, - actualStartDate: now, - isOpen: true, + actualStartDate: openImmediately ? now : null, + isOpen: !!openImmediately, createdBy: 'Autopilot', updatedBy: 'Autopilot', }, }); - await tx.challenge.update({ - where: { id: challengeId }, - data: { - currentPhaseNames: [postMortemPhaseType.name], - }, - }); + // Maintain currentPhaseNames only if opening immediately + if (openImmediately) { + const phaseNames = new Set(challenge.currentPhaseNames ?? []); + phaseNames.add(postMortemPhaseType.name); + await tx.challenge.update({ + where: { id: challengeId }, + data: { currentPhaseNames: Array.from(phaseNames) }, + }); + } return { createdPhaseId: created.id }; }); @@ -739,9 +1098,11 @@ export class ChallengeApiService { status: 'SUCCESS', source: ChallengeApiService.name, details: { - submissionPhaseId, postMortemPhaseId: mapped.id, durationHours, + preserveFuturePhases: true, + openImmediately, + predecessorPhaseId, }, }); @@ -753,8 +1114,10 @@ export class ChallengeApiService { status: 'ERROR', source: ChallengeApiService.name, details: { - submissionPhaseId, + predecessorPhaseId, durationHours, + preserveFuturePhases: true, + openImmediately, error: err.message, }, }); diff --git a/src/challenge/interfaces/challenge.interface.ts b/src/challenge/interfaces/challenge.interface.ts index fa0b0e3..8579f89 100644 --- a/src/challenge/interfaces/challenge.interface.ts +++ b/src/challenge/interfaces/challenge.interface.ts @@ -3,6 +3,8 @@ * returned by the external Challenge API, matching the provided JSON response. */ +import type { PrizeSetTypeEnum } from '@prisma/client'; + /** * Represents a single phase of a challenge. */ @@ -31,6 +33,7 @@ export interface IChallengeReviewer { incrementalPayment: number | null; type: string | null; aiWorkflowId: string | null; + shouldOpenOpportunity: boolean; } export interface IChallengeWinner { @@ -40,6 +43,18 @@ export interface IChallengeWinner { type?: string; } +export interface IChallengePrize { + type: string; + value: number; + description: string | null; +} + +export interface IChallengePrizeSet { + type: PrizeSetTypeEnum; + description: string | null; + prizes: IChallengePrize[]; +} + /** * Represents a full challenge object from the Challenge API. */ @@ -65,13 +80,13 @@ export interface IChallenge { status: string; createdBy: string; updatedBy: string; - metadata: any[]; + metadata: Record; phases: IPhase[]; reviewers: IChallengeReviewer[]; winners: IChallengeWinner[]; discussions: any[]; events: any[]; - prizeSets: any[]; + prizeSets: IChallengePrizeSet[]; terms: any[]; skills: any[]; attachments: any[]; diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 3b079a2..1e97029 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -3,8 +3,11 @@ import kafkaConfig from './sections/kafka.config'; import challengeConfig from './sections/challenge.config'; import reviewConfig from './sections/review.config'; import resourcesConfig from './sections/resources.config'; +import membersConfig from './sections/members.config'; +import busConfig from './sections/bus.config'; import auth0Config from './sections/auth0.config'; import autopilotConfig from './sections/autopilot.config'; +import financeConfig from './sections/finance.config'; export default () => ({ app: appConfig(), @@ -12,6 +15,9 @@ export default () => ({ challenge: challengeConfig(), review: reviewConfig(), resources: resourcesConfig(), + members: membersConfig(), + bus: busConfig(), auth0: auth0Config(), autopilot: autopilotConfig(), + finance: financeConfig(), }); diff --git a/src/config/sections/app.config.ts b/src/config/sections/app.config.ts index 3d0b75e..7953b5c 100644 --- a/src/config/sections/app.config.ts +++ b/src/config/sections/app.config.ts @@ -8,4 +8,5 @@ export default registerAs('app', () => ({ directory: process.env.LOG_DIR || 'logs', enableFileLogging: process.env.ENABLE_FILE_LOGGING === 'true', }, + reviewAppUrl: process.env.REVIEW_APP_URL?.trim() || null, })); diff --git a/src/config/sections/autopilot.config.ts b/src/config/sections/autopilot.config.ts index a3cae4a..ad29d82 100644 --- a/src/config/sections/autopilot.config.ts +++ b/src/config/sections/autopilot.config.ts @@ -22,6 +22,9 @@ export default registerAs('autopilot', () => ({ postMortemScorecardId: process.env.POST_MORTEM_SCORECARD_ID || null, topgearPostMortemScorecardId: process.env.TOPGEAR_POST_MORTEM_SCORECARD_ID || null, + // Optional default scorecard to use for First2Finish iterative reviews + iterativeReviewScorecardId: + process.env.ITERATIVE_REVIEW_SCORECARD_ID || null, postMortemDurationHours: parseNumber( process.env.POST_MORTEM_DURATION_HOURS, 72, @@ -44,4 +47,6 @@ export default registerAs('autopilot', () => ({ process.env.APPEALS_RESPONSE_PHASE_NAMES, ['Appeals Response'], ), + phaseNotificationSendgridTemplateId: + process.env.PHASE_NOTIFICATION_SENDGRID_TEMPLATE || null, })); diff --git a/src/config/sections/bus.config.ts b/src/config/sections/bus.config.ts new file mode 100644 index 0000000..62285f2 --- /dev/null +++ b/src/config/sections/bus.config.ts @@ -0,0 +1,12 @@ +import { registerAs } from '@nestjs/config'; + +function parseNumber(value: string | undefined, fallback: number): number { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +export default registerAs('bus', () => ({ + url: process.env.BUS_API_URL, + timeoutMs: parseNumber(process.env.BUS_API_TIMEOUT_MS, 10000), + originator: process.env.BUS_API_ORIGINATOR || 'autopilot-service', +})); diff --git a/src/config/sections/finance.config.ts b/src/config/sections/finance.config.ts new file mode 100644 index 0000000..3a04205 --- /dev/null +++ b/src/config/sections/finance.config.ts @@ -0,0 +1,12 @@ +import { registerAs } from '@nestjs/config'; + +function parseNumber(value: string | undefined, fallback: number): number { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +export default registerAs('finance', () => ({ + baseUrl: process.env.FINANCE_API_URL || '', + timeoutMs: parseNumber(process.env.FINANCE_API_TIMEOUT_MS, 15000), +})); + diff --git a/src/config/sections/members.config.ts b/src/config/sections/members.config.ts new file mode 100644 index 0000000..3fb173b --- /dev/null +++ b/src/config/sections/members.config.ts @@ -0,0 +1,5 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('members', () => ({ + dbUrl: process.env.MEMBER_DB_URL, +})); diff --git a/src/config/validation.ts b/src/config/validation.ts index 152444c..d812f1e 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -1,5 +1,5 @@ import * as Joi from 'joi'; -import { CronExpression } from '@nestjs/schedule'; +// Note: CronExpression enum is not required for string-based defaults export const validationSchema = Joi.object({ // App Configuration @@ -16,6 +16,7 @@ export const validationSchema = Joi.object({ REDIS_URL: Joi.string() .uri({ scheme: ['redis', 'rediss'] }) .default('redis://127.0.0.1:6379'), + REVIEW_APP_URL: Joi.string().uri().optional(), // Kafka Configuration KAFKA_BROKERS: Joi.string().required(), @@ -46,6 +47,13 @@ export const validationSchema = Joi.object({ then: Joi.optional().default('postgresql://localhost:5432/resources'), otherwise: Joi.required(), }), + MEMBER_DB_URL: Joi.string() + .uri() + .when('NODE_ENV', { + is: 'test', + then: Joi.optional().default('postgresql://localhost:5432/members'), + otherwise: Joi.required(), + }), AUTOPILOT_DB_URL: Joi.string().uri().when('DB_DEBUG', { is: true, then: Joi.required(), @@ -64,8 +72,11 @@ export const validationSchema = Joi.object({ .integer() .positive() .default(24), + // Optional default scorecard to use for First2Finish iterative reviews + ITERATIVE_REVIEW_SCORECARD_ID: Joi.string().optional().allow(null, ''), APPEALS_PHASE_NAMES: Joi.string().default('Appeals'), APPEALS_RESPONSE_PHASE_NAMES: Joi.string().default('Appeals Response'), + PHASE_NOTIFICATION_SENDGRID_TEMPLATE: Joi.string().optional(), // Auth0 Configuration (optional in test environment) AUTH0_URL: Joi.string() @@ -97,6 +108,22 @@ export const validationSchema = Joi.object({ }), AUTH0_PROXY_SEREVR_URL: Joi.string().optional().allow(''), + // Bus API Configuration + BUS_API_URL: Joi.string() + .uri() + .when('NODE_ENV', { + is: 'test', + then: Joi.optional().default('http://localhost:4000'), + otherwise: Joi.required(), + }), + BUS_API_TIMEOUT_MS: Joi.number().integer().positive().default(10000), + BUS_API_ORIGINATOR: Joi.string().default('autopilot-service'), + + // Finance API (optional but recommended) + FINANCE_API_URL: Joi.string().uri().optional(), + FINANCE_API_TIMEOUT_MS: Joi.number().integer().positive().default(15000), + // Sync Service Configuration - SYNC_CRON_SCHEDULE: Joi.string().default(CronExpression.EVERY_5_MINUTES), + // Default sync cadence set to every 3 minutes + SYNC_CRON_SCHEDULE: Joi.string().default('*/3 * * * *'), }); diff --git a/src/finance/finance-api.service.ts b/src/finance/finance-api.service.ts new file mode 100644 index 0000000..b6aade2 --- /dev/null +++ b/src/finance/finance-api.service.ts @@ -0,0 +1,98 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; +import { Auth0Service } from '../auth/auth0.service'; +import { AutopilotDbLoggerService } from '../autopilot/services/autopilot-db-logger.service'; + +@Injectable() +export class FinanceApiService { + private readonly logger = new Logger(FinanceApiService.name); + private readonly baseUrl: string; + private readonly timeoutMs: number; + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService, + private readonly auth0Service: Auth0Service, + private readonly dbLogger: AutopilotDbLoggerService, + ) { + this.baseUrl = (this.configService.get('finance.baseUrl') || '').trim(); + this.timeoutMs = this.configService.get('finance.timeoutMs') ?? 15000; + + if (!this.baseUrl) { + this.logger.warn( + 'FINANCE_API_URL is not configured. Automatic payment generation is disabled.', + ); + } + } + + private buildUrl(path: string): string | null { + if (!this.baseUrl) { + return null; + } + const normalizedBase = this.baseUrl.endsWith('/') + ? this.baseUrl.slice(0, -1) + : this.baseUrl; + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${normalizedBase}${normalizedPath}`; + } + + async generateChallengePayments(challengeId: string): Promise { + const url = this.buildUrl(`/challenges/${challengeId}`); + if (!url) { + await this.dbLogger.logAction('finance.generatePayments', { + challengeId, + status: 'INFO', + source: FinanceApiService.name, + details: { + note: 'FINANCE_API_URL not configured; skipping payment generation call.', + }, + }); + return false; + } + + let token: string | undefined; + try { + token = await this.auth0Service.getAccessToken(); + const response = await firstValueFrom( + this.httpService.post(url, undefined, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + timeout: this.timeoutMs, + }), + ); + + const status = response.status; + await this.dbLogger.logAction('finance.generatePayments', { + challengeId, + status: 'SUCCESS', + source: FinanceApiService.name, + details: { url, status, token }, + }); + this.logger.log( + `Triggered finance payments for challenge ${challengeId} (status ${status}).`, + ); + return true; + } catch (error) { + const err = error as any; + const message = err?.message || 'Unknown error'; + const status = err?.response?.status; + const data = err?.response?.data; + + this.logger.error( + `Failed to trigger finance payments for challenge ${challengeId}: ${message}`, + err?.stack, + ); + await this.dbLogger.logAction('finance.generatePayments', { + challengeId, + status: 'ERROR', + source: FinanceApiService.name, + details: { url, error: message, status, response: data, token }, + }); + return false; + } + } +} diff --git a/src/finance/finance.module.ts b/src/finance/finance.module.ts new file mode 100644 index 0000000..5f695d5 --- /dev/null +++ b/src/finance/finance.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { FinanceApiService } from './finance-api.service'; +import { Auth0Module } from '../auth/auth0.module'; +import { AutopilotLoggingModule } from '../autopilot/autopilot-logging.module'; + +@Module({ + imports: [HttpModule, Auth0Module, AutopilotLoggingModule], + providers: [FinanceApiService], + exports: [FinanceApiService], +}) +export class FinanceModule {} + diff --git a/src/members/members-prisma.service.ts b/src/members/members-prisma.service.ts new file mode 100644 index 0000000..a594cfa --- /dev/null +++ b/src/members/members-prisma.service.ts @@ -0,0 +1,39 @@ +import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class MembersPrismaService + extends PrismaClient + implements OnModuleDestroy +{ + private readonly logger = new Logger(MembersPrismaService.name); + + constructor(configService: ConfigService) { + const databaseUrl = configService.get('members.dbUrl'); + + super( + databaseUrl + ? { + datasources: { + db: { + url: databaseUrl, + }, + }, + } + : undefined, + ); + + if (!databaseUrl) { + Logger.warn( + 'MEMBER_DB_URL is not configured. Prisma client will rely on the default environment resolution.', + MembersPrismaService.name, + ); + } + } + + async onModuleDestroy(): Promise { + await this.$disconnect(); + this.logger.debug('Disconnected Prisma client for Members DB.'); + } +} diff --git a/src/members/members.module.ts b/src/members/members.module.ts new file mode 100644 index 0000000..ad936c7 --- /dev/null +++ b/src/members/members.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { MembersPrismaService } from './members-prisma.service'; +import { MembersService } from './members.service'; + +@Module({ + providers: [MembersPrismaService, MembersService], + exports: [MembersService], +}) +export class MembersModule {} diff --git a/src/members/members.service.ts b/src/members/members.service.ts new file mode 100644 index 0000000..9e5b3b6 --- /dev/null +++ b/src/members/members.service.ts @@ -0,0 +1,135 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { MembersPrismaService } from './members-prisma.service'; +import { AutopilotDbLoggerService } from '../autopilot/services/autopilot-db-logger.service'; + +export interface MemberEmailLookupInput { + memberIds?: string[]; + handles?: string[]; +} + +export interface MemberEmailLookupResult { + idToEmail: Map; + handleToEmail: Map; +} + +@Injectable() +export class MembersService { + constructor( + private readonly prisma: MembersPrismaService, + private readonly dbLogger: AutopilotDbLoggerService, + ) {} + + async getMemberEmails( + params: MemberEmailLookupInput, + ): Promise { + const idCandidates = params.memberIds ?? []; + const handleCandidates = params.handles ?? []; + + const memberIds = Array.from( + new Set( + idCandidates + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value) && /^\d+$/.test(value)), + ), + ); + + const handles = Array.from( + new Set( + handleCandidates + .map((value) => value?.trim().toLowerCase()) + .filter((value): value is string => Boolean(value)), + ), + ); + + const idToEmail = new Map(); + const handleToEmail = new Map(); + + if (!memberIds.length && !handles.length) { + void this.dbLogger.logAction('members.getMemberEmails', { + status: 'INFO', + source: MembersService.name, + details: { + inputIds: idCandidates.length, + inputHandles: handleCandidates.length, + resolvedIds: 0, + resolvedHandles: 0, + note: 'No member identifiers provided after normalization.', + }, + }); + + return { idToEmail, handleToEmail }; + } + + try { + if (memberIds.length) { + const idList = Prisma.join( + memberIds.map((id) => Prisma.sql`${BigInt(id)}`), + ); + + const rows = await this.prisma.$queryRaw>( + Prisma.sql` + SELECT "userId", "email" + FROM "member" + WHERE "userId" IN (${idList}) + `, + ); + + for (const row of rows) { + if (!row.userId || !row.email) { + continue; + } + idToEmail.set(row.userId.toString(), row.email.trim()); + } + } + + if (handles.length) { + const handleList = Prisma.join( + handles.map((handle) => Prisma.sql`${handle}`), + ); + + const rows = await this.prisma.$queryRaw>( + Prisma.sql` + SELECT "handleLower", "email" + FROM "member" + WHERE "handleLower" IN (${handleList}) + `, + ); + + for (const row of rows) { + if (!row.handleLower || !row.email) { + continue; + } + handleToEmail.set(row.handleLower.trim(), row.email.trim()); + } + } + + void this.dbLogger.logAction('members.getMemberEmails', { + status: 'SUCCESS', + source: MembersService.name, + details: { + inputIds: idCandidates.length, + inputHandles: handleCandidates.length, + resolvedIds: memberIds.length, + resolvedHandles: handles.length, + matchedIds: idToEmail.size, + matchedHandles: handleToEmail.size, + }, + }); + + return { idToEmail, handleToEmail }; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('members.getMemberEmails', { + status: 'ERROR', + source: MembersService.name, + details: { + inputIds: idCandidates.length, + inputHandles: handleCandidates.length, + error: err.message, + }, + }); + throw err; + } + } +} diff --git a/src/resources/resources.service.ts b/src/resources/resources.service.ts index 03325c8..f146b4b 100644 --- a/src/resources/resources.service.ts +++ b/src/resources/resources.service.ts @@ -176,6 +176,56 @@ export class ResourcesService { } } + async getPhaseChangeNotificationResources( + challengeId: string, + ): Promise { + if (!challengeId) { + return []; + } + + const query = Prisma.sql` + SELECT + r."id", + r."memberId", + r."memberHandle", + rr."name" AS "roleName" + FROM ${ResourcesService.RESOURCE_TABLE} r + INNER JOIN ${ResourcesService.RESOURCE_ROLE_TABLE} rr ON rr."id" = r."roleId" + WHERE r."challengeId" = ${challengeId} + AND r."phaseChangeNotifications" IS TRUE + `; + + try { + const recipients = await this.prisma.$queryRaw< + ReviewerResourceRecord[] + >(query); + + void this.dbLogger.logAction( + 'resources.getPhaseChangeNotificationResources', + { + challengeId, + status: 'SUCCESS', + source: ResourcesService.name, + details: { recipientCount: recipients.length }, + }, + ); + + return recipients; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction( + 'resources.getPhaseChangeNotificationResources', + { + challengeId, + status: 'ERROR', + source: ResourcesService.name, + details: { error: err.message }, + }, + ); + throw err; + } + } + async getResourceByMemberHandle( challengeId: string, memberHandle: string, diff --git a/src/review/review.service.spec.ts b/src/review/review.service.spec.ts new file mode 100644 index 0000000..557024e --- /dev/null +++ b/src/review/review.service.spec.ts @@ -0,0 +1,220 @@ +import { ReviewService } from './review.service'; + +describe('ReviewService', () => { + const challengeId = 'challenge-1'; + + let prismaMock: { $queryRaw: jest.Mock }; + let dbLoggerMock: { logAction: jest.Mock }; + let service: ReviewService; + + beforeEach(() => { + prismaMock = { + $queryRaw: jest.fn(), + }; + dbLoggerMock = { + logAction: jest.fn(), + }; + + service = new ReviewService( + prismaMock as unknown as any, + dbLoggerMock as unknown as any, + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('getTopFinalReviewScores', () => { + it('returns winners from review summations when available', async () => { + prismaMock.$queryRaw.mockResolvedValueOnce([ + { + memberId: ' 123 ', + submissionId: 'submission-1', + aggregateScore: '98.5', + }, + { + memberId: '123', + submissionId: 'submission-2', + aggregateScore: 90, + }, + ]); + + const winners = await service.getTopFinalReviewScores(challengeId, 2); + + expect(winners).toEqual([ + { + memberId: '123', + submissionId: 'submission-1', + aggregateScore: 98.5, + }, + ]); + expect(dbLoggerMock.logAction).toHaveBeenCalledWith( + 'review.getTopFinalReviewScores', + expect.objectContaining({ + details: expect.objectContaining({ + dataSource: 'reviewSummation', + winnersCount: 1, + }), + }), + ); + }); + + it('falls back to generated summaries when summations are empty', async () => { + prismaMock.$queryRaw.mockResolvedValueOnce([]); + + const summariesSpy = jest + .spyOn(service, 'generateReviewSummaries') + .mockResolvedValue([ + { + submissionId: 'submission-3', + legacySubmissionId: null, + memberId: '456', + submittedDate: new Date('2024-01-01T00:00:00Z'), + aggregateScore: 92, + scorecardId: null, + scorecardLegacyId: null, + passingScore: 80, + isPassing: true, + }, + { + submissionId: 'submission-4', + legacySubmissionId: null, + memberId: null, + submittedDate: new Date('2024-01-02T00:00:00Z'), + aggregateScore: 99, + scorecardId: null, + scorecardLegacyId: null, + passingScore: 80, + isPassing: true, + }, + ]); + + const winners = await service.getTopFinalReviewScores(challengeId, 1); + + expect(summariesSpy).toHaveBeenCalledWith(challengeId); + expect(winners).toEqual([ + { + memberId: '456', + submissionId: 'submission-3', + aggregateScore: 92, + }, + ]); + expect(dbLoggerMock.logAction).toHaveBeenCalledWith( + 'review.getTopFinalReviewScores', + expect.objectContaining({ + details: expect.objectContaining({ + dataSource: 'reviewSummaries', + winnersCount: 1, + }), + }), + ); + }); + }); + + describe('getCheckpointPassedSubmissionIds', () => { + const screeningScorecardId = 'scorecard-1'; + + it('includes fallback logic for raw scores and minimum score thresholds', async () => { + prismaMock.$queryRaw.mockResolvedValue([{ id: 'submission-1' }]); + + const result = await service.getCheckpointPassedSubmissionIds( + challengeId, + screeningScorecardId, + ); + + expect(result).toEqual(['submission-1']); + expect(prismaMock.$queryRaw).toHaveBeenCalledTimes(1); + + const rawQuery = prismaMock.$queryRaw.mock.calls[0][0] as { + strings?: TemplateStringsArray | string[]; + }; + const sqlText = Array.isArray(rawQuery?.strings) + ? rawQuery.strings.join('') + : ''; + + expect(sqlText).toContain('GREATEST('); + expect(sqlText).toContain('r."initialScore"'); + expect(sqlText).toContain('sc."minScore"'); + + expect(dbLoggerMock.logAction).toHaveBeenCalledWith( + 'review.getCheckpointPassedSubmissionIds', + expect.objectContaining({ + details: expect.objectContaining({ + screeningScorecardId, + submissionCount: 1, + }), + }), + ); + }); + }); + + describe('getFailedScreeningSubmissionIds', () => { + it('returns an empty set when scorecard list is empty', async () => { + const result = await service.getFailedScreeningSubmissionIds( + challengeId, + [], + ); + + expect(result.size).toBe(0); + expect(prismaMock.$queryRaw).not.toHaveBeenCalled(); + expect(dbLoggerMock.logAction).not.toHaveBeenCalled(); + }); + + it('filters completed reviews using both final and raw scores', async () => { + prismaMock.$queryRaw.mockResolvedValue([{ id: 'submission-2' }]); + + const result = await service.getFailedScreeningSubmissionIds( + challengeId, + ['scorecard-1'], + ); + + expect(result).toEqual(new Set(['submission-2'])); + expect(prismaMock.$queryRaw).toHaveBeenCalledTimes(1); + const rawQuery = prismaMock.$queryRaw.mock.calls[0][0] as { + strings?: TemplateStringsArray | string[]; + }; + const sqlText = Array.isArray(rawQuery?.strings) + ? rawQuery.strings.join('') + : ''; + + expect(sqlText).toContain('GREATEST('); + expect(sqlText).toContain('r."initialScore"'); + expect(sqlText).toContain('sc."minScore"'); + + expect(dbLoggerMock.logAction).toHaveBeenCalledWith( + 'review.getFailedScreeningSubmissionIds', + expect.objectContaining({ + details: expect.objectContaining({ + screeningScorecardCount: 1, + failedSubmissionCount: 1, + }), + }), + ); + }); + + it('returns failed submission ids when query succeeds', async () => { + prismaMock.$queryRaw.mockResolvedValueOnce([ + { id: 'failed-1' }, + { id: 'failed-2' }, + { id: 'failed-1' }, + ]); + + const result = await service.getFailedScreeningSubmissionIds( + challengeId, + ['screening-1'], + ); + + expect(result).toEqual(new Set(['failed-1', 'failed-2'])); + expect(dbLoggerMock.logAction).toHaveBeenCalledWith( + 'review.getFailedScreeningSubmissionIds', + expect.objectContaining({ + status: 'SUCCESS', + details: expect.objectContaining({ + failedSubmissionCount: 2, + }), + }), + ); + }); + }); +}); diff --git a/src/review/review.service.ts b/src/review/review.service.ts index 0ea0e03..9b85885 100644 --- a/src/review/review.service.ts +++ b/src/review/review.service.ts @@ -8,6 +8,12 @@ interface SubmissionRecord { id: string; } +export interface ActiveContestSubmission { + id: string; + memberId: string | null; + isLatest: boolean; +} + interface ReviewRecord { submissionId: string | null; resourceId: string; @@ -143,53 +149,22 @@ export class ReviewService { AND s."memberId" IS NOT NULL AND ( s."type" IS NULL - OR upper(s."type") = 'CONTEST_SUBMISSION' + OR UPPER((s."type")::text) = 'CONTEST_SUBMISSION' ) ORDER BY rs."aggregateScore" DESC, s."submittedDate" ASC, s."id" ASC ${limitClause} `); - if (!rows.length) { - void this.dbLogger.logAction('review.getTopFinalReviewScores', { - challengeId, - status: 'SUCCESS', - source: ReviewService.name, - details: { limit, rowsExamined: 0, winnersCount: 0 }, - }); - return []; - } + const winnersFromSummations = + this.selectUniqueMemberScoresFromSummations(rows, limit); - const seenMembers = new Set(); - const winners: Array<{ - memberId: string; - submissionId: string; - aggregateScore: number; - }> = []; + let winners = winnersFromSummations; + let dataSource: 'reviewSummation' | 'reviewSummaries' = 'reviewSummation'; - for (const row of rows) { - const memberId = row.memberId?.trim(); - if (!memberId || seenMembers.has(memberId)) { - continue; - } - - const aggregateScore = - typeof row.aggregateScore === 'string' - ? Number(row.aggregateScore) - : (row.aggregateScore ?? 0); - - if (Number.isNaN(aggregateScore)) { - continue; - } - - winners.push({ - memberId, - submissionId: row.submissionId, - aggregateScore, - }); - seenMembers.add(memberId); - - if (winners.length >= limit) { - break; + if (!winners.length) { + winners = await this.deriveTopScoresFromSummaries(challengeId, limit); + if (winners.length) { + dataSource = 'reviewSummaries'; } } @@ -201,6 +176,7 @@ export class ReviewService { limit, rowsExamined: rows.length, winnersCount: winners.length, + dataSource, }, }); @@ -217,6 +193,106 @@ export class ReviewService { } } + private selectUniqueMemberScoresFromSummations( + rows: Array<{ + memberId: string | null; + submissionId: string; + aggregateScore: number | string | null; + }>, + limit: number, + ): Array<{ memberId: string; submissionId: string; aggregateScore: number }> { + const winners: Array<{ + memberId: string; + submissionId: string; + aggregateScore: number; + }> = []; + const seenMembers = new Set(); + + for (const row of rows) { + const memberId = row.memberId?.trim(); + if (!memberId || seenMembers.has(memberId)) { + continue; + } + + const aggregateScore = + typeof row.aggregateScore === 'string' + ? Number(row.aggregateScore) + : (row.aggregateScore ?? 0); + + if (Number.isNaN(aggregateScore)) { + continue; + } + + winners.push({ + memberId, + submissionId: row.submissionId, + aggregateScore, + }); + seenMembers.add(memberId); + + if (winners.length >= limit) { + break; + } + } + + return winners; + } + + private async deriveTopScoresFromSummaries( + challengeId: string, + limit: number, + ): Promise< + Array<{ memberId: string; submissionId: string; aggregateScore: number }> + > { + const summaries = await this.generateReviewSummaries(challengeId); + if (!summaries.length) { + return []; + } + + const seenMembers = new Set(); + const sortedSummaries = summaries + .filter((summary) => summary.isPassing) + .sort((a, b) => { + if (b.aggregateScore !== a.aggregateScore) { + return b.aggregateScore - a.aggregateScore; + } + + const timeA = a.submittedDate?.getTime() ?? Number.POSITIVE_INFINITY; + const timeB = b.submittedDate?.getTime() ?? Number.POSITIVE_INFINITY; + if (timeA !== timeB) { + return timeA - timeB; + } + + return a.submissionId.localeCompare(b.submissionId); + }); + + const winners: Array<{ + memberId: string; + submissionId: string; + aggregateScore: number; + }> = []; + + for (const summary of sortedSummaries) { + const memberId = summary.memberId?.trim(); + if (!memberId || seenMembers.has(memberId)) { + continue; + } + + winners.push({ + memberId, + submissionId: summary.submissionId, + aggregateScore: summary.aggregateScore, + }); + seenMembers.add(memberId); + + if (winners.length >= limit) { + break; + } + } + + return winners; + } + async getActiveSubmissionIds(challengeId: string): Promise { const query = Prisma.sql` SELECT "id" @@ -252,6 +328,274 @@ export class ReviewService { } } + async getActiveContestSubmissions( + challengeId: string, + ): Promise { + const query = Prisma.sql` + SELECT + s."id", + s."memberId", + CASE + WHEN ROW_NUMBER() OVER ( + PARTITION BY COALESCE(s."memberId", s."id") + ORDER BY + s."submittedDate" DESC NULLS LAST, + s."createdAt" DESC NULLS LAST, + s."updatedAt" DESC NULLS LAST, + s."id" DESC + ) = 1 THEN TRUE + ELSE FALSE + END AS "isLatest" + FROM ${ReviewService.SUBMISSION_TABLE} s + 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' + ) + `; + + try { + const submissions = + await this.prisma.$queryRaw< + Array<{ id: string; memberId: string | null; isLatest: boolean }> + >(query); + + const sanitized = submissions + .filter((record) => Boolean(record?.id)) + .map((record) => ({ + id: record.id, + memberId: record.memberId ?? null, + isLatest: Boolean(record.isLatest), + })); + + void this.dbLogger.logAction('review.getActiveContestSubmissions', { + challengeId, + status: 'SUCCESS', + source: ReviewService.name, + details: { submissionCount: sanitized.length }, + }); + + return sanitized; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('review.getActiveContestSubmissions', { + challengeId, + status: 'ERROR', + source: ReviewService.name, + details: { error: err.message }, + }); + throw err; + } + } + + async getActiveContestSubmissionIds( + challengeId: string, + ): Promise { + try { + const submissions = await this.getActiveContestSubmissions(challengeId); + const submissionIds = submissions.map((record) => record.id); + + void this.dbLogger.logAction('review.getActiveContestSubmissionIds', { + challengeId, + status: 'SUCCESS', + source: ReviewService.name, + details: { submissionCount: submissionIds.length }, + }); + + return submissionIds; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('review.getActiveContestSubmissionIds', { + challengeId, + status: 'ERROR', + source: ReviewService.name, + details: { error: err.message }, + }); + throw err; + } + } + + async getActiveCheckpointSubmissionIds( + challengeId: string, + ): Promise { + const query = Prisma.sql` + SELECT "id" + FROM ${ReviewService.SUBMISSION_TABLE} + WHERE "challengeId" = ${challengeId} + AND ("status" = 'ACTIVE' OR "status" IS NULL) + AND UPPER(("type")::text) = 'CHECKPOINT_SUBMISSION' + `; + + try { + const submissions = + await this.prisma.$queryRaw(query); + const submissionIds = submissions + .map((record) => record.id) + .filter(Boolean); + + void this.dbLogger.logAction( + 'review.getActiveCheckpointSubmissionIds', + { + challengeId, + status: 'SUCCESS', + source: ReviewService.name, + details: { submissionCount: submissionIds.length }, + }, + ); + + return submissionIds; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction( + 'review.getActiveCheckpointSubmissionIds', + { + challengeId, + status: 'ERROR', + source: ReviewService.name, + details: { error: err.message }, + }, + ); + throw err; + } + } + + /** + * Returns checkpoint submission IDs that have a COMPLETED review for the provided screening scorecard + * with a recorded score (final or raw) greater than or equal to the scorecard's minimumPassingScore. + */ + async getCheckpointPassedSubmissionIds( + challengeId: string, + screeningScorecardId: string, + ): Promise { + if (!challengeId || !screeningScorecardId) { + return []; + } + + const query = Prisma.sql` + SELECT s."id" + FROM ${ReviewService.SUBMISSION_TABLE} s + INNER JOIN ${ReviewService.REVIEW_TABLE} r + ON r."submissionId" = s."id" + INNER JOIN ${ReviewService.SCORECARD_TABLE} sc + ON sc."id" = r."scorecardId" + WHERE s."challengeId" = ${challengeId} + AND (s."status" = 'ACTIVE' OR s."status" IS NULL) + AND UPPER((s."type")::text) = 'CHECKPOINT_SUBMISSION' + AND r."scorecardId" = ${screeningScorecardId} + AND UPPER((r."status")::text) = 'COMPLETED' + AND GREATEST( + COALESCE(r."finalScore", 0), + COALESCE(r."initialScore", 0) + ) >= COALESCE(sc."minimumPassingScore", sc."minScore", 50) + `; + + try { + const rows = await this.prisma.$queryRaw(query); + const submissionIds = rows.map((r) => r.id).filter(Boolean); + + void this.dbLogger.logAction('review.getCheckpointPassedSubmissionIds', { + challengeId, + status: 'SUCCESS', + source: ReviewService.name, + details: { + screeningScorecardId, + submissionCount: submissionIds.length, + }, + }); + + return submissionIds; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('review.getCheckpointPassedSubmissionIds', { + challengeId, + status: 'ERROR', + source: ReviewService.name, + details: { + screeningScorecardId, + error: err.message, + }, + }); + throw err; + } + } + + async getFailedScreeningSubmissionIds( + challengeId: string, + screeningScorecardIds: string[], + ): Promise> { + const uniqueIds = Array.from( + new Set( + screeningScorecardIds + .map((id) => id?.trim()) + .filter((id): id is string => Boolean(id)), + ), + ); + + if (!challengeId || !uniqueIds.length) { + return new Set(); + } + + const scorecardList = Prisma.join(uniqueIds.map((id) => Prisma.sql`${id}`)); + + const query = Prisma.sql` + SELECT DISTINCT s."id" + FROM ${ReviewService.SUBMISSION_TABLE} s + INNER JOIN ${ReviewService.REVIEW_TABLE} r + ON r."submissionId" = s."id" + INNER JOIN ${ReviewService.SCORECARD_TABLE} sc + ON sc."id" = r."scorecardId" + 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 r."scorecardId" IN (${scorecardList}) + AND ( + ( + UPPER((r."status")::text) = 'COMPLETED' + AND GREATEST( + COALESCE(r."finalScore", 0), + COALESCE(r."initialScore", 0) + ) < COALESCE(sc."minimumPassingScore", sc."minScore", 50) + ) + OR UPPER((r."status")::text) = 'FAILED' + ) + `; + + try { + const rows = await this.prisma.$queryRaw(query); + const failedIds = new Set( + rows.map((record) => record.id).filter(Boolean), + ); + + void this.dbLogger.logAction('review.getFailedScreeningSubmissionIds', { + challengeId, + status: 'SUCCESS', + source: ReviewService.name, + details: { + screeningScorecardCount: uniqueIds.length, + failedSubmissionCount: failedIds.size, + }, + }); + + return failedIds; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('review.getFailedScreeningSubmissionIds', { + challengeId, + status: 'ERROR', + source: ReviewService.name, + details: { + screeningScorecardCount: uniqueIds.length, + error: err.message, + }, + }); + throw err; + } + } + async getExistingReviewPairs( phaseId: string, challengeId?: string, @@ -303,6 +647,57 @@ export class ReviewService { } } + async getReviewerSubmissionPairs( + challengeId: string, + ): Promise> { + if (!challengeId) { + return new Set(); + } + + const query = Prisma.sql` + SELECT review."submissionId", review."resourceId" + FROM ${ReviewService.REVIEW_TABLE} AS review + INNER JOIN ${ReviewService.SUBMISSION_TABLE} AS submission + ON submission."id" = review."submissionId" + WHERE submission."challengeId" = ${challengeId} + `; + + try { + const records = await this.prisma.$queryRaw(query); + const result = new Set(); + + for (const record of records) { + if (!record.submissionId || !record.resourceId) { + continue; + } + + result.add(this.composeKey(record.resourceId, record.submissionId)); + } + + void this.dbLogger.logAction('review.getReviewerSubmissionPairs', { + challengeId, + status: 'SUCCESS', + source: ReviewService.name, + details: { + pairCount: result.size, + }, + }); + + return result; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('review.getReviewerSubmissionPairs', { + challengeId, + status: 'ERROR', + source: ReviewService.name, + details: { + error: err.message, + }, + }); + throw err; + } + } + async getPendingReviewCount( phaseId: string, challengeId?: string, @@ -385,6 +780,7 @@ export class ReviewService { OR UPPER((existing."status")::text) NOT IN ('COMPLETED', 'NO_REVIEW') ) ) + RETURNING "id" `; try { @@ -395,14 +791,47 @@ export class ReviewService { scorecardId, ); - const created = await this.prisma.$transaction(async (tx) => { - await tx.$executeRaw(Prisma.sql` - SELECT pg_advisory_xact_lock(${lockId}) - `); - - const rowsInserted = await tx.$executeRaw(insert); - return rowsInserted > 0; - }); + const { created, reviewId, pendingReviewIds } = + await this.prisma.$transaction(async (tx) => { + await tx.$executeRaw(Prisma.sql` + SELECT pg_advisory_xact_lock(${lockId}) + `); + + const insertedReviews = await tx.$queryRaw< + Array<{ id: string }> + >(insert); + + if (insertedReviews.length > 0) { + return { + created: true, + reviewId: insertedReviews[0]?.id ?? null, + pendingReviewIds: insertedReviews.map((row) => row.id), + }; + } + + const existingPendingReviews = await tx.$queryRaw< + Array<{ id: string }> + >(Prisma.sql` + SELECT existing."id" + FROM ${ReviewService.REVIEW_TABLE} existing + WHERE existing."resourceId" = ${resourceId} + AND existing."phaseId" = ${phaseId} + AND existing."submissionId" IS NOT DISTINCT FROM ${submissionId} + AND existing."scorecardId" IS NOT DISTINCT FROM ${scorecardId} + AND ( + existing."status" IS NULL + OR UPPER((existing."status")::text) NOT IN ('COMPLETED', 'NO_REVIEW') + ) + ORDER BY existing."createdAt" DESC, existing."id" DESC + LIMIT 10 + `); + + return { + created: false, + reviewId: existingPendingReviews[0]?.id ?? null, + pendingReviewIds: existingPendingReviews.map((row) => row.id), + }; + }); void this.dbLogger.logAction('review.createPendingReview', { challengeId, @@ -412,7 +841,10 @@ export class ReviewService { resourceId, submissionId, phaseId, + scorecardId, created, + reviewId, + pendingReviewIds, }, }); @@ -427,7 +859,9 @@ export class ReviewService { resourceId, submissionId, phaseId, + scorecardId, error: err.message, + errorStack: err.stack ?? null, }, }); throw err; @@ -811,6 +1245,51 @@ export class ReviewService { } } + async getScorecardIdByName(name: string): Promise { + if (!name) { + return null; + } + + const query = Prisma.sql` + SELECT "id" + FROM ${ReviewService.SCORECARD_TABLE} + WHERE "name" = ${name} + LIMIT 1 + `; + + try { + const [record] = await this.prisma.$queryRaw<{ id: string | null }[]>( + query, + ); + + const id = record?.id ?? null; + + void this.dbLogger.logAction('review.getScorecardIdByName', { + challengeId: null, + status: 'SUCCESS', + source: ReviewService.name, + details: { + name, + scorecardId: id, + }, + }); + + return id; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('review.getScorecardIdByName', { + challengeId: null, + status: 'ERROR', + source: ReviewService.name, + details: { + name, + error: err.message, + }, + }); + throw err; + } + } + async getPendingAppealCount(challengeId: string): Promise { const query = Prisma.sql` SELECT COUNT(*)::int AS count @@ -857,4 +1336,48 @@ export class ReviewService { throw err; } } + + async getTotalAppealCount(challengeId: string): Promise { + const query = Prisma.sql` + SELECT COUNT(*)::int AS count + FROM ${ReviewService.APPEAL_TABLE} a + INNER JOIN ${ReviewService.REVIEW_ITEM_COMMENT_TABLE} ric + ON ric."id" = a."reviewItemCommentId" + INNER JOIN ${ReviewService.REVIEW_ITEM_TABLE} ri + ON ri."id" = ric."reviewItemId" + INNER JOIN ${ReviewService.REVIEW_TABLE} r + ON r."id" = ri."reviewId" + INNER JOIN ${ReviewService.SUBMISSION_TABLE} s + ON s."id" = r."submissionId" + WHERE s."challengeId" = ${challengeId} + `; + + try { + const [record] = await this.prisma.$queryRaw(query); + const rawCount = Number(record?.count ?? 0); + const count = Number.isFinite(rawCount) ? rawCount : 0; + + void this.dbLogger.logAction('review.getTotalAppealCount', { + challengeId, + status: 'SUCCESS', + source: ReviewService.name, + details: { + totalAppeals: count, + }, + }); + + return count; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('review.getTotalAppealCount', { + challengeId, + status: 'ERROR', + source: ReviewService.name, + details: { + error: err.message, + }, + }); + throw err; + } + } } diff --git a/src/sync/sync.service.ts b/src/sync/sync.service.ts index 467e7ed..48f5c32 100644 --- a/src/sync/sync.service.ts +++ b/src/sync/sync.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; +import { Cron } from '@nestjs/schedule'; import { AutopilotService } from '../autopilot/services/autopilot.service'; import { ChallengeApiService } from '../challenge/challenge-api.service'; import { SchedulerService } from '../autopilot/services/scheduler.service'; @@ -18,7 +18,8 @@ export class SyncService { private readonly schedulerService: SchedulerService, ) {} - @Cron(CronExpression.EVERY_5_MINUTES) + // Run sync every 3 minutes instead of 5 + @Cron('*/3 * * * *') async handleCron() { this.logger.log('Running scheduled challenge synchronization...'); await this.synchronizeChallenges(); diff --git a/test/autopilot.e2e-spec.ts b/test/autopilot.e2e-spec.ts index 04e5fe3..8ee1444 100644 --- a/test/autopilot.e2e-spec.ts +++ b/test/autopilot.e2e-spec.ts @@ -19,6 +19,7 @@ import { } from '../src/autopilot/interfaces/autopilot.interface'; import { AutopilotService } from '../src/autopilot/services/autopilot.service'; import { ReviewService } from '../src/review/review.service'; +import type { ActiveContestSubmission } from '../src/review/review.service'; import { ResourcesService } from '../src/resources/resources.service'; import { PhaseReviewService } from '../src/autopilot/services/phase-review.service'; import { ReviewAssignmentService } from '../src/autopilot/services/review-assignment.service'; @@ -225,8 +226,13 @@ describe('Autopilot Service (e2e)', () => { deletePendingReviewsForResource: jest.fn().mockResolvedValue(0), createPendingReview: jest.fn().mockResolvedValue(true), getActiveSubmissionCount: jest.fn().mockResolvedValue(1), + getActiveContestSubmissions: jest + .fn() + .mockResolvedValue([] as ActiveContestSubmission[]), + getActiveContestSubmissionIds: jest.fn().mockResolvedValue([]), getAllSubmissionIdsOrdered: jest.fn().mockResolvedValue([]), getExistingReviewPairs: jest.fn().mockResolvedValue(new Set()), + getReviewerSubmissionPairs: jest.fn().mockResolvedValue(new Set()), getReviewById: jest.fn().mockResolvedValue(null), getScorecardPassingScore: jest.fn().mockResolvedValue(50), getCompletedReviewCountForPhase: jest.fn().mockResolvedValue(0), @@ -1011,7 +1017,9 @@ describe('Autopilot Service (e2e)', () => { mockChallengeApiService.getChallengeById.mockResolvedValueOnce( challengeWithZeroSubmissions, ); - reviewServiceMockFns.getActiveSubmissionCount.mockResolvedValueOnce(0); + reviewServiceMockFns.getActiveContestSubmissionIds.mockResolvedValueOnce( + [], + ); resourcesServiceMockFns.getResourcesByRoleNames.mockResolvedValue([ { id: 'postmortem-reviewer' }, { id: 'postmortem-copilot' }, @@ -1216,7 +1224,7 @@ describe('Autopilot Service (e2e)', () => { schedulerAdvanceSpy.mockRestore(); }); - it('should reassign the same submission to the reviewer when iterative review fails', async () => { + it('should assign the next available submission when iterative review fails', async () => { const challengeId = 'f2f-challenge-fail'; const iterativePhase = { id: 'iter-phase-id', @@ -1251,7 +1259,15 @@ describe('Autopilot Service (e2e)', () => { mockChallengeApiService.getChallengeById .mockResolvedValueOnce(f2fChallenge) - .mockResolvedValueOnce(f2fChallenge); + .mockResolvedValueOnce({ + ...f2fChallenge, + phases: [ + { + ...iterativePhase, + isOpen: false, + }, + ], + }); reviewServiceMockFns.getReviewById.mockResolvedValueOnce({ id: 'review-1', phaseId: iterativePhase.id, @@ -1262,13 +1278,15 @@ describe('Autopilot Service (e2e)', () => { status: 'COMPLETED', } as any); reviewServiceMockFns.getScorecardPassingScore.mockResolvedValueOnce(80); - reviewServiceMockFns.getAllSubmissionIdsOrdered.mockResolvedValueOnce([ - 'submission-1', - 'submission-2', - ]); - reviewServiceMockFns.getExistingReviewPairs.mockResolvedValueOnce( - new Set(), - ); + reviewServiceMockFns.getAllSubmissionIdsOrdered + .mockResolvedValueOnce(['submission-1', 'submission-2']) + .mockResolvedValueOnce(['submission-1', 'submission-2']); + reviewServiceMockFns.getExistingReviewPairs + .mockResolvedValueOnce(new Set()) + .mockResolvedValueOnce(new Set()); + reviewServiceMockFns.getReviewerSubmissionPairs + .mockResolvedValueOnce(new Set(['iter-resource:submission-1'])) + .mockResolvedValueOnce(new Set(['iter-resource:submission-1'])); resourcesServiceMockFns.getReviewerResources.mockResolvedValueOnce([ { id: 'iter-resource' }, ]); @@ -1304,7 +1322,7 @@ describe('Autopilot Service (e2e)', () => { } as ReviewCompletedPayload); expect(reviewServiceMockFns.createPendingReview).toHaveBeenCalledWith( - 'submission-1', + 'submission-2', 'iter-resource', iterativePhase.id, 'iter-scorecard', @@ -1314,6 +1332,130 @@ describe('Autopilot Service (e2e)', () => { schedulerAdvanceSpy.mockRestore(); }); + it('should skip previously reviewed submissions when selecting the next iterative review', async () => { + const challengeId = 'f2f-challenge-history'; + const iterativePhase = { + id: 'iter-phase-1', + phaseId: 'iter-template-id', + name: 'Iterative Review', + isOpen: true, + scheduledStartDate: mockPastPhaseDate, + scheduledEndDate: mockFuturePhaseDate1, + actualStartDate: mockPastPhaseDate, + actualEndDate: null, + predecessor: null, + }; + const challengeWithOpenPhase = { + ...mockChallenge, + id: challengeId, + type: 'first2finish', + phases: [iterativePhase], + reviewers: [ + { + id: 'rev-config', + scorecardId: 'iter-scorecard', + isMemberReview: true, + memberReviewerCount: 1, + phaseId: iterativePhase.phaseId, + basePayment: null, + incrementalPayment: null, + type: null, + aiWorkflowId: null, + }, + ], + }; + const challengeAfterClose = { + ...challengeWithOpenPhase, + phases: [ + { + ...iterativePhase, + isOpen: false, + actualEndDate: new Date().toISOString(), + }, + ], + }; + const nextPhase = { + id: 'iter-phase-2', + phaseId: 'iter-template-id', + name: 'Iterative Review', + isOpen: true, + scheduledStartDate: mockFuturePhaseDate1, + scheduledEndDate: mockFuturePhaseDate2, + actualStartDate: new Date().toISOString(), + actualEndDate: null, + predecessor: iterativePhase.id, + }; + + mockChallengeApiService.getChallengeById + .mockResolvedValueOnce(challengeWithOpenPhase) + .mockResolvedValueOnce(challengeAfterClose); + reviewServiceMockFns.getReviewById.mockResolvedValueOnce({ + id: 'review-2', + phaseId: iterativePhase.id, + resourceId: 'iter-resource', + submissionId: 'submission-2', + scorecardId: 'iter-scorecard', + score: 30, + status: 'COMPLETED', + } as any); + reviewServiceMockFns.getScorecardPassingScore.mockResolvedValueOnce(80); + reviewServiceMockFns.getAllSubmissionIdsOrdered + .mockResolvedValueOnce([ + 'submission-1', + 'submission-2', + 'submission-3', + ]) + .mockResolvedValueOnce([ + 'submission-1', + 'submission-2', + 'submission-3', + ]); + reviewServiceMockFns.getExistingReviewPairs + .mockResolvedValueOnce(new Set()) + .mockResolvedValueOnce(new Set()); + reviewServiceMockFns.getReviewerSubmissionPairs + .mockResolvedValueOnce( + new Set([ + 'iter-resource:submission-1', + 'iter-resource:submission-2', + ]), + ) + .mockResolvedValueOnce( + new Set([ + 'iter-resource:submission-1', + 'iter-resource:submission-2', + ]), + ); + resourcesServiceMockFns.getReviewerResources.mockResolvedValueOnce([ + { id: 'iter-resource' }, + ]); + mockChallengeApiService.createIterativeReviewPhase.mockResolvedValueOnce( + nextPhase, + ); + + await autopilotService.handleReviewCompleted({ + challengeId, + submissionId: 'submission-2', + reviewId: 'review-2', + scorecardId: 'iter-scorecard', + reviewerResourceId: 'iter-resource', + reviewerHandle: 'iter-reviewer', + reviewerMemberId: 'iter-member', + submitterHandle: 'submitter', + submitterMemberId: 'submitter-id', + completedAt: new Date().toISOString(), + initialScore: 30, + } as ReviewCompletedPayload); + + expect(reviewServiceMockFns.createPendingReview).toHaveBeenCalledWith( + 'submission-3', + 'iter-resource', + nextPhase.id, + 'iter-scorecard', + challengeId, + ); + }); + it('should assign the next submission when another pending review exists for the same reviewer', async () => { const challengeId = 'f2f-challenge-pending'; const iterativePhase = { @@ -1499,7 +1641,7 @@ describe('Autopilot Service (e2e)', () => { ); }); - it('keeps Topgear submission phase open when late and prepares creator post-mortem review', async () => { + it('keeps Topgear submission phase open when late and does not create post-mortem review', async () => { const challengeId = 'topgear-late-challenge'; const submissionPhase = { id: 'topgear-submission-phase-id', @@ -1537,7 +1679,65 @@ describe('Autopilot Service (e2e)', () => { mockChallengeApiService.getChallengeById.mockResolvedValueOnce( topgearChallenge, ); - reviewServiceMockFns.getActiveSubmissionCount.mockResolvedValueOnce(0); + reviewServiceMockFns.getActiveContestSubmissionIds.mockResolvedValueOnce( + [], + ); + + await schedulerService.advancePhase({ + projectId: topgearChallenge.projectId, + challengeId, + phaseId: submissionPhase.id, + phaseTypeName: submissionPhase.name, + state: 'END', + operator: AutopilotOperator.SYSTEM_SCHEDULER, + projectStatus: topgearChallenge.status, + }); + + expect(mockChallengeApiService.advancePhase).not.toHaveBeenCalled(); + expect(reviewServiceMockFns.createPendingReview).not.toHaveBeenCalled(); + }); + + it('keeps Topgear submission phase open when scheduled via system-new-challenge operator', async () => { + const challengeId = 'topgear-late-new-challenge'; + const submissionPhase = { + id: 'topgear-submission-phase-id', + phaseId: 'topgear-template', + name: 'Topgear Submission', + isOpen: true, + scheduledStartDate: mockPastPhaseDate, + scheduledEndDate: mockPastPhaseDate, + actualStartDate: mockPastPhaseDate, + actualEndDate: null, + predecessor: null, + }; + const postMortemPhase = { + id: 'post-mortem-phase-id', + phaseId: 'post-mortem-template', + name: 'Post-Mortem', + isOpen: false, + scheduledStartDate: mockPastPhaseDate, + scheduledEndDate: mockFuturePhaseDate1, + actualStartDate: null, + actualEndDate: null, + predecessor: submissionPhase.phaseId, + }; + const topgearChallenge = { + ...mockChallenge, + id: challengeId, + type: 'Topgear Task', + createdBy: 'creator', + phases: [submissionPhase, postMortemPhase], + }; + + mockChallengeApiService.getPhaseDetails.mockResolvedValueOnce( + submissionPhase, + ); + mockChallengeApiService.getChallengeById.mockResolvedValueOnce( + topgearChallenge, + ); + reviewServiceMockFns.getActiveContestSubmissionIds.mockResolvedValueOnce( + [], + ); resourcesServiceMockFns.getResourceByMemberHandle.mockResolvedValueOnce({ id: 'creator-resource-id', roleName: 'Copilot', @@ -1550,18 +1750,11 @@ describe('Autopilot Service (e2e)', () => { phaseId: submissionPhase.id, phaseTypeName: submissionPhase.name, state: 'END', - operator: AutopilotOperator.SYSTEM_SCHEDULER, + operator: AutopilotOperator.SYSTEM_NEW_CHALLENGE, projectStatus: topgearChallenge.status, }); expect(mockChallengeApiService.advancePhase).not.toHaveBeenCalled(); - expect(reviewServiceMockFns.createPendingReview).toHaveBeenCalledWith( - null, - 'creator-resource-id', - postMortemPhase.id, - 'topgear-post-mortem-scorecard', - challengeId, - ); }); }); });