diff --git a/812522dc-eabb-4875-bc44-ea2ba9b90494.json b/812522dc-eabb-4875-bc44-ea2ba9b90494.json new file mode 100644 index 0000000..12ba41e --- /dev/null +++ b/812522dc-eabb-4875-bc44-ea2ba9b90494.json @@ -0,0 +1,853 @@ + +> 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/Dockerfile b/Dockerfile index 61e9f06..2ff5731 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,4 +23,4 @@ ENV NODE_ENV production COPY --from=build /usr/src/app/dist ./dist COPY --from=deps /usr/src/app/node_modules ./node_modules EXPOSE 3000 -CMD ["node", "dist/main.js"] +CMD ["node", "dist/src/main.js"] diff --git a/eslint.config.mjs b/eslint.config.mjs index caebf6e..91b84da 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'; export default tseslint.config( { - ignores: ['eslint.config.mjs'], + ignores: ['eslint.config.mjs', 'src/autopilot/generated/**/*'], }, eslint.configs.recommended, ...tseslint.configs.recommendedTypeChecked, diff --git a/package.json b/package.json index 741a921..4b3de5b 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "test:e2e": "jest --config ./test/jest-e2e.json", "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" + "prisma:pushautopilot": "prisma db push --schema prisma/autopilot.schema.prisma", + "pull:logs": "ts-node scripts/fetch-autopilot-actions.ts" }, "prisma": { "schema": "prisma/challenge.schema.prisma" diff --git a/prisma/challenge.schema.prisma b/prisma/challenge.schema.prisma index 8d1826e..ef871a4 100644 --- a/prisma/challenge.schema.prisma +++ b/prisma/challenge.schema.prisma @@ -10,10 +10,10 @@ generator client { // Enum for allowed challenge track values (matches app-constants) enum ChallengeTrackEnum { - DEVELOP + DEVELOPMENT DESIGN DATA_SCIENCE - QA + QUALITY_ASSURANCE } enum ReviewTypeEnum { diff --git a/scripts/fetch-autopilot-actions.ts b/scripts/fetch-autopilot-actions.ts new file mode 100644 index 0000000..51956a0 --- /dev/null +++ b/scripts/fetch-autopilot-actions.ts @@ -0,0 +1,70 @@ +import { Prisma, PrismaClient } from '@prisma/client'; + +interface AutopilotActionRecord { + id: string; + challengeId: string | null; + action: string; + status: string; + source: string | null; + details: Prisma.JsonValue | null; + createdAt: Date | string; +} + +async function main(): Promise { + const [, , challengeId] = process.argv; + + if (!challengeId) { + console.error('Usage: ts-node scripts/fetch-autopilot-actions.ts '); + process.exit(1); + } + + const databaseUrl = process.env.AUTOPILOT_DB_URL; + + if (!databaseUrl) { + console.error('AUTOPILOT_DB_URL environment variable is not set.'); + process.exit(1); + } + + const prisma = new PrismaClient({ + datasources: { + db: { + url: databaseUrl, + }, + }, + }); + + try { + const rows = (await prisma.$queryRaw + (Prisma.sql` + SELECT + "id", + "challengeId", + "action", + "status", + "source", + "details", + "createdAt" + FROM "autopilot"."actions" + WHERE "challengeId" = ${challengeId} + ORDER BY "createdAt" ASC + `)) ?? []; + + const normalizedRows = rows.map((row) => ({ + ...row, + createdAt: + row.createdAt instanceof Date + ? row.createdAt.toISOString() + : row.createdAt, + })); + + console.log(JSON.stringify(normalizedRows, null, 2)); + } catch (error) { + const err = error as Error; + console.error(`Failed to fetch autopilot actions: ${err.message}`); + process.exitCode = 1; + } finally { + await prisma.$disconnect(); + } +} + +void main(); diff --git a/src/autopilot/autopilot.module.ts b/src/autopilot/autopilot.module.ts index d4a47e4..bf3e9e3 100644 --- a/src/autopilot/autopilot.module.ts +++ b/src/autopilot/autopilot.module.ts @@ -9,6 +9,9 @@ import { ResourcesModule } from '../resources/resources.module'; import { PhaseReviewService } from './services/phase-review.service'; import { ReviewAssignmentService } from './services/review-assignment.service'; import { ChallengeCompletionService } from './services/challenge-completion.service'; +import { PhaseScheduleManager } from './services/phase-schedule-manager.service'; +import { ResourceEventHandler } from './services/resource-event-handler.service'; +import { First2FinishService } from './services/first2finish.service'; @Module({ imports: [ @@ -23,6 +26,9 @@ import { ChallengeCompletionService } from './services/challenge-completion.serv providers: [ AutopilotService, SchedulerService, + PhaseScheduleManager, + ResourceEventHandler, + First2FinishService, PhaseReviewService, ReviewAssignmentService, ChallengeCompletionService, diff --git a/src/autopilot/constants/challenge.constants.ts b/src/autopilot/constants/challenge.constants.ts new file mode 100644 index 0000000..352defc --- /dev/null +++ b/src/autopilot/constants/challenge.constants.ts @@ -0,0 +1,26 @@ +export const FIRST2FINISH_TYPE = 'first2finish'; +export const TOPGEAR_TASK_TYPE = 'topgear task'; + +export function normalizeChallengeType(type?: string): string { + return (type ?? '').toLowerCase(); +} + +export function isTopgearTaskChallenge(type?: string): boolean { + return normalizeChallengeType(type) === TOPGEAR_TASK_TYPE; +} + +export function isFirst2FinishChallenge(type?: string): boolean { + const normalized = normalizeChallengeType(type); + return normalized === FIRST2FINISH_TYPE || normalized === TOPGEAR_TASK_TYPE; +} + +export function describeChallengeType(type?: string): string { + const normalized = normalizeChallengeType(type); + if (normalized === TOPGEAR_TASK_TYPE) { + return 'Topgear Task'; + } + if (normalized === FIRST2FINISH_TYPE) { + return 'First2Finish'; + } + return type ?? 'Unknown'; +} diff --git a/src/autopilot/constants/review.constants.ts b/src/autopilot/constants/review.constants.ts index a09d0d9..8d93b3c 100644 --- a/src/autopilot/constants/review.constants.ts +++ b/src/autopilot/constants/review.constants.ts @@ -1,12 +1,37 @@ -export const REVIEW_PHASE_NAMES = new Set(['Review', 'Iterative Review']); +export const REVIEW_PHASE_NAMES = new Set([ + 'Review', + 'Iterative Review', + 'Post-Mortem', +]); + +export const ITERATIVE_REVIEW_PHASE_NAME = 'Iterative Review'; +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 SUBMISSION_PHASE_NAMES = new Set([ + SUBMISSION_PHASE_NAME, + TOPGEAR_SUBMISSION_PHASE_NAME, +]); + +export const DEFAULT_APPEALS_PHASE_NAMES = new Set(['Appeals']); +export const DEFAULT_APPEALS_RESPONSE_PHASE_NAMES = new Set([ + 'Appeals Response', +]); const DEFAULT_PHASE_ROLES = ['Reviewer', 'Iterative Reviewer']; export const PHASE_ROLE_MAP: Record = { Review: ['Reviewer'], 'Iterative Review': ['Iterative Reviewer'], + 'Post-Mortem': ['Reviewer', 'Copilot'], }; export function getRoleNamesForPhase(phaseName: string): string[] { return PHASE_ROLE_MAP[phaseName] ?? DEFAULT_PHASE_ROLES; } + +export function isSubmissionPhaseName(phaseName: string): boolean { + return SUBMISSION_PHASE_NAMES.has(phaseName); +} diff --git a/src/autopilot/interfaces/autopilot.interface.ts b/src/autopilot/interfaces/autopilot.interface.ts index 31ecc33..1eb2f65 100644 --- a/src/autopilot/interfaces/autopilot.interface.ts +++ b/src/autopilot/interfaces/autopilot.interface.ts @@ -86,3 +86,54 @@ export interface SubmissionAggregatePayload { originalTopic?: string; [key: string]: unknown; } + +export interface ResourceEventPayload { + id: string; + challengeId: string; + memberId: string; + memberHandle: string; + roleId: string; + created: string; + createdBy: string; +} + +export interface ReviewCompletedPayload { + challengeId: string; + submissionId: string; + reviewId: string; + phaseId: string; + scorecardId: string; + reviewerResourceId: string; + reviewerHandle: string; + reviewerMemberId: string; + submitterHandle: string; + submitterMemberId: string; + completedAt: string; + initialScore: number; +} + +export interface AppealRespondedPayload { + challengeId: string; + submissionId: string; + reviewId: string; + scorecardId: string; + appealId: string; + appealResponseId: string; + reviewerResourceId: string; + reviewerHandle: string; + reviewerMemberId: string; + submitterHandle: string; + submitterMemberId: string; + reviewCompletedAt: string; + finalScore: number; +} + +export interface First2FinishSubmissionPayload { + challengeId: string; + submissionId: string; + memberId: string; + memberHandle: string; + submittedAt: string; +} + +export type TopgearSubmissionPayload = First2FinishSubmissionPayload; diff --git a/src/autopilot/services/autopilot.service.spec.ts b/src/autopilot/services/autopilot.service.spec.ts index e3542c4..b3b744c 100644 --- a/src/autopilot/services/autopilot.service.spec.ts +++ b/src/autopilot/services/autopilot.service.spec.ts @@ -1,80 +1,38 @@ +jest.mock('../../kafka/kafka.service', () => ({ + KafkaService: jest.fn().mockImplementation(() => ({})), +})); + import { AutopilotService } from './autopilot.service'; -import { SchedulerService } from './scheduler.service'; -import { ChallengeApiService } from '../../challenge/challenge-api.service'; -import { PhaseReviewService } from './phase-review.service'; -import { ReviewAssignmentService } from './review-assignment.service'; -import { SubmissionAggregatePayload } from '../interfaces/autopilot.interface'; -import { +import type { + ReviewCompletedPayload, + SubmissionAggregatePayload, +} from '../interfaces/autopilot.interface'; +import { POST_MORTEM_PHASE_NAME } from '../constants/review.constants'; +import type { PhaseScheduleManager } from './phase-schedule-manager.service'; +import type { ResourceEventHandler } from './resource-event-handler.service'; +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 { ConfigService } from '@nestjs/config'; +import type { IChallenge, IPhase, } from '../../challenge/interfaces/challenge.interface'; -describe('AutopilotService - handleSubmissionNotificationAggregate', () => { - const isoNow = new Date().toISOString(); - - const createPhase = (overrides: Partial = {}): IPhase => ({ - id: 'phase-1', - phaseId: 'phase-1', - name: 'Iterative Review', - description: null, - isOpen: false, - duration: 0, - scheduledStartDate: isoNow, - scheduledEndDate: isoNow, - actualStartDate: null, - actualEndDate: null, - predecessor: null, - constraints: [], - ...overrides, - }); +const createMockMethod = any>() => + jest.fn, Parameters>>(); - const createChallenge = ( - overrides: Partial = {}, - ): IChallenge => ({ - id: 'challenge-123', - name: 'Test Challenge', - description: null, - descriptionFormat: 'markdown', - projectId: 123, - typeId: 'type-id', - trackId: 'track-id', - timelineTemplateId: 'template-id', - currentPhaseNames: [], - tags: [], - groups: [], - submissionStartDate: isoNow, - submissionEndDate: isoNow, - registrationStartDate: isoNow, - registrationEndDate: isoNow, - startDate: isoNow, - endDate: null, - legacyId: null, - status: 'ACTIVE', - createdBy: 'tester', - updatedBy: 'tester', - metadata: [], - phases: [createPhase()], - reviewers: [], - winners: [], - discussions: [], - events: [], - prizeSets: [], - terms: [], - skills: [], - attachments: [], - track: 'Development', - type: 'First2Finish', - legacy: {}, - task: {}, - created: isoNow, - updated: isoNow, - overview: {}, - numOfSubmissions: 0, - numOfCheckpointSubmissions: 0, - numOfRegistrants: 0, - ...overrides, - }); +type First2FinishServiceMock = Pick< + jest.Mocked, + | 'handleSubmissionByChallengeId' + | 'handleIterativeReviewerAdded' + | 'handleIterativeReviewCompletion' + | 'isFirst2FinishChallenge' + | 'isChallengeActive' +>; +describe('AutopilotService - handleSubmissionNotificationAggregate', () => { const createPayload = ( overrides: Partial = {}, ): SubmissionAggregatePayload => ({ @@ -85,31 +43,86 @@ describe('AutopilotService - handleSubmissionNotificationAggregate', () => { ...overrides, }); - let schedulerService: Partial; - let challengeApiService: { - getChallengeById: jest.Mock; - advancePhase: jest.Mock; - }; + let phaseScheduleManager: jest.Mocked; + + let resourceEventHandler: jest.Mocked; + + let first2FinishService: First2FinishServiceMock; + let schedulerService: jest.Mocked; + let challengeApiService: jest.Mocked; + let reviewService: jest.Mocked; + let configService: jest.Mocked; let autopilotService: AutopilotService; beforeEach(() => { + phaseScheduleManager = { + schedulePhaseTransition: jest.fn(), + cancelPhaseTransition: jest.fn(), + reschedulePhaseTransition: jest.fn(), + handlePhaseTransition: jest.fn(), + handleNewChallenge: jest.fn(), + handleChallengeUpdate: jest.fn(), + cancelAllPhasesForChallenge: jest.fn(), + processPhaseChain: jest.fn(), + getActiveSchedulesSnapshot: jest.fn().mockReturnValue(new Map()), + } as unknown as jest.Mocked; + + resourceEventHandler = { + handleResourceCreated: jest.fn(), + handleResourceDeleted: jest.fn(), + } as unknown as jest.Mocked; + + const handleSubmissionByChallengeId = + createMockMethod(); + handleSubmissionByChallengeId.mockResolvedValue(undefined); + + const handleIterativeReviewerAdded = + createMockMethod(); + const handleIterativeReviewCompletion = + createMockMethod< + First2FinishService['handleIterativeReviewCompletion'] + >(); + + first2FinishService = { + handleSubmissionByChallengeId, + handleIterativeReviewerAdded, + handleIterativeReviewCompletion, + isFirst2FinishChallenge: jest.fn().mockReturnValue(true), + isChallengeActive: jest.fn().mockReturnValue(true), + } as unknown as First2FinishServiceMock; + schedulerService = { - setPhaseChainCallback: jest.fn(), - schedulePhaseTransition: jest.fn().mockResolvedValue('job-id'), - cancelScheduledTransition: jest.fn().mockResolvedValue(true), - getScheduledTransition: jest.fn(), - }; + getAllScheduledTransitions: jest.fn().mockReturnValue([]), + getAllScheduledTransitionsWithData: jest.fn().mockReturnValue(new Map()), + advancePhase: jest.fn(), + } as unknown as jest.Mocked; challengeApiService = { getChallengeById: jest.fn(), advancePhase: jest.fn(), - }; + getPhaseTypeName: jest.fn(), + } as unknown as jest.Mocked; + + reviewService = { + getReviewById: jest.fn(), + getActiveSubmissionCount: jest.fn(), + getCompletedReviewCountForPhase: jest.fn(), + getScorecardPassingScore: jest.fn(), + getPendingReviewCount: jest.fn(), + } as unknown as jest.Mocked; + + configService = { + get: jest.fn().mockReturnValue(undefined), + } as unknown as jest.Mocked; autopilotService = new AutopilotService( - schedulerService as SchedulerService, - challengeApiService as unknown as ChallengeApiService, - {} as PhaseReviewService, - {} as ReviewAssignmentService, + phaseScheduleManager, + resourceEventHandler, + first2FinishService as unknown as First2FinishService, + schedulerService, + challengeApiService, + reviewService, + configService, ); jest.clearAllMocks(); @@ -120,8 +133,9 @@ describe('AutopilotService - handleSubmissionNotificationAggregate', () => { createPayload({ originalTopic: 'submission.notification.update' }), ); - expect(challengeApiService.getChallengeById).not.toHaveBeenCalled(); - expect(challengeApiService.advancePhase).not.toHaveBeenCalled(); + expect( + first2FinishService.handleSubmissionByChallengeId, + ).not.toHaveBeenCalled(); }); it('ignores messages without a v5 challenge id', async () => { @@ -129,69 +143,373 @@ describe('AutopilotService - handleSubmissionNotificationAggregate', () => { createPayload({ v5ChallengeId: undefined }), ); - expect(challengeApiService.getChallengeById).not.toHaveBeenCalled(); + expect( + first2FinishService.handleSubmissionByChallengeId, + ).not.toHaveBeenCalled(); }); - it('opens iterative review phase for First2Finish challenge', async () => { - const iterativeReviewPhase = createPhase({ id: 'iterative-phase' }); - challengeApiService.getChallengeById.mockResolvedValue( - createChallenge({ phases: [iterativeReviewPhase] }), - ); - challengeApiService.advancePhase.mockResolvedValue({ - success: true, - message: 'opened', - }); - + it('delegates to First2FinishService when payload is valid', async () => { await autopilotService.handleSubmissionNotificationAggregate( createPayload({ id: 'submission-123' }), ); - expect(challengeApiService.getChallengeById).toHaveBeenCalledWith( - 'challenge-123', - ); - expect(challengeApiService.advancePhase).toHaveBeenCalledWith( - 'challenge-123', - 'iterative-phase', - 'open', - ); + expect( + first2FinishService.handleSubmissionByChallengeId, + ).toHaveBeenCalledWith('challenge-123', 'submission-123'); }); + describe('handleReviewCompleted (review phase)', () => { + const buildReviewPhase = (): IPhase => ({ + id: 'phase-review', + phaseId: 'template-review', + name: 'Review', + description: null, + isOpen: true, + duration: 7200, + scheduledStartDate: new Date().toISOString(), + scheduledEndDate: new Date(Date.now() + 2 * 3600 * 1000).toISOString(), + actualStartDate: new Date().toISOString(), + actualEndDate: null, + predecessor: null, + constraints: [], + }); - it('skips non-First2Finish challenges', async () => { - challengeApiService.getChallengeById.mockResolvedValue( - createChallenge({ type: 'Design Challenge' }), - ); + const buildAppealsPhase = (): IPhase => ({ + id: 'phase-appeals', + phaseId: 'template-appeals', + name: 'Appeals', + description: null, + isOpen: false, + duration: 3600, + scheduledStartDate: new Date(Date.now() + 2 * 3600 * 1000).toISOString(), + scheduledEndDate: new Date(Date.now() + 3 * 3600 * 1000).toISOString(), + actualStartDate: null, + actualEndDate: null, + predecessor: 'template-review', + constraints: [], + }); - await autopilotService.handleSubmissionNotificationAggregate( - createPayload(), - ); + const buildReviewChallenge = ( + reviewPhase: IPhase = buildReviewPhase(), + ): IChallenge => ({ + id: 'challenge-1', + name: 'Test Challenge', + description: null, + descriptionFormat: 'markdown', + projectId: 1001, + typeId: 'type-1', + trackId: 'track-1', + timelineTemplateId: 'timeline-1', + currentPhaseNames: [reviewPhase.name], + 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: 'tester', + updatedBy: 'tester', + metadata: [], + phases: [reviewPhase, buildAppealsPhase()], + reviewers: [ + { + id: 'reviewer-config', + scorecardId: 'scorecard-1', + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'template-review', + basePayment: null, + incrementalPayment: null, + type: null, + aiWorkflowId: null, + }, + ], + winners: [], + discussions: [], + events: [], + prizeSets: [], + terms: [], + skills: [], + attachments: [], + track: 'DEVELOP', + type: 'Standard', + legacy: {}, + task: {}, + created: new Date().toISOString(), + updated: new Date().toISOString(), + overview: {}, + numOfSubmissions: 2, + numOfCheckpointSubmissions: 0, + numOfRegistrants: 0, + }); - expect(challengeApiService.advancePhase).not.toHaveBeenCalled(); - }); + const buildPayload = ( + overrides: Partial = {}, + ): ReviewCompletedPayload => ({ + challengeId: 'challenge-1', + reviewId: 'review-1', + submissionId: 'sub-1', + phaseId: 'phase-review', + scorecardId: 'scorecard-1', + reviewerResourceId: 'resource-1', + reviewerHandle: 'reviewer', + reviewerMemberId: '1', + submitterHandle: 'submitter', + submitterMemberId: '2', + completedAt: new Date().toISOString(), + initialScore: 90, + ...overrides, + }); - it('skips when iterative review phase is already open', async () => { - const iterativeReviewPhase = createPhase({ isOpen: true }); - challengeApiService.getChallengeById.mockResolvedValue( - createChallenge({ phases: [iterativeReviewPhase] }), - ); + beforeEach(() => { + reviewService.getPendingReviewCount.mockResolvedValue(0); + }); - await autopilotService.handleSubmissionNotificationAggregate( - createPayload(), - ); + it('does not close the review phase while reviews are still pending', async () => { + const reviewPhase = buildReviewPhase(); + challengeApiService.getChallengeById.mockResolvedValue( + buildReviewChallenge(reviewPhase), + ); + + reviewService.getReviewById.mockResolvedValue({ + id: 'review-1', + phaseId: reviewPhase.id, + resourceId: 'resource-1', + submissionId: 'sub-1', + scorecardId: 'scorecard-1', + score: null, + status: 'COMPLETED', + }); + reviewService.getPendingReviewCount.mockResolvedValueOnce(1); - expect(challengeApiService.advancePhase).not.toHaveBeenCalled(); + await autopilotService.handleReviewCompleted(buildPayload()); + + expect(reviewService.getPendingReviewCount).toHaveBeenCalledWith( + reviewPhase.id, + 'challenge-1', + ); + expect(schedulerService.advancePhase).not.toHaveBeenCalled(); + }); + + it('closes the review phase once all reviews are completed', async () => { + const reviewPhase = buildReviewPhase(); + challengeApiService.getChallengeById.mockResolvedValue( + buildReviewChallenge(reviewPhase), + ); + + reviewService.getReviewById.mockResolvedValue({ + id: 'review-1', + phaseId: reviewPhase.id, + resourceId: 'resource-1', + submissionId: 'sub-1', + scorecardId: 'scorecard-1', + score: null, + status: 'COMPLETED', + }); + + await autopilotService.handleReviewCompleted(buildPayload()); + + expect(reviewService.getPendingReviewCount).toHaveBeenCalledWith( + reviewPhase.id, + 'challenge-1', + ); + expect(schedulerService.advancePhase).toHaveBeenCalledWith( + expect.objectContaining({ + challengeId: 'challenge-1', + phaseId: reviewPhase.id, + phaseTypeName: reviewPhase.name, + state: 'END', + }), + ); + }); + + it('falls back to payload phase id when review record lacks a phase reference', async () => { + const reviewPhase = buildReviewPhase(); + challengeApiService.getChallengeById.mockResolvedValue( + buildReviewChallenge(reviewPhase), + ); + + reviewService.getReviewById.mockResolvedValue({ + id: 'review-1', + phaseId: null, + resourceId: 'resource-1', + submissionId: 'sub-1', + scorecardId: 'scorecard-1', + score: null, + status: 'COMPLETED', + }); + + await autopilotService.handleReviewCompleted( + buildPayload({ phaseId: reviewPhase.id }), + ); + + expect(reviewService.getPendingReviewCount).toHaveBeenCalledWith( + reviewPhase.id, + 'challenge-1', + ); + expect(schedulerService.advancePhase).toHaveBeenCalledWith( + expect.objectContaining({ + challengeId: 'challenge-1', + phaseId: reviewPhase.id, + }), + ); + }); + + it('matches challenge phase on template id when review references phase template', async () => { + const reviewPhase = buildReviewPhase(); + challengeApiService.getChallengeById.mockResolvedValue( + buildReviewChallenge(reviewPhase), + ); + + reviewService.getReviewById.mockResolvedValue({ + id: 'review-1', + phaseId: reviewPhase.phaseId, + resourceId: 'resource-1', + submissionId: 'sub-1', + scorecardId: 'scorecard-1', + score: null, + status: 'COMPLETED', + }); + + await autopilotService.handleReviewCompleted( + buildPayload({ phaseId: 'unrelated-phase-id' }), + ); + + expect(reviewService.getPendingReviewCount).toHaveBeenCalledWith( + reviewPhase.id, + 'challenge-1', + ); + expect(schedulerService.advancePhase).toHaveBeenCalledWith( + expect.objectContaining({ + challengeId: 'challenge-1', + phaseId: reviewPhase.id, + }), + ); + }); }); - it('skips when iterative review phase is not present', async () => { - challengeApiService.getChallengeById.mockResolvedValue( - createChallenge({ - phases: [createPhase({ name: 'Submission', id: 'submission-phase' })], - }), - ); + describe('handleReviewCompleted (post-mortem)', () => { + const buildPhase = (): IPhase => ({ + id: 'phase-1', + phaseId: 'template-1', + name: POST_MORTEM_PHASE_NAME, + description: null, + isOpen: true, + duration: 0, + scheduledStartDate: new Date().toISOString(), + scheduledEndDate: new Date(Date.now() + 3600 * 1000).toISOString(), + actualStartDate: new Date().toISOString(), + actualEndDate: null, + predecessor: null, + constraints: [], + }); - await autopilotService.handleSubmissionNotificationAggregate( - createPayload(), - ); + const buildChallenge = (phase: IPhase = buildPhase()): IChallenge => ({ + id: 'challenge-1', + name: 'Test Challenge', + description: null, + descriptionFormat: 'markdown', + projectId: 1001, + 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: 'tester', + updatedBy: 'tester', + metadata: [], + phases: [phase], + reviewers: [], + winners: [], + discussions: [], + events: [], + prizeSets: [], + terms: [], + skills: [], + attachments: [], + track: 'DEVELOP', + type: 'First2Finish', + legacy: {}, + task: {}, + created: new Date().toISOString(), + updated: new Date().toISOString(), + overview: {}, + numOfSubmissions: 0, + numOfCheckpointSubmissions: 0, + numOfRegistrants: 0, + }); - expect(challengeApiService.advancePhase).not.toHaveBeenCalled(); + const buildPayload = (): ReviewCompletedPayload => ({ + challengeId: 'challenge-1', + reviewId: 'review-1', + submissionId: 'submission-1', + phaseId: 'phase-1', + scorecardId: 'scorecard-1', + reviewerResourceId: 'resource-1', + reviewerHandle: 'handle', + reviewerMemberId: '1', + submitterHandle: 'submitter', + submitterMemberId: '2', + completedAt: new Date().toISOString(), + initialScore: 0, + }); + + beforeEach(() => { + const completedReview = { + id: 'review-1', + phaseId: 'phase-1', + resourceId: 'resource-1', + submissionId: null, + scorecardId: 'scorecard-1', + score: null, + status: 'COMPLETED', + } satisfies Exclude< + Awaited>, + null + >; + + reviewService.getReviewById.mockResolvedValue(completedReview); + + challengeApiService.getChallengeById.mockResolvedValue(buildChallenge()); + reviewService.getPendingReviewCount.mockResolvedValue(0); + }); + + it('does not close the phase when post-mortem reviews are still pending', async () => { + reviewService.getPendingReviewCount.mockResolvedValueOnce(2); + + await autopilotService.handleReviewCompleted(buildPayload()); + + // eslint-disable-next-line @typescript-eslint/unbound-method + const advancePhaseMock = schedulerService.advancePhase as jest.Mock; + expect(advancePhaseMock).not.toHaveBeenCalled(); + }); + + it('closes the post-mortem phase when all reviews are complete', async () => { + await autopilotService.handleReviewCompleted(buildPayload()); + + // eslint-disable-next-line @typescript-eslint/unbound-method + const advancePhaseMock = schedulerService.advancePhase as jest.Mock; + + expect(advancePhaseMock).toHaveBeenCalledWith( + expect.objectContaining({ + challengeId: 'challenge-1', + phaseId: 'phase-1', + phaseTypeName: POST_MORTEM_PHASE_NAME, + state: 'END', + }), + ); + }); }); }); diff --git a/src/autopilot/services/autopilot.service.ts b/src/autopilot/services/autopilot.service.ts index 43b28c1..1a6c62e 100644 --- a/src/autopilot/services/autopilot.service.ts +++ b/src/autopilot/services/autopilot.service.ts @@ -1,438 +1,290 @@ import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { SchedulerService } from './scheduler.service'; -import { PhaseReviewService } from './phase-review.service'; -import { ReviewAssignmentService } from './review-assignment.service'; +import { PhaseScheduleManager } from './phase-schedule-manager.service'; +import { ResourceEventHandler } from './resource-event-handler.service'; +import { First2FinishService } from './first2finish.service'; import { PhaseTransitionPayload, ChallengeUpdatePayload, CommandPayload, AutopilotOperator, SubmissionAggregatePayload, + ResourceEventPayload, + ReviewCompletedPayload, + AppealRespondedPayload, + First2FinishSubmissionPayload, + TopgearSubmissionPayload, } from '../interfaces/autopilot.interface'; import { ChallengeApiService } from '../../challenge/challenge-api.service'; import { IPhase } from '../../challenge/interfaces/challenge.interface'; import { AUTOPILOT_COMMANDS } from '../../common/constants/commands.constants'; -import { REVIEW_PHASE_NAMES } from '../constants/review.constants'; - +import { + DEFAULT_APPEALS_PHASE_NAMES, + DEFAULT_APPEALS_RESPONSE_PHASE_NAMES, + ITERATIVE_REVIEW_PHASE_NAME, + POST_MORTEM_PHASE_NAME, + REVIEW_PHASE_NAMES, +} from '../constants/review.constants'; +import { ReviewService } from '../../review/review.service'; +import { + getNormalizedStringArray, + isActiveStatus, +} from '../utils/config.utils'; const SUBMISSION_NOTIFICATION_CREATE_TOPIC = 'submission.notification.create'; -const ITERATIVE_REVIEW_PHASE_NAME = 'Iterative Review'; -const FIRST2FINISH_TYPE = 'first2finish'; @Injectable() export class AutopilotService { private readonly logger = new Logger(AutopilotService.name); - private activeSchedules = new Map(); + private readonly appealsPhaseNames: Set; + private readonly appealsResponsePhaseNames: Set; constructor( + private readonly phaseScheduleManager: PhaseScheduleManager, + private readonly resourceEventHandler: ResourceEventHandler, + private readonly first2FinishService: First2FinishService, private readonly schedulerService: SchedulerService, private readonly challengeApiService: ChallengeApiService, - private readonly phaseReviewService: PhaseReviewService, - private readonly reviewAssignmentService: ReviewAssignmentService, + private readonly reviewService: ReviewService, + private readonly configService: ConfigService, ) { - // Set up the phase chain callback to handle next phase opening and scheduling - this.schedulerService.setPhaseChainCallback( - ( - challengeId: string, - projectId: number, - projectStatus: string, - nextPhases: IPhase[], - ) => { - void this.openAndScheduleNextPhases( - challengeId, - projectId, - projectStatus, - nextPhases, - ); - }, + this.appealsPhaseNames = new Set( + getNormalizedStringArray( + this.configService.get('autopilot.appealsPhaseNames'), + [...Array.from(DEFAULT_APPEALS_PHASE_NAMES)], + ), + ); + this.appealsResponsePhaseNames = new Set( + getNormalizedStringArray( + this.configService.get('autopilot.appealsResponsePhaseNames'), + [...Array.from(DEFAULT_APPEALS_RESPONSE_PHASE_NAMES)], + ), ); - } - - private isChallengeActive(status?: string): boolean { - return (status ?? '').toUpperCase() === 'ACTIVE'; - } - - private isFirst2FinishChallenge(type?: string): boolean { - return (type ?? '').toLowerCase() === FIRST2FINISH_TYPE; } async schedulePhaseTransition( phaseData: PhaseTransitionPayload, ): Promise { - try { - const phaseKey = `${phaseData.challengeId}:${phaseData.phaseId}`; - - const existingJobId = this.activeSchedules.get(phaseKey); - if (existingJobId) { - this.logger.log( - `Canceling existing schedule for phase ${phaseKey} before rescheduling.`, - ); - const canceled = - await this.schedulerService.cancelScheduledTransition(existingJobId); - if (!canceled) { - this.logger.warn( - `Failed to cancel existing schedule ${existingJobId} for phase ${phaseKey}`, - ); - } - this.activeSchedules.delete(phaseKey); - } - - const jobId = - await this.schedulerService.schedulePhaseTransition(phaseData); - this.activeSchedules.set(phaseKey, jobId); - - this.logger.log( - `Scheduled phase transition for challenge ${phaseData.challengeId}, phase ${phaseData.phaseId} at ${phaseData.date}`, - ); - return jobId; - } catch (error) { - const err = error as Error; - this.logger.error( - `Failed to schedule phase transition: ${err.message}`, - err.stack, - ); - throw error; - } + return this.phaseScheduleManager.schedulePhaseTransition(phaseData); } async cancelPhaseTransition( challengeId: string, phaseId: string, ): Promise { - const phaseKey = `${challengeId}:${phaseId}`; - const jobId = this.activeSchedules.get(phaseKey); - - if (!jobId) { - this.logger.warn(`No active schedule found for phase ${phaseKey}`); - return false; - } - - const canceled = - await this.schedulerService.cancelScheduledTransition(jobId); - if (canceled) { - this.activeSchedules.delete(phaseKey); - this.logger.log(`Canceled scheduled transition for phase ${phaseKey}`); - return true; - } - - this.logger.warn( - `Unable to cancel scheduled transition for phase ${phaseKey}; job may have already executed. Removing stale reference.`, + return this.phaseScheduleManager.cancelPhaseTransition( + challengeId, + phaseId, ); - this.activeSchedules.delete(phaseKey); - return false; } async reschedulePhaseTransition( challengeId: string, newPhaseData: PhaseTransitionPayload, ): Promise { - const phaseKey = `${challengeId}:${newPhaseData.phaseId}`; - const existingJobId = this.activeSchedules.get(phaseKey); - let wasRescheduled = false; - - if (existingJobId) { - const scheduledJob = - this.schedulerService.getScheduledTransition(existingJobId); + return this.phaseScheduleManager.reschedulePhaseTransition( + challengeId, + newPhaseData, + ); + } - if (!scheduledJob) { - this.logger.warn( - `No scheduled job found for phase ${phaseKey}, but it was in the active map. Scheduling new job.`, - ); - } else if (scheduledJob.date && newPhaseData.date) { - const existingTime = new Date(scheduledJob.date).getTime(); - const newTime = new Date(newPhaseData.date).getTime(); + handlePhaseTransition(message: PhaseTransitionPayload): void { + void this.phaseScheduleManager.handlePhaseTransition(message); + } - if (existingTime === newTime) { - this.logger.log( - `No change detected for phase ${phaseKey}, skipping reschedule.`, - ); - return existingJobId; - } + async handleNewChallenge(challenge: ChallengeUpdatePayload): Promise { + await this.phaseScheduleManager.handleNewChallenge(challenge); + } - this.logger.log( - `Detected change in end time for phase ${phaseKey}, rescheduling.`, - ); - wasRescheduled = true; - } - } + async handleChallengeUpdate(message: ChallengeUpdatePayload): Promise { + await this.phaseScheduleManager.handleChallengeUpdate(message); + } - const newJobId = await this.schedulePhaseTransition(newPhaseData); + async handleSubmissionNotificationAggregate( + payload: SubmissionAggregatePayload, + ): Promise { + const { id: submissionId } = payload; + const challengeId = payload.v5ChallengeId; - if (wasRescheduled) { - this.logger.log( - `Successfully rescheduled phase ${newPhaseData.phaseId} with new end time: ${newPhaseData.date}`, + if (payload.originalTopic !== SUBMISSION_NOTIFICATION_CREATE_TOPIC) { + this.logger.debug( + 'Ignoring submission aggregate message with non-create original topic', + { + submissionId, + originalTopic: payload.originalTopic, + }, ); + return; } - return newJobId; - } - - handlePhaseTransition(message: PhaseTransitionPayload): void { - this.logger.log( - `Consumed phase transition event: ${JSON.stringify(message)}`, - ); - - if (!this.isChallengeActive(message.projectStatus)) { - this.logger.log( - `Ignoring phase transition for challenge ${message.challengeId} with status ${message.projectStatus}; only ACTIVE challenges are processed.`, + if (!challengeId) { + this.logger.warn( + 'Submission aggregate message missing v5ChallengeId; unable to process', + { submissionId }, ); return; } - if (message.state === 'START') { - // Advance the phase (open it) using the scheduler service - void (async () => { - try { - await this.schedulerService.advancePhase(message); - this.logger.log( - `Successfully processed START event for phase ${message.phaseId} (challenge ${message.challengeId})`, - ); - } catch (error) { - const err = error as Error; - this.logger.error( - `Failed to advance phase ${message.phaseId} for challenge ${message.challengeId}: ${err.message}`, - err.stack, - ); - } - })(); - } else if (message.state === 'END') { - // Advance the phase (close it) using the scheduler service - void (async () => { - try { - await this.schedulerService.advancePhase(message); - this.logger.log( - `Successfully processed END event for phase ${message.phaseId} (challenge ${message.challengeId})`, - ); - - // Clean up the scheduled job after closing the phase - const canceled = await this.cancelPhaseTransition( - message.challengeId, - message.phaseId, - ); - if (canceled) { - this.logger.log( - `Cleaned up job for phase ${message.phaseId} (challenge ${message.challengeId}) from registry after consuming event.`, - ); - } - } catch (error) { - const err = error as Error; - this.logger.error( - `Failed to advance phase ${message.phaseId} for challenge ${message.challengeId}: ${err.message}`, - err.stack, - ); - } - })(); + try { + await this.first2FinishService.handleSubmissionByChallengeId( + challengeId, + submissionId, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed processing submission aggregate for challenge ${challengeId}: ${err.message}`, + err.stack, + ); } } - async handleNewChallenge(challenge: ChallengeUpdatePayload): Promise { - this.logger.log( - `Handling new challenge creation: ${JSON.stringify(challenge)}`, - ); - try { - // Refactored: Use getChallengeById as required - const challengeDetails = await this.challengeApiService.getChallengeById( - challenge.id, - ); + async handleResourceCreated(payload: ResourceEventPayload): Promise { + await this.resourceEventHandler.handleResourceCreated(payload); + } - if (!this.isChallengeActive(challengeDetails.status)) { - this.logger.log( - `Skipping challenge ${challenge.id} with status ${challengeDetails.status}; only ACTIVE challenges are processed.`, - ); - return; - } + async handleResourceDeleted(payload: ResourceEventPayload): Promise { + await this.resourceEventHandler.handleResourceDeleted(payload); + } + + async handleReviewCompleted(payload: ReviewCompletedPayload): Promise { + const { challengeId, reviewId } = payload; - if (!challengeDetails.phases) { + try { + const review = await this.reviewService.getReviewById(reviewId); + if (!review) { this.logger.warn( - `Challenge ${challenge.id} has no phases to schedule.`, + `Review ${reviewId} not found when handling completion for challenge ${challengeId}.`, ); return; } - // Find the phases that should be scheduled (similar logic to PhaseAdvancer) - const phasesToSchedule = this.findPhasesToSchedule( - challengeDetails.phases, - ); + const challenge = + await this.challengeApiService.getChallengeById(challengeId); - if (phasesToSchedule.length === 0) { - this.logger.log( - `No phase needs to be scheduled for new challenge ${challenge.id}`, - ); + if (!isActiveStatus(challenge.status)) { return; } - const now = new Date(); - const scheduledSummaries: string[] = []; - - for (const nextPhase of phasesToSchedule) { - const shouldOpen = - !nextPhase.isOpen && - !nextPhase.actualEndDate && - nextPhase.scheduledStartDate && - new Date(nextPhase.scheduledStartDate) <= now; - - const scheduleDate = shouldOpen - ? nextPhase.scheduledStartDate - : nextPhase.scheduledEndDate; - const state = shouldOpen ? 'START' : 'END'; - - if (!scheduleDate) { - this.logger.warn( - `Next phase ${nextPhase.id} for new challenge ${challenge.id} has no scheduled ${shouldOpen ? 'start' : 'end'} date. Skipping.`, - ); - continue; - } + const candidatePhaseIds = new Set(); + if (review.phaseId) { + candidatePhaseIds.add(review.phaseId); + } + if (payload.phaseId) { + candidatePhaseIds.add(payload.phaseId); + } - const phaseData: PhaseTransitionPayload = { - projectId: challengeDetails.projectId, - challengeId: challengeDetails.id, - phaseId: nextPhase.id, - phaseTypeName: nextPhase.name, - state, - operator: AutopilotOperator.SYSTEM_NEW_CHALLENGE, - projectStatus: challengeDetails.status, - date: scheduleDate, - }; - - await this.schedulePhaseTransition(phaseData); - scheduledSummaries.push( - `${nextPhase.name} (${nextPhase.id}) -> ${state} @ ${scheduleDate}`, + if (!candidatePhaseIds.size) { + this.logger.warn( + `Review ${reviewId} does not provide a phase reference and payload is missing phaseId for challenge ${challengeId}.`, ); + return; } - if (scheduledSummaries.length === 0) { + const phase = challenge.phases.find((phaseCandidate) => { + if (candidatePhaseIds.has(phaseCandidate.id)) { + return true; + } + if (phaseCandidate.phaseId) { + return candidatePhaseIds.has(phaseCandidate.phaseId); + } + return false; + }); + if (!phase) { this.logger.warn( - `Unable to schedule any phases for new challenge ${challenge.id} due to missing schedule data.`, + `Unable to resolve phase for review ${reviewId} on challenge ${challengeId}. Candidates: ${Array.from(candidatePhaseIds).join(', ')}.`, ); return; } - this.logger.log( - `Scheduled ${scheduledSummaries.length} phase(s) for new challenge ${challenge.id}: ${scheduledSummaries.join('; ')}`, - ); - } catch (error) { - const err = error as Error; - this.logger.error( - `Error handling new challenge creation for id ${challenge.id}: ${err.message}`, - err.stack, - ); - } - } + if (!phase.isOpen) { + this.logger.debug( + `Phase ${phase.id} already closed for challenge ${challengeId}; ignoring review completion event.`, + ); + return; + } - async handleChallengeUpdate(message: ChallengeUpdatePayload): Promise { - this.logger.log(`Handling challenge update: ${JSON.stringify(message)}`); + if (phase.name === POST_MORTEM_PHASE_NAME) { + const pendingPostMortemReviews = + await this.reviewService.getPendingReviewCount(phase.id, challengeId); - try { - // Refactored: Use getChallengeById as required - const challengeDetails = await this.challengeApiService.getChallengeById( - message.id, - ); + if (pendingPostMortemReviews > 0) { + this.logger.debug( + `Post-mortem phase ${phase.id} on challenge ${challengeId} still has ${pendingPostMortemReviews} pending review(s).`, + ); + return; + } - if (!this.isChallengeActive(challengeDetails.status)) { this.logger.log( - `Skipping challenge ${message.id} update with status ${challengeDetails.status}; only ACTIVE challenges are processed.`, + `All post-mortem reviews submitted for phase ${phase.id} on challenge ${challengeId}. Closing 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, + }); return; } - if (!challengeDetails.phases) { - this.logger.warn( - `Updated challenge ${message.id} has no phases to process.`, + if (phase.name === ITERATIVE_REVIEW_PHASE_NAME) { + await this.first2FinishService.handleIterativeReviewCompletion( + challenge, + phase, + review, + payload, ); return; } - const phasesToSchedule = this.findPhasesToSchedule( - challengeDetails.phases, - ); - - if (phasesToSchedule.length === 0) { - this.logger.log( - `No phase needs to be rescheduled for updated challenge ${message.id}`, - ); + if (!REVIEW_PHASE_NAMES.has(phase.name)) { return; } - const now = new Date(); - const rescheduledSummaries: string[] = []; - - for (const nextPhase of phasesToSchedule) { - const shouldOpen = - !nextPhase.isOpen && - !nextPhase.actualEndDate && - nextPhase.scheduledStartDate && - new Date(nextPhase.scheduledStartDate) <= now; - - const scheduleDate = shouldOpen - ? nextPhase.scheduledStartDate - : nextPhase.scheduledEndDate; - const state = shouldOpen ? 'START' : 'END'; - - if (!scheduleDate) { - this.logger.warn( - `Next phase ${nextPhase.id} for updated challenge ${message.id} has no scheduled ${shouldOpen ? 'start' : 'end'} date. Skipping.`, - ); - continue; - } - - const payload: PhaseTransitionPayload = { - projectId: challengeDetails.projectId, - challengeId: challengeDetails.id, - phaseId: nextPhase.id, - phaseTypeName: nextPhase.name, - operator: message.operator, - projectStatus: challengeDetails.status, - date: scheduleDate, - state, - }; - - await this.reschedulePhaseTransition(challengeDetails.id, payload); - rescheduledSummaries.push( - `${nextPhase.name} (${nextPhase.id}) -> ${state} @ ${scheduleDate}`, - ); - } + const pendingReviews = await this.reviewService.getPendingReviewCount( + phase.id, + challengeId, + ); - if (rescheduledSummaries.length === 0) { - this.logger.warn( - `Unable to reschedule any phases for updated challenge ${message.id} due to missing schedule data.`, + if (pendingReviews > 0) { + this.logger.debug( + `Review phase ${phase.id} for challenge ${challengeId} still has ${pendingReviews} review(s) in progress.`, ); return; } this.logger.log( - `Rescheduled ${rescheduledSummaries.length} phase(s) for updated challenge ${message.id}: ${rescheduledSummaries.join('; ')}`, + `All reviews completed for phase ${phase.id} on challenge ${challengeId}. Closing Review phase early.`, ); + + await this.schedulerService.advancePhase({ + projectId: challenge.projectId, + challengeId: challenge.id, + phaseId: phase.id, + phaseTypeName: phase.name, + state: 'END', + operator: AutopilotOperator.SYSTEM, + projectStatus: challenge.status, + }); } catch (error) { const err = error as Error; this.logger.error( - `Error handling challenge update: ${err.message}`, + `Failed to handle review completion for challenge ${challengeId}: ${err.message}`, err.stack, ); } } - async handleSubmissionNotificationAggregate( - payload: SubmissionAggregatePayload, - ): Promise { - const { id: submissionId } = payload; - const challengeId = payload.v5ChallengeId; - - if (payload.originalTopic !== SUBMISSION_NOTIFICATION_CREATE_TOPIC) { - this.logger.debug( - 'Ignoring submission aggregate message with non-create original topic', - { - submissionId, - originalTopic: payload.originalTopic, - }, - ); - return; - } + async handleAppealResponded(payload: AppealRespondedPayload): Promise { + const { challengeId } = payload; if (!challengeId) { - this.logger.warn( - 'Submission aggregate message missing v5ChallengeId; unable to process', - { submissionId }, - ); + this.logger.warn('Appeal responded event missing challengeId.', payload); return; } @@ -440,71 +292,91 @@ export class AutopilotService { const challenge = await this.challengeApiService.getChallengeById(challengeId); - if (!this.isFirst2FinishChallenge(challenge.type)) { + if (!isActiveStatus(challenge.status)) { this.logger.debug( - 'Skipping submission aggregate for non-First2Finish challenge', - { - submissionId, - challengeId, - challengeType: challenge.type, - }, + `Skipping appeal responded handling for challenge ${challengeId} with status ${challenge.status}.`, ); return; } - const iterativeReviewPhase = challenge.phases?.find( - (phase) => phase.name === ITERATIVE_REVIEW_PHASE_NAME, - ); + const pendingAppeals = + await this.reviewService.getPendingAppealCount(challengeId); - if (!iterativeReviewPhase) { - this.logger.warn( - 'No Iterative Review phase found for First2Finish challenge', - { submissionId, challengeId }, + if (pendingAppeals > 0) { + this.logger.debug( + `Appeal responded processed for challenge ${challengeId}, but ${pendingAppeals} appeal(s) still pending response.`, ); return; } - if (iterativeReviewPhase.isOpen) { + const phasesToClose = + challenge.phases?.filter( + (phase) => + phase.isOpen && + (this.appealsResponsePhaseNames.has(phase.name) || + this.appealsPhaseNames.has(phase.name)), + ) ?? []; + + if (!phasesToClose.length) { this.logger.debug( - 'Iterative Review phase already open; skipping advance', - { - submissionId, - challengeId, - phaseId: iterativeReviewPhase.id, - }, + `No open appeals or appeals response phases to close for challenge ${challengeId}.`, ); return; } - this.logger.log( - `Opening Iterative Review phase ${iterativeReviewPhase.id} for challenge ${challengeId} in response to submission ${submissionId}.`, + for (const phase of phasesToClose) { + try { + await this.schedulerService.advancePhase({ + projectId: challenge.projectId, + challengeId: challenge.id, + phaseId: phase.id, + phaseTypeName: phase.name, + state: 'END', + operator: AutopilotOperator.SYSTEM, + projectStatus: challenge.status, + }); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to close phase ${phase.id} (${phase.name}) on challenge ${challengeId} after appeals resolved: ${err.message}`, + err.stack, + ); + } + } + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to handle appeal responded event for challenge ${challengeId}: ${err.message}`, + err.stack, ); + } + } - const advanceResult = await this.challengeApiService.advancePhase( - challenge.id, - iterativeReviewPhase.id, - 'open', - ); + async handleFirst2FinishSubmission( + payload: First2FinishSubmissionPayload, + ): Promise { + await this.handleRapidSubmission(payload, 'First2Finish'); + } - if (!advanceResult.success) { - this.logger.warn( - 'Advance phase operation reported failure for Iterative Review phase', - { - submissionId, - challengeId, - phaseId: iterativeReviewPhase.id, - message: advanceResult.message, - }, - ); - } else { - this.logger.log( - `Iterative Review phase ${iterativeReviewPhase.id} opened for challenge ${challengeId}.`, - ); - } + async handleTopgearSubmission( + payload: TopgearSubmissionPayload, + ): Promise { + await this.handleRapidSubmission(payload, 'Topgear Task'); + } + + private async handleRapidSubmission( + payload: First2FinishSubmissionPayload, + challengeLabel: string, + ): Promise { + try { + await this.first2FinishService.handleSubmissionByChallengeId( + payload.challengeId, + payload.submissionId, + ); } catch (error) { const err = error as Error; this.logger.error( - `Failed processing submission aggregate for challenge ${challengeId}: ${err.message}`, + `Failed to handle ${challengeLabel} submission for challenge ${payload.challengeId}: ${err.message}`, err.stack, ); } @@ -533,7 +405,10 @@ export class AutopilotService { ); return; } - await this.handleSinglePhaseCancellation(challengeId, phaseId); + await this.phaseScheduleManager.cancelPhaseTransition( + challengeId, + phaseId, + ); } else { const challengeId = message.challengeId; if (!challengeId) { @@ -542,7 +417,9 @@ export class AutopilotService { ); return; } - await this.cancelAllPhasesForChallenge(challengeId); + await this.phaseScheduleManager.cancelAllPhasesForChallenge( + challengeId, + ); } break; @@ -555,54 +432,12 @@ export class AutopilotService { return; } - void (async () => { - try { - const challengeDetails = - await this.challengeApiService.getChallengeById(challengeId); - - if (!challengeDetails) { - this.logger.error( - `Could not find challenge with ID ${challengeId} to reschedule.`, - ); - return; - } - - if (!this.isChallengeActive(challengeDetails.status)) { - this.logger.log( - `${AUTOPILOT_COMMANDS.RESCHEDULE_PHASE}: ignoring challenge ${challengeId} with status ${challengeDetails.status}; only ACTIVE challenges are processed.`, - ); - return; - } - - const phaseTypeName = - await this.challengeApiService.getPhaseTypeName( - challengeDetails.id, - phaseId, - ); - - const payload: PhaseTransitionPayload = { - projectId: challengeDetails.projectId, - phaseId, - challengeId: challengeDetails.id, - phaseTypeName, - operator, - state: 'END', - projectStatus: challengeDetails.status, - date, - }; - - await this.reschedulePhaseTransition( - challengeDetails.id, - payload, - ); - } catch (error) { - const err = error as Error; - this.logger.error( - `Error in reschedule_phase command: ${err.message}`, - err.stack, - ); - } - })(); + await this.handleRescheduleCommand({ + challengeId, + phaseId, + operator, + date, + }); break; } @@ -615,299 +450,95 @@ export class AutopilotService { } } - private async handleSinglePhaseCancellation( - challengeId: string, - phaseId: string, + private async handleRescheduleCommand( + params: Required< + Pick + > & { + operator: CommandPayload['operator']; + }, ): Promise { - await this.cancelPhaseTransition(challengeId, phaseId); - } + const { challengeId, phaseId, date, operator } = params; - private async cancelAllPhasesForChallenge( - challengeId: string, - ): Promise { - const phaseKeys = Array.from(this.activeSchedules.keys()).filter((key) => - key.startsWith(`${challengeId}:`), - ); - - for (const key of phaseKeys) { - const [, phaseId] = key.split(':'); - if (phaseId) { - await this.handleSinglePhaseCancellation(challengeId, phaseId); - } - } - } - - /** - * Find the phases that should be scheduled based on current phase state. - * Similar logic to PhaseAdvancer.js - ensure every phase that needs attention is handled. - */ - private findPhasesToSchedule(phases: IPhase[]): IPhase[] { - const now = new Date(); - - // First, check for phases that should be open but aren't - const phasesToOpen = phases - .filter((phase) => { - if (phase.isOpen || phase.actualEndDate) { - return false; // Already open or already ended - } - - const startTime = new Date(phase.scheduledStartDate); - if (startTime > now) { - return false; // Not time to start yet - } - - // Check if predecessor requirements are met - if (!phase.predecessor) { - return true; // No predecessor, ready to start - } + try { + const challengeDetails = + await this.challengeApiService.getChallengeById(challengeId); - const predecessor = phases.find( - (p) => p.phaseId === phase.predecessor || p.id === phase.predecessor, + if (!challengeDetails) { + this.logger.error( + `Could not find challenge with ID ${challengeId} to reschedule.`, ); - - return Boolean(predecessor?.actualEndDate); // Predecessor has ended - }) - .sort( - (a, b) => - new Date(a.scheduledStartDate).getTime() - - new Date(b.scheduledStartDate).getTime(), - ); - - if (phasesToOpen.length > 0) { - return phasesToOpen; - } - - // Next, check for open phases that should be closed - const openPhases = phases - .filter((phase) => phase.isOpen) - .sort( - (a, b) => - new Date(a.scheduledEndDate).getTime() - - new Date(b.scheduledEndDate).getTime(), - ); - - if (openPhases.length > 0) { - return openPhases; - } - - // Finally, look for future phases that need to be scheduled - const futurePhases = phases.filter( - (phase) => - !phase.actualStartDate && // hasn't started yet - !phase.actualEndDate && // hasn't ended yet - phase.scheduledStartDate && // has a scheduled start date - new Date(phase.scheduledStartDate) > now, // starts in the future - ); - - // Find phases that are ready to start (no predecessor or predecessor is closed) - const readyPhases = futurePhases.filter((phase) => { - if (!phase.predecessor) { - return true; // No predecessor, ready to start + return; } - const predecessor = phases.find( - (p) => p.phaseId === phase.predecessor || p.id === phase.predecessor, - ); - - return Boolean(predecessor?.actualEndDate); - }); - - if (readyPhases.length === 0) { - return []; - } - - // Return the phases with the earliest scheduled start (handle identical start times) - const sortedReady = readyPhases.sort( - (a, b) => - new Date(a.scheduledStartDate).getTime() - - new Date(b.scheduledStartDate).getTime(), - ); - - const earliest = new Date(sortedReady[0].scheduledStartDate).getTime(); - - return sortedReady.filter( - (phase) => new Date(phase.scheduledStartDate).getTime() === earliest, - ); - } - - /** - * Open and schedule next phases in the transition chain - */ - async openAndScheduleNextPhases( - challengeId: string, - projectId: number, - projectStatus: string, - nextPhases: IPhase[], - ): Promise { - if (!this.isChallengeActive(projectStatus)) { - this.logger.log( - `[PHASE CHAIN] Challenge ${challengeId} is not ACTIVE (status: ${projectStatus}), skipping phase chain processing.`, - ); - return; - } - - if (!nextPhases || nextPhases.length === 0) { - this.logger.log( - `[PHASE CHAIN] No next phases to open for challenge ${challengeId}`, - ); - return; - } - - this.logger.log( - `[PHASE CHAIN] Opening and scheduling ${nextPhases.length} next phases for challenge ${challengeId}`, - ); - - let processedCount = 0; - let deferredCount = 0; - - for (const nextPhase of nextPhases) { - const openPhaseCallback = async () => - await this.openPhaseAndSchedule( - challengeId, - projectId, - projectStatus, - nextPhase, - ); - - try { - const canOpenNow = - await this.reviewAssignmentService.ensureAssignmentsOrSchedule( - challengeId, - nextPhase, - openPhaseCallback, - ); - - if (!canOpenNow) { - deferredCount++; - continue; - } - - const opened = await openPhaseCallback(); - if (opened) { - processedCount++; - } - } catch (error) { - const err = error as Error; - this.logger.error( - `[PHASE CHAIN] Failed to open and schedule phase ${nextPhase.name} (${nextPhase.id}) for challenge ${challengeId}: ${err.message}`, - err.stack, + if (!isActiveStatus(challengeDetails.status)) { + this.logger.log( + `${AUTOPILOT_COMMANDS.RESCHEDULE_PHASE}: ignoring challenge ${challengeId} with status ${challengeDetails.status}; only ACTIVE challenges are processed.`, ); + return; } - } - const summaryParts = [ - `opened and scheduled ${processedCount} out of ${nextPhases.length}`, - ]; - if (deferredCount > 0) { - summaryParts.push( - `deferred ${deferredCount} awaiting reviewer assignments`, + const phaseTypeName = await this.challengeApiService.getPhaseTypeName( + challengeDetails.id, + phaseId, ); - } - - this.logger.log( - `[PHASE CHAIN] ${summaryParts.join(', ')} for challenge ${challengeId}`, - ); - } - private async openPhaseAndSchedule( - challengeId: string, - projectId: number, - projectStatus: string, - phase: IPhase, - ): Promise { - if (!this.isChallengeActive(projectStatus)) { - this.logger.log( - `[PHASE CHAIN] Challenge ${challengeId} is not ACTIVE (status: ${projectStatus}); skipping phase ${phase.name} (${phase.id}).`, + const payload: PhaseTransitionPayload = { + projectId: challengeDetails.projectId, + phaseId, + challengeId: challengeDetails.id, + phaseTypeName, + operator, + state: 'END', + projectStatus: challengeDetails.status, + date, + }; + + await this.phaseScheduleManager.reschedulePhaseTransition( + challengeDetails.id, + payload, ); - return false; - } - - this.logger.log( - `[PHASE CHAIN] Opening phase ${phase.name} (${phase.id}) for challenge ${challengeId}`, - ); - - const openResult = await this.challengeApiService.advancePhase( - challengeId, - phase.id, - 'open', - ); - - if (!openResult.success) { + } catch (error) { + const err = error as Error; this.logger.error( - `[PHASE CHAIN] Failed to open phase ${phase.name} (${phase.id}) for challenge ${challengeId}: ${openResult.message}`, + `Error in reschedule_phase command: ${err.message}`, + err.stack, ); - return false; + throw err; } + } - this.reviewAssignmentService.clearPolling(challengeId, phase.id); - - this.logger.log( - `[PHASE CHAIN] Successfully opened phase ${phase.name} (${phase.id}) for challenge ${challengeId}`, - ); + private getStringArray(path: string, fallback: string[]): string[] { + const value = this.configService.get(path); - 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, - ); + 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; } } - const updatedPhase = - openResult.updatedPhases?.find((p) => p.id === phase.id) || phase; - - if (!updatedPhase.scheduledEndDate) { - this.logger.warn( - `[PHASE CHAIN] Opened phase ${phase.name} (${phase.id}) has no scheduled end date, skipping scheduling`, - ); - return false; - } - - const phaseKey = `${challengeId}:${phase.id}`; - if (this.activeSchedules.has(phaseKey)) { - this.logger.log( - `[PHASE CHAIN] Phase ${phase.name} (${phase.id}) is already scheduled, skipping`, - ); - return false; + 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; + } } - const nextPhaseData: PhaseTransitionPayload = { - projectId, - challengeId, - phaseId: updatedPhase.id, - phaseTypeName: updatedPhase.name, - state: 'END', - operator: AutopilotOperator.SYSTEM_PHASE_CHAIN, - projectStatus, - date: updatedPhase.scheduledEndDate, - }; - - const jobId = await this.schedulePhaseTransition(nextPhaseData); - this.logger.log( - `[PHASE CHAIN] Scheduled opened phase ${updatedPhase.name} (${updatedPhase.id}) for closure at ${updatedPhase.scheduledEndDate} with job ID: ${jobId}`, - ); - return true; + return fallback; } - /** - * @deprecated Use openAndScheduleNextPhases instead - * Schedule next phases in the transition chain - */ - scheduleNextPhases( + async openAndScheduleNextPhases( challengeId: string, projectId: number, projectStatus: string, nextPhases: IPhase[], - ): void { - this.logger.warn( - `[PHASE CHAIN] scheduleNextPhases is deprecated, use openAndScheduleNextPhases instead`, - ); - // Convert to async call - void this.openAndScheduleNextPhases( + ): Promise { + await this.phaseScheduleManager.processPhaseChain( challengeId, projectId, projectStatus, @@ -916,7 +547,7 @@ export class AutopilotService { } getActiveSchedules(): Map { - return new Map(this.activeSchedules); + return this.phaseScheduleManager.getActiveSchedulesSnapshot(); } getAllScheduledTransitions(): { @@ -929,9 +560,6 @@ export class AutopilotService { }; } - /** - * Get detailed information about scheduled transitions for debugging - */ getScheduledTransitionsDetails(): { totalScheduled: number; byChallenge: Record; diff --git a/src/autopilot/services/challenge-completion.service.ts b/src/autopilot/services/challenge-completion.service.ts index 48cf2d8..634d93b 100644 --- a/src/autopilot/services/challenge-completion.service.ts +++ b/src/autopilot/services/challenge-completion.service.ts @@ -3,6 +3,7 @@ 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'; @Injectable() export class ChallengeCompletionService { @@ -18,70 +19,92 @@ export class ChallengeCompletionService { const challenge = await this.challengeApiService.getChallengeById(challengeId); - if ( - challenge.status === 'COMPLETED' && - challenge.winners && - challenge.winners.length - ) { + const normalizedStatus = (challenge.status ?? '').toUpperCase(); + if (normalizedStatus !== ChallengeStatusEnum.ACTIVE) { this.logger.log( - `Challenge ${challengeId} is already completed with winners; skipping finalization.`, + `Challenge ${challengeId} is not ACTIVE (status: ${challenge.status}); skipping finalization attempt.`, ); return true; } - const scoreRows = await this.reviewService.getTopFinalReviewScores( - challengeId, - 3, - ); + const summaries = + await this.reviewService.generateReviewSummaries(challengeId); - if (!scoreRows.length) { - if ((challenge.numOfSubmissions ?? 0) > 0) { - this.logger.warn( - `Final review scores are not yet available for challenge ${challengeId}. Will retry finalization later.`, + if (!summaries.length) { + if ((challenge.numOfSubmissions ?? 0) === 0) { + this.logger.log( + `Challenge ${challengeId} has no submissions; marking as CANCELLED_ZERO_SUBMISSIONS if not already handled.`, + ); + await this.challengeApiService.cancelChallenge( + challengeId, + ChallengeStatusEnum.CANCELLED_ZERO_SUBMISSIONS, ); - return false; + return true; } this.logger.warn( - `No submissions found for challenge ${challengeId}; marking completed without winners.`, + `Review data not yet available for challenge ${challengeId}; will retry finalization later.`, + ); + return false; + } + + const passingSummaries = summaries.filter((summary) => summary.isPassing); + + if (!passingSummaries.length) { + this.logger.log( + `No passing submissions detected for challenge ${challengeId}; marking as CANCELLED_FAILED_REVIEW.`, + ); + await this.challengeApiService.cancelChallenge( + challengeId, + ChallengeStatusEnum.CANCELLED_FAILED_REVIEW, ); - await this.challengeApiService.completeChallenge(challengeId, []); return true; } - const memberIds = scoreRows.map((row) => row.memberId); + const sortedSummaries = [...passingSummaries].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 0; + } + return timeA - timeB; + }); + + const memberIds = sortedSummaries + .map((summary) => summary.memberId) + .filter((id): id is string => Boolean(id)); + const handleMap = await this.resourcesService.getMemberHandleMap( challengeId, memberIds, ); const winners: IChallengeWinner[] = []; - for (const [index, row] of scoreRows.entries()) { - const numericMemberId = Number(row.memberId); + for (const summary of sortedSummaries) { + if (!summary.memberId) { + this.logger.warn( + `Skipping winner placement for submission ${summary.submissionId} on challenge ${challengeId} because memberId is missing.`, + ); + continue; + } + + const numericMemberId = Number(summary.memberId); if (!Number.isFinite(numericMemberId)) { this.logger.warn( - `Skipping winner placement ${index + 1} for challenge ${challengeId} because memberId ${row.memberId} is not numeric.`, + `Skipping winner placement for submission ${summary.submissionId} on challenge ${challengeId} because memberId ${summary.memberId} is not numeric.`, ); continue; } winners.push({ userId: numericMemberId, - handle: handleMap.get(row.memberId) ?? row.memberId, + handle: handleMap.get(summary.memberId) ?? summary.memberId, placement: winners.length + 1, }); - - if (winners.length >= 3) { - break; - } - } - - if (!winners.length) { - this.logger.warn( - `Unable to derive any numeric winners for challenge ${challengeId}; marking completed without winners.`, - ); - await this.challengeApiService.completeChallenge(challengeId, []); - return true; } await this.challengeApiService.completeChallenge(challengeId, winners); diff --git a/src/autopilot/services/first2finish.service.spec.ts b/src/autopilot/services/first2finish.service.spec.ts new file mode 100644 index 0000000..77b16ac --- /dev/null +++ b/src/autopilot/services/first2finish.service.spec.ts @@ -0,0 +1,233 @@ +jest.mock('../../kafka/kafka.service', () => ({ + KafkaService: jest.fn().mockImplementation(() => ({})), +})); + +import { First2FinishService } from './first2finish.service'; +import type { ChallengeApiService } from '../../challenge/challenge-api.service'; +import type { SchedulerService } from './scheduler.service'; +import type { ReviewService } from '../../review/review.service'; +import type { ResourcesService } from '../../resources/resources.service'; +import type { ConfigService } from '@nestjs/config'; +import type { + IChallenge, + IPhase, + IChallengeReviewer, +} from '../../challenge/interfaces/challenge.interface'; +import { ITERATIVE_REVIEW_PHASE_NAME } from '../constants/review.constants'; + +const iso = () => new Date().toISOString(); + +const buildIterativePhase = (overrides: Partial = {}): IPhase => ({ + id: 'iterative-phase-1', + phaseId: 'iterative-template', + name: ITERATIVE_REVIEW_PHASE_NAME, + description: null, + isOpen: false, + duration: 86400, + scheduledStartDate: iso(), + scheduledEndDate: iso(), + actualStartDate: iso(), + actualEndDate: iso(), + predecessor: null, + constraints: [], + ...overrides, +}); + +const buildReviewer = ( + overrides: Partial = {}, +): IChallengeReviewer => ({ + id: 'reviewer-config-1', + scorecardId: 'iterative-scorecard', + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'iterative-template', + basePayment: null, + incrementalPayment: null, + type: null, + aiWorkflowId: null, + ...overrides, +}); + +const buildChallenge = (overrides: Partial = {}): IChallenge => ({ + id: 'challenge-1', + name: 'Test', + description: null, + descriptionFormat: 'markdown', + projectId: 123, + typeId: 'type', + trackId: 'track', + timelineTemplateId: 'timeline', + currentPhaseNames: [], + tags: [], + groups: [], + submissionStartDate: iso(), + submissionEndDate: iso(), + registrationStartDate: iso(), + registrationEndDate: iso(), + startDate: iso(), + endDate: null, + legacyId: null, + status: 'ACTIVE', + createdBy: 'tester', + updatedBy: 'tester', + metadata: [], + phases: [], + reviewers: [], + winners: [], + discussions: [], + events: [], + prizeSets: [], + terms: [], + skills: [], + attachments: [], + track: 'DEVELOP', + type: 'first2finish', + legacy: {}, + task: {}, + created: iso(), + updated: iso(), + overview: {}, + numOfSubmissions: 0, + numOfCheckpointSubmissions: 0, + numOfRegistrants: 0, + ...overrides, +}); + +describe('First2FinishService', () => { + let challengeApiService: jest.Mocked; + let schedulerService: jest.Mocked; + let reviewService: jest.Mocked; + let resourcesService: jest.Mocked; + let configService: jest.Mocked; + let service: First2FinishService; + + beforeEach(() => { + jest.useFakeTimers(); + + challengeApiService = { + getChallengeById: jest.fn(), + createIterativeReviewPhase: jest.fn(), + } as unknown as jest.Mocked; + + schedulerService = { + advancePhase: jest.fn(), + schedulePhaseTransition: jest.fn(), + } as unknown as jest.Mocked; + + reviewService = { + getAllSubmissionIdsOrdered: jest.fn(), + getExistingReviewPairs: jest.fn(), + createPendingReview: jest.fn(), + getScorecardPassingScore: jest.fn(), + } as unknown as jest.Mocked; + + resourcesService = { + getReviewerResources: jest.fn(), + } as unknown as jest.Mocked; + + configService = { + get: jest.fn((key: string) => { + if (key === 'autopilot.iterativeReviewDurationHours') { + return 24; + } + if (key === 'autopilot.iterativeReviewAssignmentRetrySeconds') { + return 30; + } + return undefined; + }), + } as unknown as jest.Mocked; + + service = new First2FinishService( + challengeApiService, + schedulerService, + reviewService, + resourcesService, + configService, + ); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('skips creating a new iterative review phase when no submissions exist', async () => { + const challenge = buildChallenge({ + phases: [buildIterativePhase({ isOpen: false })], + reviewers: [buildReviewer()], + }); + + challengeApiService.getChallengeById.mockResolvedValue(challenge); + resourcesService.getReviewerResources.mockResolvedValue([ + { + id: 'resource-1', + memberId: '2001', + memberHandle: 'iterativeReviewer', + roleName: 'Iterative Reviewer', + }, + ]); + reviewService.getAllSubmissionIdsOrdered.mockResolvedValue([]); + + await service.handleSubmissionByChallengeId(challenge.id); + + expect( + challengeApiService.createIterativeReviewPhase, + ).not.toHaveBeenCalled(); + expect(schedulerService.advancePhase).not.toHaveBeenCalled(); + }); + + it('assigns the preferred submission when the list snapshot is empty', async () => { + const closedPhase = buildIterativePhase({ isOpen: false }); + const challenge = buildChallenge({ + phases: [closedPhase], + reviewers: [buildReviewer()], + }); + + const nextPhase = buildIterativePhase({ + id: 'iterative-phase-2', + isOpen: true, + actualEndDate: null, + predecessor: closedPhase.phaseId, + }); + + challengeApiService.getChallengeById.mockResolvedValue(challenge); + challengeApiService.createIterativeReviewPhase.mockResolvedValue(nextPhase); + + resourcesService.getReviewerResources.mockResolvedValue([ + { + id: 'resource-1', + memberId: '2001', + memberHandle: 'iterativeReviewer', + roleName: 'Iterative Reviewer', + }, + ]); + + reviewService.getAllSubmissionIdsOrdered.mockResolvedValue([]); + reviewService.getExistingReviewPairs.mockResolvedValue(new Set()); + reviewService.createPendingReview.mockResolvedValue(true); + + await service.handleSubmissionByChallengeId(challenge.id, 'sub-123'); + + expect( + challengeApiService.createIterativeReviewPhase, + ).toHaveBeenCalledWith( + challenge.id, + closedPhase.id, + closedPhase.phaseId, + closedPhase.name, + closedPhase.description, + expect.any(Number), + ); + + expect(reviewService.createPendingReview).toHaveBeenCalledWith( + 'sub-123', + 'resource-1', + nextPhase.id, + 'iterative-scorecard', + challenge.id, + ); + + expect(schedulerService.advancePhase).not.toHaveBeenCalled(); + expect(schedulerService.schedulePhaseTransition).toHaveBeenCalled(); + }); +}); diff --git a/src/autopilot/services/first2finish.service.ts b/src/autopilot/services/first2finish.service.ts new file mode 100644 index 0000000..9d6258d --- /dev/null +++ b/src/autopilot/services/first2finish.service.ts @@ -0,0 +1,709 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ChallengeApiService } from '../../challenge/challenge-api.service'; +import { + IChallenge, + IPhase, +} from '../../challenge/interfaces/challenge.interface'; +import { SchedulerService } from './scheduler.service'; +import { ReviewService } from '../../review/review.service'; +import { ResourcesService } from '../../resources/resources.service'; +import { + AutopilotOperator, + ReviewCompletedPayload, +} from '../interfaces/autopilot.interface'; +import { + ITERATIVE_REVIEW_PHASE_NAME, + PHASE_ROLE_MAP, + SUBMISSION_PHASE_NAME, + TOPGEAR_SUBMISSION_PHASE_NAME, + isSubmissionPhaseName, +} from '../constants/review.constants'; +import { + describeChallengeType, + isFirst2FinishChallenge as isSupportedChallengeType, + isTopgearTaskChallenge, +} from '../constants/challenge.constants'; +import { isActiveStatus } from '../utils/config.utils'; +import { selectScorecardId } from '../utils/reviewer.utils'; + +@Injectable() +export class First2FinishService { + private readonly logger = new Logger(First2FinishService.name); + private readonly iterativeRoles: string[]; + private readonly iterativeReviewDurationMs: number; + private readonly iterativeAssignmentRetryMs: number; + private readonly iterativeAssignmentRetryTimers = new Map< + string, + NodeJS.Timeout + >(); + + constructor( + private readonly challengeApiService: ChallengeApiService, + private readonly schedulerService: SchedulerService, + private readonly reviewService: ReviewService, + private readonly resourcesService: ResourcesService, + private readonly configService: ConfigService, + ) { + this.iterativeRoles = PHASE_ROLE_MAP[ITERATIVE_REVIEW_PHASE_NAME] ?? [ + 'Iterative Reviewer', + ]; + + const configuredDuration = this.configService.get( + 'autopilot.iterativeReviewDurationHours', + ); + const parsedDuration = Number(configuredDuration); + const normalizedHours = + Number.isFinite(parsedDuration) && parsedDuration > 0 + ? parsedDuration + : 24; + this.iterativeReviewDurationMs = normalizedHours * 60 * 60 * 1000; + + const configuredRetrySeconds = this.configService.get( + 'autopilot.iterativeReviewAssignmentRetrySeconds', + ); + const parsedRetrySeconds = Number(configuredRetrySeconds); + this.iterativeAssignmentRetryMs = + Number.isFinite(parsedRetrySeconds) && parsedRetrySeconds >= 0 + ? parsedRetrySeconds * 1000 + : 30_000; + } + + isChallengeActive(status?: string): boolean { + return (status ?? '').toUpperCase() === 'ACTIVE'; + } + + isFirst2FinishChallenge(type?: string): boolean { + return isSupportedChallengeType(type); + } + + async handleSubmissionByChallengeId( + challengeId: string, + submissionId?: string, + ): Promise { + const challenge = + await this.challengeApiService.getChallengeById(challengeId); + + if (!this.isFirst2FinishChallenge(challenge.type)) { + this.logger.debug( + 'Skipping submission aggregate for unsupported challenge type', + { + submissionId, + challengeId, + challengeType: challenge.type, + }, + ); + return; + } + + await this.processFirst2FinishSubmission(challenge, submissionId); + } + + async handleIterativeReviewerAdded(challenge: IChallenge): Promise { + const reviewers = await this.resourcesService.getReviewerResources( + challenge.id, + this.iterativeRoles, + ); + + if (reviewers.length !== 1) { + this.logger.debug( + `Skipping iterative reviewer added handling for challenge ${challenge.id}; expected the first reviewer but found ${reviewers.length}.`, + ); + return; + } + + await this.processFirst2FinishSubmission(challenge); + } + + async handleIterativeReviewCompletion( + challenge: IChallenge, + phase: IPhase, + review: { + score?: number | string | null; + scorecardId: string | null; + resourceId: string; + submissionId: string | null; + phaseId: string | null; + }, + payload: ReviewCompletedPayload, + ): Promise { + const scorecardId = review.scorecardId ?? payload.scorecardId; + const passingScore = + await this.reviewService.getScorecardPassingScore(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); + + 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( + `Iterative review passed for submission ${payload.submissionId} on challenge ${challenge.id} (score ${finalScore} / passing ${passingScore}).`, + ); + + const submissionPhase = challenge.phases.find( + (p) => isSubmissionPhaseName(p.name) && p.isOpen, + ); + + if (submissionPhase) { + await this.schedulerService.advancePhase({ + projectId: challenge.projectId, + challengeId: challenge.id, + phaseId: submissionPhase.id, + phaseTypeName: submissionPhase.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}).`, + ); + const lastSubmissionId = + payload.submissionId ?? review.submissionId ?? undefined; + await this.prepareNextIterativeReview(challenge.id, lastSubmissionId); + } + } + + private async processFirst2FinishSubmission( + challenge: IChallenge, + submissionId?: string, + ): Promise { + if (!this.isFirst2FinishChallenge(challenge.type)) { + return; + } + + if (!isActiveStatus(challenge.status)) { + this.logger.debug( + `Skipping iterative review processing for challenge ${challenge.id}; status ${challenge.status ?? 'UNKNOWN'} is not active.`, + { + submissionId: submissionId ?? null, + }, + ); + return; + } + + const latestIterativePhase = this.getLatestIterativePhase(challenge); + + if (!latestIterativePhase) { + this.logger.warn( + `No Iterative Review phase configured for ${describeChallengeType(challenge.type)} challenge ${challenge.id}.`, + ); + return; + } + + const reviewers = await this.resourcesService.getReviewerResources( + challenge.id, + this.iterativeRoles, + ); + + if (!reviewers.length) { + this.logger.warn( + `Awaiting iterative reviewer assignment for challenge ${challenge.id} before processing submission ${submissionId ?? 'latest'}.`, + ); + return; + } + + const scorecardId = this.pickIterativeScorecard(challenge, latestIterativePhase); + if (!scorecardId) { + this.logger.warn( + `Unable to determine scorecard for iterative review phase on challenge ${challenge.id}.`, + ); + return; + } + + let activePhase: IPhase | null = + challenge.phases.find( + (phase) => phase.name === ITERATIVE_REVIEW_PHASE_NAME && phase.isOpen, + ) ?? null; + + if (activePhase) { + const pendingReviews = await this.reviewService.getPendingReviewCount( + activePhase.id, + challenge.id, + ); + + if (pendingReviews > 0) { + this.logger.debug( + `Iterative review already in progress for challenge ${challenge.id}; deferring submission processing.`, + { + submissionId: submissionId ?? null, + activePhaseId: activePhase.id, + pendingReviews, + }, + ); + return; + } + } + + if (!activePhase) { + if (!submissionId) { + const availableSubmissionIds = + await this.reviewService.getAllSubmissionIdsOrdered(challenge.id); + + if (!availableSubmissionIds.length) { + this.logger.debug( + `Skipping iterative review phase creation for challenge ${challenge.id}; no submissions available for iterative review.`, + ); + return; + } + } + + activePhase = await this.createNextIterativePhase( + challenge, + latestIterativePhase, + ); + + if (!activePhase) { + return; + } + } + + const assigned = await this.assignIterativeReviewToReviewers( + challenge.id, + activePhase, + reviewers, + scorecardId, + submissionId, + ); + + if (!assigned) { + this.logger.debug( + `No additional submissions available for iterative review on challenge ${challenge.id}; closing phase ${activePhase.id}.`, + ); + + await this.schedulerService.advancePhase({ + projectId: challenge.projectId, + challengeId: challenge.id, + phaseId: activePhase.id, + phaseTypeName: activePhase.name, + state: 'END', + operator: AutopilotOperator.SYSTEM, + projectStatus: challenge.status, + }); + return; + } + + await this.scheduleIterativeReviewClosure(challenge, activePhase); + this.scheduleIterativeAssignmentVerification(challenge.id); + } + + private scheduleIterativeAssignmentVerification(challengeId: string): void { + const existingTimer = this.iterativeAssignmentRetryTimers.get(challengeId); + if (existingTimer) { + clearTimeout(existingTimer); + } + + const executeVerification = () => { + this.iterativeAssignmentRetryTimers.delete(challengeId); + void this.verifyIterativeAssignment(challengeId); + }; + + if (this.iterativeAssignmentRetryMs <= 0) { + executeVerification(); + return; + } + + const timer = setTimeout(executeVerification, this.iterativeAssignmentRetryMs); + this.iterativeAssignmentRetryTimers.set(challengeId, timer); + } + + private async verifyIterativeAssignment(challengeId: string): Promise { + try { + const challenge = + await this.challengeApiService.getChallengeById(challengeId); + + if (!this.isFirst2FinishChallenge(challenge.type)) { + return; + } + + if (!isActiveStatus(challenge.status)) { + return; + } + + const activePhase = + challenge.phases.find( + (phase) => + phase.name === ITERATIVE_REVIEW_PHASE_NAME && phase.isOpen, + ) ?? null; + + if (!activePhase) { + return; + } + + const pendingCount = await this.reviewService.getPendingReviewCount( + activePhase.id, + challengeId, + ); + + if (pendingCount > 0) { + return; + } + + const reviewers = await this.resourcesService.getReviewerResources( + challengeId, + this.iterativeRoles, + ); + + if (!reviewers.length) { + return; + } + + const scorecardId = this.pickIterativeScorecard(challenge, activePhase); + if (!scorecardId) { + return; + } + + const assigned = await this.assignIterativeReviewToReviewers( + challengeId, + activePhase, + reviewers, + scorecardId, + ); + + if (!assigned) { + this.logger.debug( + `Iterative review assignment verification found no eligible submissions for challenge ${challengeId}.`, + { + phaseId: activePhase.id, + }, + ); + return; + } + + this.logger.log( + `Recreated pending iterative review for challenge ${challengeId} after verification retry.`, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to verify iterative review assignment for challenge ${challengeId}: ${err.message}`, + err.stack, + ); + } + } + + private async assignNextIterativeReview( + challengeId: string, + phase: IPhase, + resourceId: string, + scorecardId: string, + preferredSubmissionId?: string, + ): 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; + } + 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)) { + 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}.`, + ); + return true; + } + } + + return false; + } + + private async prepareNextIterativeReview( + challengeId: string, + lastSubmissionId?: string, + ): Promise { + const challenge = + await this.challengeApiService.getChallengeById(challengeId); + + if (!isActiveStatus(challenge.status)) { + return; + } + + const latestIterativePhase = this.getLatestIterativePhase(challenge); + + if (!latestIterativePhase) { + return; + } + + if (latestIterativePhase.isOpen) { + this.logger.debug( + `Iterative review phase ${latestIterativePhase.id} already open for challenge ${challengeId}; awaiting additional submissions.`, + ); + return; + } + + const reviewers = await this.resourcesService.getReviewerResources( + challengeId, + this.iterativeRoles, + ); + + if (!reviewers.length) { + this.logger.warn( + `Awaiting iterative reviewer assignment for challenge ${challengeId} before processing next submission.`, + ); + return; + } + + const submissionIds = await this.reviewService.getAllSubmissionIdsOrdered( + challengeId, + ); + + const recentPairs = await this.reviewService.getExistingReviewPairs( + latestIterativePhase.id, + challengeId, + ); + + const preferredSubmissionId = this.selectNextIterativeSubmission( + reviewers, + submissionIds, + recentPairs, + lastSubmissionId, + ); + + if (!preferredSubmissionId) { + this.logger.debug( + `No pending submissions available for next iterative review on challenge ${challengeId}; will wait for new submissions.`, + ); + return; + } + + const scorecardId = this.pickIterativeScorecard( + challenge, + latestIterativePhase, + ); + + if (!scorecardId) { + this.logger.warn( + `Unable to determine scorecard for iterative review phase on challenge ${challengeId}.`, + ); + return; + } + + const nextPhase = await this.createNextIterativePhase( + challenge, + latestIterativePhase, + ); + + if (!nextPhase) { + return; + } + + const assigned = await this.assignIterativeReviewToReviewers( + challengeId, + nextPhase, + reviewers, + scorecardId, + preferredSubmissionId, + ); + + if (!assigned) { + this.logger.debug( + `Unable to assign next iterative review for challenge ${challengeId}; closing phase ${nextPhase.id}.`, + ); + + await this.schedulerService.advancePhase({ + projectId: challenge.projectId, + challengeId: challenge.id, + phaseId: nextPhase.id, + phaseTypeName: nextPhase.name, + state: 'END', + operator: AutopilotOperator.SYSTEM, + projectStatus: challenge.status, + }); + return; + } + + await this.scheduleIterativeReviewClosure(challenge, nextPhase); + this.scheduleIterativeAssignmentVerification(challengeId); + } + + private pickIterativeScorecard( + challenge: IChallenge, + phase: IPhase, + ): string | null { + return selectScorecardId( + challenge.reviewers ?? [], + () => null, + () => null, + phase.phaseId, + ); + } + + private getLatestIterativePhase(challenge: IChallenge): IPhase | null { + const phases = challenge.phases?.filter( + (phase) => phase.name === ITERATIVE_REVIEW_PHASE_NAME, + ); + + if (!phases?.length) { + return null; + } + + const sorted = [...phases].sort((a, b) => { + return this.getPhaseStartTime(a) - this.getPhaseStartTime(b); + }); + + return sorted.at(-1) ?? null; + } + + private getPhaseStartTime(phase: IPhase): number { + const reference = phase.actualStartDate ?? phase.scheduledStartDate; + return new Date(reference).getTime(); + } + + private selectNextIterativeSubmission( + reviewers: Array<{ id: string }>, + submissionIds: string[], + recentPairs: Set, + lastSubmissionId?: string, + ): string | null { + for (const submissionId of submissionIds) { + if (submissionId === lastSubmissionId) { + continue; + } + + const alreadyReviewed = reviewers.some((reviewer) => + recentPairs.has(`${reviewer.id}:${submissionId}`), + ); + + if (!alreadyReviewed) { + return submissionId; + } + } + + return null; + } + + private async createNextIterativePhase( + challenge: IChallenge, + predecessor: IPhase, + ): Promise { + try { + const durationSeconds = Math.max( + Math.round(this.iterativeReviewDurationMs / 1000), + predecessor.duration || 1, + ); + + return await this.challengeApiService.createIterativeReviewPhase( + challenge.id, + predecessor.id, + predecessor.phaseId, + predecessor.name, + predecessor.description, + durationSeconds, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to create next iterative review phase after ${predecessor.id} on challenge ${challenge.id}: ${err.message}`, + err.stack, + ); + return null; + } + } + + private async scheduleIterativeReviewClosure( + challenge: IChallenge, + phase: IPhase, + ): Promise { + const startTime = phase.actualStartDate + ? new Date(phase.actualStartDate).getTime() + : Date.now(); + const deadline = Math.max( + startTime + this.iterativeReviewDurationMs, + Date.now(), + ); + const deadlineIso = new Date(deadline).toISOString(); + + try { + await this.schedulerService.schedulePhaseTransition({ + projectId: challenge.projectId, + challengeId: challenge.id, + phaseId: phase.id, + phaseTypeName: phase.name, + state: 'END', + operator: AutopilotOperator.SYSTEM_PHASE_CHAIN, + projectStatus: challenge.status, + date: deadlineIso, + }); + this.logger.debug( + `Scheduled iterative review phase ${phase.id} closure for challenge ${challenge.id} at ${deadlineIso}.`, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to schedule iterative review closure for challenge ${challenge.id}, phase ${phase.id}: ${err.message}`, + err.stack, + ); + } + } + + private async assignIterativeReviewToReviewers( + challengeId: string, + phase: IPhase, + reviewers: Array<{ id: string }>, + scorecardId: string, + preferredSubmissionId?: string, + ): Promise { + for (const reviewer of reviewers) { + const assigned = await this.assignNextIterativeReview( + challengeId, + phase, + reviewer.id, + scorecardId, + preferredSubmissionId, + ); + + if (assigned) { + return true; + } + } + + return false; + } +} diff --git a/src/autopilot/services/phase-review.service.ts b/src/autopilot/services/phase-review.service.ts index 4929d5a..69e8df3 100644 --- a/src/autopilot/services/phase-review.service.ts +++ b/src/autopilot/services/phase-review.service.ts @@ -2,11 +2,14 @@ import { Injectable, Logger } from '@nestjs/common'; import { ChallengeApiService } from '../../challenge/challenge-api.service'; import { ReviewService } from '../../review/review.service'; import { ResourcesService } from '../../resources/resources.service'; -import { IChallengeReviewer } from '../../challenge/interfaces/challenge.interface'; import { getRoleNamesForPhase, REVIEW_PHASE_NAMES, } from '../constants/review.constants'; +import { + getMemberReviewerConfigs, + selectScorecardId, +} from '../utils/reviewer.utils'; @Injectable() export class PhaseReviewService { @@ -34,10 +37,10 @@ export class PhaseReviewService { return; } - const reviewerConfigs = this.getReviewerConfigsForPhase( + const reviewerConfigs = getMemberReviewerConfigs( challenge.reviewers, phase.phaseId, - ); + ).filter((config) => Boolean(config.scorecardId)); if (!reviewerConfigs.length) { this.logger.log( @@ -46,10 +49,16 @@ export class PhaseReviewService { return; } - const scorecardId = this.pickScorecardId( + const scorecardId = selectScorecardId( reviewerConfigs, - challengeId, - phase.id, + () => + 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; @@ -118,41 +127,4 @@ export class PhaseReviewService { ); } } - - private getReviewerConfigsForPhase( - reviewers: IChallengeReviewer[], - phaseTemplateId: string, - ): IChallengeReviewer[] { - return reviewers.filter( - (reviewer) => - reviewer.isMemberReview && - reviewer.phaseId === phaseTemplateId && - Boolean(reviewer.scorecardId), - ); - } - - private pickScorecardId( - reviewerConfigs: IChallengeReviewer[], - challengeId: string, - phaseId: string, - ): string | null { - const uniqueScorecards = Array.from( - new Set(reviewerConfigs.map((config) => config.scorecardId)), - ); - - if (uniqueScorecards.length === 0) { - this.logger.warn( - `Member reviewer configs missing scorecard IDs for challenge ${challengeId}, phase ${phaseId}`, - ); - return null; - } - - if (uniqueScorecards.length > 1) { - this.logger.warn( - `Multiple scorecard IDs detected for challenge ${challengeId}, phase ${phaseId}. Using ${uniqueScorecards[0]} for pending reviews`, - ); - } - - return uniqueScorecards[0]; - } } diff --git a/src/autopilot/services/phase-schedule-manager.service.ts b/src/autopilot/services/phase-schedule-manager.service.ts new file mode 100644 index 0000000..600748c --- /dev/null +++ b/src/autopilot/services/phase-schedule-manager.service.ts @@ -0,0 +1,639 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ChallengeApiService } from '../../challenge/challenge-api.service'; +import { + IChallenge, + IPhase, +} from '../../challenge/interfaces/challenge.interface'; +import { SchedulerService } from './scheduler.service'; +import { PhaseReviewService } from './phase-review.service'; +import { ReviewAssignmentService } from './review-assignment.service'; +import { + AutopilotOperator, + ChallengeUpdatePayload, + PhaseTransitionPayload, +} from '../interfaces/autopilot.interface'; +import { REVIEW_PHASE_NAMES } from '../constants/review.constants'; + +@Injectable() +export class PhaseScheduleManager { + private readonly logger = new Logger(PhaseScheduleManager.name); + + constructor( + private readonly schedulerService: SchedulerService, + private readonly challengeApiService: ChallengeApiService, + private readonly phaseReviewService: PhaseReviewService, + private readonly reviewAssignmentService: ReviewAssignmentService, + ) { + this.schedulerService.setPhaseChainCallback( + ( + challengeId: string, + projectId: number, + projectStatus: string, + nextPhases: IPhase[], + ) => { + void this.openAndScheduleNextPhases( + challengeId, + projectId, + projectStatus, + nextPhases, + ); + }, + ); + } + + async schedulePhaseTransition( + phaseData: PhaseTransitionPayload, + ): Promise { + const jobId = this.schedulerService.buildJobId( + phaseData.challengeId, + phaseData.phaseId, + ); + + const existingJob = this.schedulerService.getScheduledTransition(jobId); + if (existingJob) { + this.logger.log( + `Canceling existing schedule ${jobId} before scheduling new transition.`, + ); + const canceled = + await this.schedulerService.cancelScheduledTransition(jobId); + if (!canceled) { + this.logger.warn( + `Failed to cancel existing schedule ${jobId} for challenge ${phaseData.challengeId}.`, + ); + } + } + + const newJobId = + await this.schedulerService.schedulePhaseTransition(phaseData); + + this.logger.log( + `Scheduled phase transition for challenge ${phaseData.challengeId}, phase ${phaseData.phaseId} at ${phaseData.date}`, + ); + return newJobId; + } + + async cancelPhaseTransition( + challengeId: string, + phaseId: string, + ): Promise { + const jobId = this.schedulerService.buildJobId(challengeId, phaseId); + const scheduledJob = this.schedulerService.getScheduledTransition(jobId); + + if (!scheduledJob) { + this.logger.warn( + `No active schedule found for challenge ${challengeId}, phase ${phaseId}`, + ); + return false; + } + + const canceled = + await this.schedulerService.cancelScheduledTransition(jobId); + if (canceled) { + this.logger.log( + `Canceled scheduled transition for phase ${phaseId} on challenge ${challengeId}`, + ); + return true; + } + + this.logger.warn( + `Unable to cancel scheduled transition ${jobId}; job may have already executed.`, + ); + return false; + } + + async reschedulePhaseTransition( + challengeId: string, + newPhaseData: PhaseTransitionPayload, + ): Promise { + const jobId = this.schedulerService.buildJobId( + challengeId, + newPhaseData.phaseId, + ); + const existingJob = this.schedulerService.getScheduledTransition(jobId); + let wasRescheduled = false; + + if (existingJob && existingJob.date && newPhaseData.date) { + const existingTime = new Date(existingJob.date).getTime(); + const newTime = new Date(newPhaseData.date).getTime(); + + if (existingTime === newTime) { + this.logger.log( + `No change detected for challenge ${challengeId}, phase ${newPhaseData.phaseId}; skipping reschedule.`, + ); + return jobId; + } + + this.logger.log( + `Detected change in end time for challenge ${challengeId}, phase ${newPhaseData.phaseId}; rescheduling.`, + ); + wasRescheduled = true; + } + + const newJobId = await this.schedulePhaseTransition(newPhaseData); + + if (wasRescheduled) { + this.logger.log( + `Successfully rescheduled phase ${newPhaseData.phaseId} with new end time: ${newPhaseData.date}`, + ); + } + + return newJobId; + } + + async handlePhaseTransition(message: PhaseTransitionPayload): Promise { + this.logger.log( + `Consumed phase transition event: ${JSON.stringify(message)}`, + ); + + if (!this.isChallengeActive(message.projectStatus)) { + this.logger.log( + `Ignoring phase transition for challenge ${message.challengeId} with status ${message.projectStatus}; only ACTIVE challenges are processed.`, + ); + return; + } + + try { + await this.schedulerService.advancePhase(message); + this.logger.log( + `Successfully processed ${message.state} event for phase ${message.phaseId} (challenge ${message.challengeId})`, + ); + + if (message.state === 'END') { + const canceled = await this.cancelPhaseTransition( + message.challengeId, + message.phaseId, + ); + if (canceled) { + this.logger.log( + `Cleaned up job for phase ${message.phaseId} (challenge ${message.challengeId}) from registry after consuming event.`, + ); + } + } + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to advance phase ${message.phaseId} for challenge ${message.challengeId}: ${err.message}`, + err.stack, + ); + throw err; + } + } + + async handleNewChallenge(challenge: ChallengeUpdatePayload): Promise { + this.logger.log( + `Handling new challenge creation: ${JSON.stringify(challenge)}`, + ); + try { + const challengeDetails = await this.challengeApiService.getChallengeById( + challenge.id, + ); + + if (!this.isChallengeActive(challengeDetails.status)) { + this.logger.log( + `Skipping challenge ${challenge.id} with status ${challengeDetails.status}; only ACTIVE challenges are processed.`, + ); + return; + } + + if (!challengeDetails.phases) { + this.logger.warn( + `Challenge ${challenge.id} has no phases to schedule.`, + ); + return; + } + + await this.scheduleRelevantPhases(challengeDetails, { + operator: AutopilotOperator.SYSTEM_NEW_CHALLENGE, + schedulePhase: (payload) => this.schedulePhaseTransition(payload), + onNoPhases: () => + this.logger.log( + `No phase needs to be scheduled for new challenge ${challenge.id}`, + ), + onMissingScheduleData: () => + this.logger.warn( + `Unable to schedule any phases for new challenge ${challenge.id} due to missing schedule data.`, + ), + onSuccess: (summaries) => + this.logger.log( + `Scheduled ${summaries.length} phase(s) for new challenge ${challenge.id}: ${summaries.join('; ')}`, + ), + onMissingDates: (phase, stateLabel) => + this.logger.warn( + `Next phase ${phase.id} for new challenge ${challenge.id} has no scheduled ${stateLabel} date. Skipping.`, + ), + }); + } catch (error) { + const err = error as Error; + this.logger.error( + `Error handling new challenge creation for id ${challenge.id}: ${err.message}`, + err.stack, + ); + } + } + + async handleChallengeUpdate(message: ChallengeUpdatePayload): Promise { + this.logger.log(`Handling challenge update: ${JSON.stringify(message)}`); + + try { + const challengeDetails = await this.challengeApiService.getChallengeById( + message.id, + ); + + if (!this.isChallengeActive(challengeDetails.status)) { + this.logger.log( + `Skipping challenge ${message.id} update with status ${challengeDetails.status}; only ACTIVE challenges are processed.`, + ); + return; + } + + if (!challengeDetails.phases) { + this.logger.warn( + `Updated challenge ${message.id} has no phases to process.`, + ); + return; + } + + await this.scheduleRelevantPhases(challengeDetails, { + operator: message.operator, + schedulePhase: (payload) => + this.reschedulePhaseTransition(challengeDetails.id, payload), + onNoPhases: () => + this.logger.log( + `No phase needs to be rescheduled for updated challenge ${message.id}`, + ), + onMissingScheduleData: () => + this.logger.warn( + `Unable to reschedule any phases for updated challenge ${message.id} due to missing schedule data.`, + ), + onSuccess: (summaries) => + this.logger.log( + `Rescheduled ${summaries.length} phase(s) for updated challenge ${message.id}: ${summaries.join('; ')}`, + ), + onMissingDates: (phase, stateLabel) => + this.logger.warn( + `Next phase ${phase.id} for updated challenge ${message.id} has no scheduled ${stateLabel} date. Skipping.`, + ), + }); + } catch (error) { + const err = error as Error; + this.logger.error( + `Error handling challenge update: ${err.message}`, + err.stack, + ); + } + } + + async processPhaseChain( + challengeId: string, + projectId: number, + projectStatus: string, + nextPhases: IPhase[], + ): Promise { + await this.openAndScheduleNextPhases( + challengeId, + projectId, + projectStatus, + nextPhases, + ); + } + + async cancelAllPhasesForChallenge(challengeId: string): Promise { + const jobIds = this.schedulerService + .getAllScheduledTransitions() + .filter((jobId) => jobId.startsWith(`${challengeId}|`)); + + for (const jobId of jobIds) { + const [, phaseId] = jobId.split('|'); + if (phaseId) { + await this.cancelPhaseTransition(challengeId, phaseId); + } + } + } + + private async scheduleRelevantPhases( + challenge: IChallenge, + context: { + operator: AutopilotOperator | string | undefined; + schedulePhase: (payload: PhaseTransitionPayload) => Promise; + onNoPhases: () => void; + onSuccess: (summaries: string[]) => void; + onMissingScheduleData: () => void; + onMissingDates: (phase: IPhase, stateLabel: 'start' | 'end') => void; + }, + ): Promise { + const phasesToSchedule = this.findPhasesToSchedule(challenge.phases ?? []); + + if (phasesToSchedule.length === 0) { + context.onNoPhases(); + return; + } + + const now = new Date(); + const summaries: string[] = []; + + for (const nextPhase of phasesToSchedule) { + const shouldOpen = + !nextPhase.isOpen && + !nextPhase.actualEndDate && + nextPhase.scheduledStartDate && + new Date(nextPhase.scheduledStartDate).getTime() <= now.getTime(); + + const state = shouldOpen ? 'START' : 'END'; + const scheduleDate = shouldOpen + ? nextPhase.scheduledStartDate + : nextPhase.scheduledEndDate; + + if (!scheduleDate) { + context.onMissingDates(nextPhase, shouldOpen ? 'start' : 'end'); + continue; + } + + const payload: PhaseTransitionPayload = { + projectId: challenge.projectId, + challengeId: challenge.id, + phaseId: nextPhase.id, + phaseTypeName: nextPhase.name, + state, + operator: context.operator ?? AutopilotOperator.SYSTEM, + projectStatus: challenge.status, + date: scheduleDate, + }; + + await context.schedulePhase(payload); + summaries.push( + `${nextPhase.name} (${nextPhase.id}) -> ${state} @ ${scheduleDate}`, + ); + } + + if (summaries.length === 0) { + context.onMissingScheduleData(); + return; + } + + context.onSuccess(summaries); + } + + getActiveSchedulesSnapshot(): Map { + const snapshot = new Map(); + const scheduledJobs = this.schedulerService.getAllScheduledTransitions(); + + for (const jobId of scheduledJobs) { + const [challengeId, phaseId] = jobId.split('|'); + if (challengeId && phaseId) { + snapshot.set(`${challengeId}:${phaseId}`, jobId); + } + } + + return snapshot; + } + + private findPhasesToSchedule(phases: IPhase[]): IPhase[] { + const now = new Date(); + + const phasesToOpen = phases + .filter((phase) => { + if (phase.isOpen || phase.actualEndDate) { + return false; + } + + const startTime = new Date(phase.scheduledStartDate); + if (startTime > now) { + return false; + } + + if (!phase.predecessor) { + return true; + } + + const predecessor = phases.find( + (p) => p.phaseId === phase.predecessor || p.id === phase.predecessor, + ); + + return Boolean(predecessor?.actualEndDate); + }) + .sort( + (a, b) => + new Date(a.scheduledStartDate).getTime() - + new Date(b.scheduledStartDate).getTime(), + ); + + if (phasesToOpen.length > 0) { + return phasesToOpen; + } + + const openPhases = phases + .filter((phase) => phase.isOpen) + .sort( + (a, b) => + new Date(a.scheduledEndDate).getTime() - + new Date(b.scheduledEndDate).getTime(), + ); + + if (openPhases.length > 0) { + return openPhases; + } + + const futurePhases = phases.filter( + (phase) => + !phase.actualStartDate && + !phase.actualEndDate && + phase.scheduledStartDate && + new Date(phase.scheduledStartDate) > now, + ); + + const readyPhases = futurePhases.filter((phase) => { + if (!phase.predecessor) { + return true; + } + + const predecessor = phases.find( + (p) => p.phaseId === phase.predecessor || p.id === phase.predecessor, + ); + + return Boolean(predecessor?.actualEndDate); + }); + + if (readyPhases.length === 0) { + return []; + } + + const sortedReady = readyPhases.sort( + (a, b) => + new Date(a.scheduledStartDate).getTime() - + new Date(b.scheduledStartDate).getTime(), + ); + + const earliest = new Date(sortedReady[0].scheduledStartDate).getTime(); + + return sortedReady.filter( + (phase) => new Date(phase.scheduledStartDate).getTime() === earliest, + ); + } + + private async openAndScheduleNextPhases( + challengeId: string, + projectId: number, + projectStatus: string, + nextPhases: IPhase[], + ): Promise { + if (!this.isChallengeActive(projectStatus)) { + this.logger.log( + `[PHASE CHAIN] Challenge ${challengeId} is not ACTIVE (status: ${projectStatus}), skipping phase chain processing.`, + ); + return; + } + + if (!nextPhases || nextPhases.length === 0) { + this.logger.log( + `[PHASE CHAIN] No next phases to open for challenge ${challengeId}`, + ); + return; + } + + this.logger.log( + `[PHASE CHAIN] Opening and scheduling ${nextPhases.length} next phases for challenge ${challengeId}`, + ); + + let processedCount = 0; + let deferredCount = 0; + + for (const nextPhase of nextPhases) { + const openPhaseCallback = async () => + await this.openPhaseAndSchedule( + challengeId, + projectId, + projectStatus, + nextPhase, + ); + + try { + const canOpenNow = + await this.reviewAssignmentService.ensureAssignmentsOrSchedule( + challengeId, + nextPhase, + openPhaseCallback, + ); + + if (!canOpenNow) { + deferredCount++; + continue; + } + + const opened = await openPhaseCallback(); + if (opened) { + processedCount++; + } + } catch (error) { + const err = error as Error; + this.logger.error( + `[PHASE CHAIN] Failed to open and schedule phase ${nextPhase.name} (${nextPhase.id}) for challenge ${challengeId}: ${err.message}`, + err.stack, + ); + } + } + + const summaryParts = [ + `opened and scheduled ${processedCount} out of ${nextPhases.length}`, + ]; + if (deferredCount > 0) { + summaryParts.push( + `deferred ${deferredCount} awaiting reviewer assignments`, + ); + } + + this.logger.log( + `[PHASE CHAIN] ${summaryParts.join(', ')} for challenge ${challengeId}`, + ); + } + + private async openPhaseAndSchedule( + challengeId: string, + projectId: number, + projectStatus: string, + phase: IPhase, + ): Promise { + if (!this.isChallengeActive(projectStatus)) { + this.logger.log( + `[PHASE CHAIN] Challenge ${challengeId} is not ACTIVE (status: ${projectStatus}); skipping phase ${phase.name} (${phase.id}).`, + ); + return false; + } + + this.logger.log( + `[PHASE CHAIN] Opening phase ${phase.name} (${phase.id}) for challenge ${challengeId}`, + ); + + const openResult = await this.challengeApiService.advancePhase( + challengeId, + phase.id, + 'open', + ); + + if (!openResult.success) { + this.logger.error( + `[PHASE CHAIN] Failed to open phase ${phase.name} (${phase.id}) for challenge ${challengeId}: ${openResult.message}`, + ); + return false; + } + + this.reviewAssignmentService.clearPolling(challengeId, phase.id); + + this.logger.log( + `[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, + ); + } + } + + const updatedPhase = + openResult.updatedPhases?.find((p) => p.id === phase.id) || phase; + + if (!updatedPhase.scheduledEndDate) { + this.logger.warn( + `[PHASE CHAIN] Opened phase ${phase.name} (${phase.id}) has no scheduled end date, skipping scheduling`, + ); + return false; + } + + const existingJobId = this.schedulerService.buildJobId( + challengeId, + phase.id, + ); + if (this.schedulerService.getScheduledTransition(existingJobId)) { + this.logger.log( + `[PHASE CHAIN] Phase ${phase.name} (${phase.id}) is already scheduled, skipping`, + ); + return false; + } + + const nextPhaseData: PhaseTransitionPayload = { + projectId, + challengeId, + phaseId: updatedPhase.id, + phaseTypeName: updatedPhase.name, + state: 'END', + operator: AutopilotOperator.SYSTEM_PHASE_CHAIN, + projectStatus, + date: updatedPhase.scheduledEndDate, + }; + + const scheduledJobId = await this.schedulePhaseTransition(nextPhaseData); + this.logger.log( + `[PHASE CHAIN] Scheduled opened phase ${updatedPhase.name} (${updatedPhase.id}) for closure at ${updatedPhase.scheduledEndDate} with job ID: ${scheduledJobId}`, + ); + return true; + } + + private isChallengeActive(status?: string): boolean { + return (status ?? '').toUpperCase() === 'ACTIVE'; + } +} diff --git a/src/autopilot/services/resource-event-handler.service.ts b/src/autopilot/services/resource-event-handler.service.ts new file mode 100644 index 0000000..50aa3a0 --- /dev/null +++ b/src/autopilot/services/resource-event-handler.service.ts @@ -0,0 +1,310 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ChallengeApiService } from '../../challenge/challenge-api.service'; +import { PhaseReviewService } from './phase-review.service'; +import { ReviewAssignmentService } from './review-assignment.service'; +import { ReviewService } from '../../review/review.service'; +import { ResourcesService } from '../../resources/resources.service'; +import { + AutopilotOperator, + ResourceEventPayload, +} from '../interfaces/autopilot.interface'; +import { + ITERATIVE_REVIEW_PHASE_NAME, + PHASE_ROLE_MAP, + REVIEW_PHASE_NAMES, +} from '../constants/review.constants'; +import { First2FinishService } from './first2finish.service'; +import { SchedulerService } from './scheduler.service'; +import { + getNormalizedStringArray, + isActiveStatus, +} from '../utils/config.utils'; + +@Injectable() +export class ResourceEventHandler { + private readonly logger = new Logger(ResourceEventHandler.name); + private readonly reviewRoleNames: Set; + private readonly iterativeRoles: string[]; + + constructor( + private readonly challengeApiService: ChallengeApiService, + private readonly phaseReviewService: PhaseReviewService, + private readonly reviewAssignmentService: ReviewAssignmentService, + private readonly reviewService: ReviewService, + private readonly resourcesService: ResourcesService, + private readonly configService: ConfigService, + private readonly first2FinishService: First2FinishService, + private readonly schedulerService: SchedulerService, + ) { + const postMortemRoles = getNormalizedStringArray( + this.configService.get('autopilot.postMortemRoles'), + ['Reviewer', 'Copilot'], + ); + this.reviewRoleNames = this.computeReviewRoleNames(postMortemRoles); + this.iterativeRoles = PHASE_ROLE_MAP[ITERATIVE_REVIEW_PHASE_NAME] ?? [ + 'Iterative Reviewer', + ]; + } + + async handleResourceCreated(payload: ResourceEventPayload): Promise { + const { challengeId, id: resourceId } = payload; + + if (!challengeId) { + this.logger.warn('Resource created event missing challengeId.', payload); + return; + } + + try { + const resource = await this.resourcesService.getResourceById(resourceId); + const roleName = resource?.roleName + ? resource.roleName.trim() + : await this.resourcesService.getRoleNameById(payload.roleId); + + if (resource && resource.challengeId !== challengeId) { + this.logger.warn( + `Resource ${resourceId} reported for challenge ${challengeId}, but database indicates challenge ${resource.challengeId}. Proceeding with payload challenge ID.`, + ); + } + + if (!roleName) { + this.logger.warn( + `Unable to determine role name for resource ${resourceId} on challenge ${challengeId}.`, + ); + } + + if (roleName && !this.isReviewRole(roleName)) { + this.logger.debug( + `Ignoring resource ${resourceId} with non-review role ${roleName} for challenge ${challengeId}.`, + ); + return; + } + + const challenge = + await this.challengeApiService.getChallengeById(challengeId); + + if (!isActiveStatus(challenge.status)) { + this.logger.debug( + `Skipping resource create for challenge ${challengeId} with status ${challenge.status}.`, + ); + return; + } + + await this.maybeOpenDeferredReviewPhases(challenge); + + const openReviewPhases = challenge.phases?.filter( + (phase) => + phase.isOpen && + REVIEW_PHASE_NAMES.has(phase.name) && + phase.name !== ITERATIVE_REVIEW_PHASE_NAME, + ); + + if (openReviewPhases?.length) { + for (const phase of openReviewPhases) { + try { + await this.phaseReviewService.handlePhaseOpened( + challengeId, + phase.id, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to synchronize pending reviews for phase ${phase.id} on challenge ${challengeId} after resource creation: ${err.message}`, + err.stack, + ); + } + } + } + + if ( + roleName && + this.first2FinishService.isFirst2FinishChallenge(challenge.type) && + this.iterativeRoles.includes(roleName) + ) { + await this.first2FinishService.handleIterativeReviewerAdded(challenge); + } + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to handle resource creation for challenge ${challengeId}: ${err.message}`, + err.stack, + ); + } + } + + async handleResourceDeleted(payload: ResourceEventPayload): Promise { + const { challengeId, id: resourceId } = payload; + + if (!challengeId) { + this.logger.warn('Resource deleted event: Missing challengeId.', payload); + return; + } + + try { + const roleName = await this.resourcesService.getRoleNameById( + payload.roleId, + ); + + if (roleName && !this.isReviewRole(roleName)) { + this.logger.debug( + `Ignoring resource ${resourceId} deletion for non-review role ${roleName} on challenge ${challengeId}.`, + ); + return; + } + + const challenge = + await this.challengeApiService.getChallengeById(challengeId); + + if (!isActiveStatus(challenge.status)) { + return; + } + + const reviewPhases = challenge.phases?.filter((phase) => + REVIEW_PHASE_NAMES.has(phase.name), + ); + + if (reviewPhases?.length) { + for (const phase of reviewPhases) { + try { + await this.reviewService.deletePendingReviewsForResource( + phase.id, + resourceId, + challengeId, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to delete pending reviews for phase ${phase.id} on challenge ${challengeId}: ${err.message}`, + err.stack, + ); + } + + await this.reviewAssignmentService.handleReviewerRemoved( + challengeId, + { + id: phase.id, + phaseId: phase.phaseId, + name: phase.name, + }, + ); + } + } + + if ( + roleName && + this.first2FinishService.isFirst2FinishChallenge(challenge.type) && + this.iterativeRoles.includes(roleName) + ) { + this.logger.warn( + `Iterative reviewer removed from challenge ${challengeId}; awaiting reassignment before continuing F2F processing.`, + ); + } + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to handle resource deletion for challenge ${challengeId}: ${err.message}`, + err.stack, + ); + } + } + + private async maybeOpenDeferredReviewPhases( + challenge: Awaited>, + ): Promise { + const reviewPhases = challenge.phases ?? []; + if (!reviewPhases.length) { + return; + } + + const now = Date.now(); + + for (const phase of reviewPhases) { + if ( + phase.isOpen || + !!phase.actualEndDate || + phase.name === ITERATIVE_REVIEW_PHASE_NAME || + !REVIEW_PHASE_NAMES.has(phase.name) + ) { + continue; + } + + if (phase.scheduledStartDate) { + const scheduledStart = new Date(phase.scheduledStartDate).getTime(); + if (Number.isFinite(scheduledStart) && scheduledStart > now) { + continue; + } + } + + if (phase.predecessor) { + const predecessor = reviewPhases.find( + (candidate) => + candidate.phaseId === phase.predecessor || + candidate.id === phase.predecessor, + ); + + if (!predecessor?.actualEndDate) { + continue; + } + } + + const openPhaseCallback = async (): Promise => { + await this.schedulerService.advancePhase({ + projectId: challenge.projectId, + challengeId: challenge.id, + phaseId: phase.id, + phaseTypeName: phase.name, + state: 'START', + operator: AutopilotOperator.SYSTEM, + projectStatus: challenge.status, + }); + return true; + }; + + let ready = false; + try { + ready = await this.reviewAssignmentService.ensureAssignmentsOrSchedule( + challenge.id, + phase, + openPhaseCallback, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to verify reviewer assignments for phase ${phase.id} on challenge ${challenge.id}: ${err.message}`, + err.stack, + ); + continue; + } + + if (!phase.isOpen && ready) { + try { + await openPhaseCallback(); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to auto-open review phase ${phase.id} on challenge ${challenge.id}: ${err.message}`, + err.stack, + ); + } + } + } + } + + private computeReviewRoleNames(postMortemRoles: string[]): Set { + const roles = new Set(); + for (const names of Object.values(PHASE_ROLE_MAP)) { + for (const name of names) { + roles.add(name); + } + } + postMortemRoles.forEach((role) => roles.add(role)); + return roles; + } + + private isReviewRole(roleName?: string | null): boolean { + if (!roleName) { + return false; + } + return this.reviewRoleNames.has(roleName); + } +} diff --git a/src/autopilot/services/review-assignment.service.ts b/src/autopilot/services/review-assignment.service.ts index 20e025c..77a5a48 100644 --- a/src/autopilot/services/review-assignment.service.ts +++ b/src/autopilot/services/review-assignment.service.ts @@ -5,13 +5,16 @@ import { ChallengeApiService } from '../../challenge/challenge-api.service'; import { ResourcesService } from '../../resources/resources.service'; import { IChallenge, - IChallengeReviewer, IPhase, } from '../../challenge/interfaces/challenge.interface'; import { REVIEW_PHASE_NAMES, getRoleNamesForPhase, } from '../constants/review.constants'; +import { + getMemberReviewerConfigs, + getRequiredReviewerCountForPhase, +} from '../utils/reviewer.utils'; interface PhaseSummary { id: string; @@ -178,6 +181,25 @@ export class ReviewAssignmentService { this.pollers.set(key, context); } + async handleReviewerRemoved( + challengeId: string, + phase: PhaseSummary, + ): Promise { + const status = await this.evaluateAssignmentStatus(challengeId, phase); + + if (status.phaseMissing || status.phaseOpen || status.ready) { + return; + } + + this.logger.warn( + `Reviewer count below requirement for challenge ${challengeId}, phase ${phase.id}. Monitoring for new assignments.`, + ); + + if (!this.pollers.has(this.buildKey(challengeId, phase.id))) { + this.startPolling(challengeId, phase, () => Promise.resolve(true)); + } + } + private async evaluateAssignmentStatus( challengeId: string, phase: PhaseSummary, @@ -223,7 +245,7 @@ export class ReviewAssignmentService { }; } - const reviewerConfigs = this.getReviewerConfigsForPhase( + const reviewerConfigs = getMemberReviewerConfigs( challenge.reviewers, phaseDetails.phaseId, ); @@ -238,10 +260,10 @@ export class ReviewAssignmentService { }; } - const required = reviewerConfigs.reduce((total, config) => { - const count = config.memberReviewerCount ?? 1; - return total + Math.max(count, 0); - }, 0); + const required = getRequiredReviewerCountForPhase( + challenge.reviewers, + phaseDetails.phaseId, + ); if (required === 0) { return { @@ -271,16 +293,6 @@ export class ReviewAssignmentService { }; } - private getReviewerConfigsForPhase( - reviewers: IChallengeReviewer[], - phaseTemplateId: string, - ): IChallengeReviewer[] { - return reviewers.filter( - (reviewer) => - reviewer.isMemberReview && reviewer.phaseId === phaseTemplateId, - ); - } - private buildKey(challengeId: string, phaseId: string): string { return `${challengeId}:${phaseId}:reviewer-assignment`; } diff --git a/src/autopilot/services/scheduler.service.spec.ts b/src/autopilot/services/scheduler.service.spec.ts index cc5ac30..30539d1 100644 --- a/src/autopilot/services/scheduler.service.spec.ts +++ b/src/autopilot/services/scheduler.service.spec.ts @@ -10,11 +10,36 @@ import { ChallengeApiService } from '../../challenge/challenge-api.service'; 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 { ConfigService } from '@nestjs/config'; +import type { IPhase } from '../../challenge/interfaces/challenge.interface'; import { AutopilotOperator, PhaseTransitionPayload, } from '../interfaces/autopilot.interface'; +type MockedMethod any> = jest.Mock< + ReturnType, + Parameters> +>; + +const createMockMethod = any>() => + jest.fn, Parameters>>(); + +type ChallengeApiServiceMock = { + getPhaseDetails: MockedMethod; + advancePhase: MockedMethod; + getChallengePhases: MockedMethod; +}; + +type ReviewServiceMock = { + getPendingReviewCount: MockedMethod; +}; + +type KafkaServiceMock = { + produce: MockedMethod; +}; + const createPayload = ( overrides: Partial = {}, ): PhaseTransitionPayload => ({ @@ -28,24 +53,49 @@ const createPayload = ( ...overrides, }); +const createPhase = (overrides: Partial = {}): IPhase => { + const now = new Date(); + const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000); + + return { + id: 'phase-1', + phaseId: 'phase-1', + name: 'Review', + description: null, + isOpen: true, + duration: 3600, + scheduledStartDate: now.toISOString(), + scheduledEndDate: oneHourLater.toISOString(), + actualStartDate: null, + actualEndDate: null, + predecessor: null, + constraints: [], + ...overrides, + }; +}; + describe('SchedulerService (review phase deferral)', () => { let scheduler: SchedulerService; - let kafkaService: jest.Mocked; - let challengeApiService: jest.Mocked; + let kafkaService: KafkaServiceMock; + let challengeApiService: ChallengeApiServiceMock; let phaseReviewService: jest.Mocked; let challengeCompletionService: jest.Mocked; - let reviewService: jest.Mocked; + let reviewService: ReviewServiceMock; + let resourcesService: jest.Mocked; + let configService: jest.Mocked; beforeEach(() => { kafkaService = { - produce: jest.fn(), - } as unknown as jest.Mocked; + produce: createMockMethod(), + }; challengeApiService = { - getPhaseDetails: jest.fn(), - advancePhase: jest.fn(), - getChallengePhases: jest.fn(), - } as unknown as jest.Mocked; + getPhaseDetails: + createMockMethod(), + advancePhase: createMockMethod(), + getChallengePhases: + createMockMethod(), + }; phaseReviewService = { handlePhaseOpened: jest.fn(), @@ -56,15 +106,28 @@ describe('SchedulerService (review phase deferral)', () => { } as unknown as jest.Mocked; reviewService = { - getPendingReviewCount: jest.fn(), - } as unknown as jest.Mocked; + getPendingReviewCount: + createMockMethod(), + }; + + resourcesService = { + hasSubmitterResource: jest.fn().mockResolvedValue(true), + getResourcesByRoleNames: jest.fn().mockResolvedValue([]), + getReviewerResources: jest.fn().mockResolvedValue([]), + } as unknown as jest.Mocked; + + configService = { + get: jest.fn().mockReturnValue(undefined), + } as unknown as jest.Mocked; scheduler = new SchedulerService( - kafkaService, - challengeApiService, + kafkaService as unknown as KafkaService, + challengeApiService as unknown as ChallengeApiService, phaseReviewService, challengeCompletionService, - reviewService, + reviewService as unknown as ReviewService, + resourcesService, + configService, ); }); @@ -74,13 +137,14 @@ describe('SchedulerService (review phase deferral)', () => { it('defers closing review phases when pending reviews exist', async () => { const payload = createPayload(); - const phaseDetails = { + const phaseDetails = createPhase({ id: payload.phaseId, + phaseId: payload.phaseId, name: 'Review', isOpen: true, - }; + }); - challengeApiService.getPhaseDetails.mockResolvedValue(phaseDetails as any); + challengeApiService.getPhaseDetails.mockResolvedValue(phaseDetails); reviewService.getPendingReviewCount.mockResolvedValue(2); const scheduleSpy = jest @@ -108,25 +172,32 @@ describe('SchedulerService (review phase deferral)', () => { it('closes review phases when no pending reviews remain', async () => { const payload = createPayload(); - const phaseDetails = { + const phaseDetails = createPhase({ id: payload.phaseId, + phaseId: payload.phaseId, name: 'Review', isOpen: true, - }; + }); - challengeApiService.getPhaseDetails.mockResolvedValue(phaseDetails as any); + challengeApiService.getPhaseDetails.mockResolvedValue(phaseDetails); reviewService.getPendingReviewCount.mockResolvedValue(0); - challengeApiService.advancePhase.mockResolvedValue({ + + const advancePhaseResponse: Awaited< + ReturnType + > = { success: true, message: 'closed', updatedPhases: [ - { + createPhase({ id: payload.phaseId, + phaseId: payload.phaseId, isOpen: false, actualEndDate: new Date().toISOString(), - }, + }), ], - } as any); + }; + + challengeApiService.advancePhase.mockResolvedValue(advancePhaseResponse); await scheduler.advancePhase(payload); diff --git a/src/autopilot/services/scheduler.service.ts b/src/autopilot/services/scheduler.service.ts index 52f4743..72eca1e 100644 --- a/src/autopilot/services/scheduler.service.ts +++ b/src/autopilot/services/scheduler.service.ts @@ -4,6 +4,7 @@ import { OnModuleDestroy, OnModuleInit, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { KafkaService } from '../../kafka/kafka.service'; import { ChallengeApiService } from '../../challenge/challenge-api.service'; import { PhaseReviewService } from './phase-review.service'; @@ -15,8 +16,21 @@ import { } from '../interfaces/autopilot.interface'; import { KAFKA_TOPICS } from '../../kafka/constants/topics'; import { Job, Queue, RedisOptions, Worker } from 'bullmq'; +import { ChallengeStatusEnum } from '@prisma/client'; import { ReviewService } from '../../review/review.service'; -import { REVIEW_PHASE_NAMES } from '../constants/review.constants'; +import { + POST_MORTEM_PHASE_NAME, + REGISTRATION_PHASE_NAME, + REVIEW_PHASE_NAMES, + SUBMISSION_PHASE_NAME, + TOPGEAR_SUBMISSION_PHASE_NAME, +} from '../constants/review.constants'; +import { ResourcesService } from '../../resources/resources.service'; +import { isTopgearTaskChallenge } from '../constants/challenge.constants'; +import { + IChallenge, + IPhase, +} from '../../challenge/interfaces/challenge.interface'; const PHASE_QUEUE_NAME = 'autopilot-phase-transitions'; const PHASE_QUEUE_PREFIX = '{autopilot-phase-transitions}'; @@ -41,6 +55,14 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { private readonly reviewCloseRetryAttempts = new Map(); private readonly reviewCloseRetryBaseDelayMs = 10 * 60 * 1000; private readonly reviewCloseRetryMaxDelayMs = 60 * 60 * 1000; + private readonly registrationCloseRetryAttempts = new Map(); + private readonly registrationCloseRetryBaseDelayMs = 5 * 60 * 1000; + private readonly registrationCloseRetryMaxDelayMs = 30 * 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 redisConnection?: RedisOptions; private phaseQueue?: Queue; @@ -53,7 +75,27 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { private readonly phaseReviewService: PhaseReviewService, private readonly challengeCompletionService: ChallengeCompletionService, private readonly reviewService: ReviewService, - ) {} + private readonly resourcesService: ResourcesService, + private readonly configService: ConfigService, + ) { + this.submitterRoles = this.getStringArray('autopilot.submitterRoles', [ + 'Submitter', + ]); + this.postMortemRoles = this.getStringArray('autopilot.postMortemRoles', [ + 'Reviewer', + 'Copilot', + ]); + this.postMortemScorecardId = + this.configService.get( + 'autopilot.postMortemScorecardId', + ) ?? null; + this.topgearPostMortemScorecardId = + this.configService.get( + 'autopilot.topgearPostMortemScorecardId', + ) ?? null; + this.postMortemDurationHours = + this.configService.get('autopilot.postMortemDurationHours') ?? 72; + } private async ensureInitialized(): Promise { if (!this.initializationPromise) { @@ -166,7 +208,7 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { phaseData: PhaseTransitionPayload, ): Promise { const { challengeId, phaseId, date: endTime } = phaseData; - const jobId = `${challengeId}|${phaseId}`; // BullMQ rejects ':' in custom IDs, use pipe instead + const jobId = this.buildJobId(challengeId, phaseId); // BullMQ rejects ':' in custom IDs, use pipe instead if (!endTime || endTime === '' || isNaN(new Date(endTime).getTime())) { this.logger.error( @@ -294,6 +336,10 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { return this.scheduledJobs.get(jobId); } + buildJobId(challengeId: string, phaseId: string): string { + return `${challengeId}|${phaseId}`; + } + public async triggerKafkaEvent(data: PhaseTransitionPayload) { // Validate phase state before sending the event const phaseDetails = await this.challengeApiService.getPhaseDetails( @@ -364,11 +410,18 @@ 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, + ); // Determine operation based on transition state and current phase state let operation: 'open' | 'close'; @@ -398,9 +451,31 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { } const isReviewPhase = - REVIEW_PHASE_NAMES.has(phaseDetails.name) || + REVIEW_PHASE_NAMES.has(phaseName) || REVIEW_PHASE_NAMES.has(data.phaseTypeName); + if (operation === 'close' && phaseName === REGISTRATION_PHASE_NAME) { + try { + const hasSubmitter = await this.resourcesService.hasSubmitterResource( + data.challengeId, + this.submitterRoles, + ); + + if (!hasSubmitter) { + await this.deferRegistrationPhaseClosure(data); + return; + } + } catch (error) { + const err = error as Error; + this.logger.error( + `[REGISTRATION] Failed to verify submitter resources for challenge ${data.challengeId}: ${err.message}`, + err.stack, + ); + await this.deferRegistrationPhaseClosure(data); + return; + } + } + if (operation === 'close' && isReviewPhase) { try { const pendingReviews = await this.reviewService.getPendingReviewCount( @@ -424,6 +499,20 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { } } + if ( + operation === 'close' && + isTopgearSubmissionPhase && + isSchedulerInitiated + ) { + const handled = await this.handleTopgearSubmissionLate( + data, + phaseDetails, + ); + if (handled) { + return; + } + } + this.logger.log( `Phase ${data.phaseId} is currently ${phaseDetails.isOpen ? 'open' : 'closed'}, will ${operation} it`, ); @@ -439,12 +528,21 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { `Successfully advanced phase ${data.phaseId} for challenge ${data.challengeId}: ${result.message}`, ); + let skipPhaseChain = false; + let skipFinalization = false; + if (operation === 'close' && isReviewPhase) { this.reviewCloseRetryAttempts.delete( this.buildReviewPhaseKey(data.challengeId, data.phaseId), ); } + if (operation === 'close' && phaseName === REGISTRATION_PHASE_NAME) { + this.registrationCloseRetryAttempts.delete( + this.buildRegistrationPhaseKey(data.challengeId, data.phaseId), + ); + } + if (operation === 'open') { try { await this.phaseReviewService.handlePhaseOpened( @@ -461,6 +559,33 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { } if (operation === 'close') { + if (phaseName === SUBMISSION_PHASE_NAME) { + try { + const handled = await this.handleSubmissionPhaseClosed(data); + if (handled) { + skipPhaseChain = true; + skipFinalization = true; + } + } catch (error) { + const err = error as Error; + this.logger.error( + `[ZERO SUBMISSIONS] Unable to process post-submission workflow for challenge ${data.challengeId}: ${err.message}`, + err.stack, + ); + } + } else if (phaseName === POST_MORTEM_PHASE_NAME) { + try { + await this.handlePostMortemPhaseClosed(data); + skipFinalization = true; + } catch (error) { + const err = error as Error; + this.logger.error( + `[ZERO SUBMISSIONS] Failed to cancel challenge ${data.challengeId} after post-mortem closure: ${err.message}`, + err.stack, + ); + } + } + try { let phases = result.updatedPhases; if (!phases || !phases.length) { @@ -474,7 +599,12 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { phases?.some((phase) => !phase.actualEndDate) ?? true; const hasNextPhases = Boolean(result.next?.phases?.length); - if (!hasOpenPhases && !hasNextPhases && !hasIncompletePhases) { + if ( + !skipFinalization && + !hasOpenPhases && + !hasNextPhases && + !hasIncompletePhases + ) { await this.attemptChallengeFinalization(data.challengeId); } else { const pendingCount = phases?.reduce((pending, phase) => { @@ -495,6 +625,7 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { // Handle phase transition chain - open and schedule next phases if they exist if ( + !skipPhaseChain && result.next?.operation === 'open' && result.next.phases && result.next.phases.length > 0 @@ -527,10 +658,14 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { err.stack, ); } - } else { + } else if (!skipPhaseChain) { this.logger.log( `[PHASE CHAIN] No next phases to open and schedule for challenge ${data.challengeId}`, ); + } else { + this.logger.log( + `[PHASE CHAIN] Skipped automatic phase chaining for challenge ${data.challengeId} due to zero-submission workflow.`, + ); } } else { this.logger.error( @@ -628,6 +763,328 @@ 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; + } + + private isSchedulerInitiatedOperator( + operator?: AutopilotOperator | string, + ): boolean { + if (!operator) { + return false; + } + + const candidate = operator.toString().toLowerCase(); + return ( + candidate === AutopilotOperator.SYSTEM_SCHEDULER || + candidate === AutopilotOperator.SYSTEM_PHASE_CHAIN + ); + } + + private async handleTopgearSubmissionLate( + data: PhaseTransitionPayload, + phase: IPhase, + ): Promise { + try { + const challenge = + await this.challengeApiService.getChallengeById(data.challengeId); + + if (!isTopgearTaskChallenge(challenge.type)) { + return false; + } + + 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, + ); + + if (submissionCount === 0) { + await this.ensureTopgearPostMortemReview(challenge); + } + + return true; + } catch (error) { + const err = error as Error; + this.logger.error( + `[TOPGEAR] Failed to defer submission closure for challenge ${data.challengeId}, phase ${data.phaseId}: ${err.message}`, + err.stack, + ); + return true; + } + } + + private async ensureTopgearPostMortemReview( + challenge: IChallenge, + ): Promise { + if (!this.topgearPostMortemScorecardId) { + this.logger.warn( + `[TOPGEAR] topgearPostMortemScorecardId is not configured; unable to create creator review for challenge ${challenge.id}.`, + ); + return; + } + + 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.`, + ); + return; + } + + const creatorHandle = challenge.createdBy?.trim(); + if (!creatorHandle) { + this.logger.warn( + `[TOPGEAR] Challenge ${challenge.id} missing creator handle; post-mortem review not created.`, + ); + return; + } + + try { + const creatorResource = await this.resourcesService.getResourceByMemberHandle( + challenge.id, + creatorHandle, + ); + + if (!creatorResource) { + this.logger.warn( + `[TOPGEAR] Unable to locate resource for creator ${creatorHandle} on challenge ${challenge.id}; post-mortem review not created.`, + ); + 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, + ); + } + } + + private async handleSubmissionPhaseClosed( + data: PhaseTransitionPayload, + ): Promise { + try { + const submissionCount = await this.reviewService.getActiveSubmissionCount( + data.challengeId, + ); + + if (submissionCount > 0) { + return false; + } + + this.logger.log( + `[ZERO SUBMISSIONS] No active submissions found for challenge ${data.challengeId}; transitioning to Post-Mortem phase.`, + ); + + const postMortemPhase = + await this.challengeApiService.createPostMortemPhase( + data.challengeId, + data.phaseId, + this.postMortemDurationHours, + ); + + await this.createPostMortemPendingReviews( + data.challengeId, + postMortemPhase.id, + ); + + if (!postMortemPhase.scheduledEndDate) { + this.logger.warn( + `[ZERO SUBMISSIONS] 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 SUBMISSIONS] 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 SUBMISSIONS] Failed to prepare Post-Mortem workflow for challenge ${data.challengeId}: ${err.message}`, + err.stack, + ); + throw err; + } + } + + private async createPostMortemPendingReviews( + challengeId: string, + phaseId: string, + ): Promise { + if (!this.postMortemScorecardId) { + this.logger.warn( + `[ZERO SUBMISSIONS] Post-mortem scorecard ID is not configured; skipping review creation for challenge ${challengeId}.`, + ); + return; + } + + try { + const resources = await this.resourcesService.getResourcesByRoleNames( + challengeId, + this.postMortemRoles, + ); + + if (!resources.length) { + this.logger.log( + `[ZERO SUBMISSIONS] No resources found for post-mortem roles on challenge ${challengeId}; skipping review creation.`, + ); + return; + } + + let createdCount = 0; + for (const resource of resources) { + 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 SUBMISSIONS] Failed to create post-mortem review for challenge ${challengeId}, resource ${resource.id}: ${err.message}`, + err.stack, + ); + } + } + + this.logger.log( + `[ZERO SUBMISSIONS] Created ${createdCount} post-mortem pending review(s) for challenge ${challengeId}.`, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `[ZERO SUBMISSIONS] Unable to prepare 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, + ); + + this.logger.log( + `[ZERO SUBMISSIONS] Marked challenge ${data.challengeId} as CANCELLED_ZERO_SUBMISSIONS after Post-Mortem completion.`, + ); + } + + private async deferRegistrationPhaseClosure( + data: PhaseTransitionPayload, + ): Promise { + 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( + `[REGISTRATION] Deferred closing registration phase ${data.phaseId} for challenge ${data.challengeId}; awaiting first submitter. Retrying in ${Math.round(delay / 60000)} minute(s).`, + ); + } catch (error) { + const err = error as Error; + this.registrationCloseRetryAttempts.delete(key); + this.logger.error( + `[REGISTRATION] Failed to reschedule registration closure for challenge ${data.challengeId}: ${err.message}`, + err.stack, + ); + throw err; + } + } + + private computeRegistrationCloseRetryDelay(attempt: number): number { + const multiplier = Math.max(attempt, 1); + const delay = this.registrationCloseRetryBaseDelayMs * multiplier; + return Math.min(delay, this.registrationCloseRetryMaxDelayMs); + } + + private buildRegistrationPhaseKey( + challengeId: string, + phaseId: string, + ): string { + return `${challengeId}|${phaseId}|registration-close`; + } + private async deferReviewPhaseClosure( data: PhaseTransitionPayload, pendingCount?: number, diff --git a/src/autopilot/utils/config.utils.ts b/src/autopilot/utils/config.utils.ts new file mode 100644 index 0000000..757e45f --- /dev/null +++ b/src/autopilot/utils/config.utils.ts @@ -0,0 +1,39 @@ +import { AutopilotOperator } from '../interfaces/autopilot.interface'; + +export function getNormalizedStringArray( + source: unknown, + fallback: string[], +): string[] { + if (Array.isArray(source)) { + const normalized = source + .map((item) => (typeof item === 'string' ? item.trim() : String(item))) + .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; +} + +export function isActiveStatus(status?: string): boolean { + return (status ?? '').toUpperCase() === 'ACTIVE'; +} + +export function parseOperator(operator?: AutopilotOperator | string): string { + return typeof operator === 'string' + ? operator + : (operator ?? AutopilotOperator.SYSTEM); +} diff --git a/src/autopilot/utils/reviewer.utils.ts b/src/autopilot/utils/reviewer.utils.ts new file mode 100644 index 0000000..e0ab2e4 --- /dev/null +++ b/src/autopilot/utils/reviewer.utils.ts @@ -0,0 +1,57 @@ +import { IChallengeReviewer } from '../../challenge/interfaces/challenge.interface'; + +export function getMemberReviewerConfigs( + reviewers: IChallengeReviewer[] | undefined, + phaseTemplateId: string, +): IChallengeReviewer[] { + if (!reviewers?.length) { + return []; + } + + return reviewers.filter( + (reviewer) => + reviewer.isMemberReview && reviewer.phaseId === phaseTemplateId, + ); +} + +export function getRequiredReviewerCountForPhase( + reviewers: IChallengeReviewer[] | undefined, + phaseTemplateId: string, +): number { + const configs = getMemberReviewerConfigs(reviewers, phaseTemplateId); + + if (!configs.length) { + return 0; + } + + return configs.reduce((total, config) => { + const count = config.memberReviewerCount ?? 1; + return total + Math.max(count, 0); + }, 0); +} + +export function selectScorecardId( + reviewers: IChallengeReviewer[], + onMissing?: () => void | null, + onMultiple?: (choices: Array) => void | null, + phaseTemplateId?: string, +): string | null { + const configs = phaseTemplateId + ? getMemberReviewerConfigs(reviewers, phaseTemplateId) + : reviewers.filter((reviewer) => reviewer.isMemberReview); + + const uniqueScorecards = Array.from( + new Set(configs.map((config) => config.scorecardId).filter(Boolean)), + ); + + if (uniqueScorecards.length === 0) { + onMissing?.(); + return null; + } + + if (uniqueScorecards.length > 1) { + onMultiple?.(uniqueScorecards); + } + + return uniqueScorecards[0] ?? null; +} diff --git a/src/challenge/challenge-api.service.spec.ts b/src/challenge/challenge-api.service.spec.ts new file mode 100644 index 0000000..9de76ce --- /dev/null +++ b/src/challenge/challenge-api.service.spec.ts @@ -0,0 +1,266 @@ +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'; + +describe('ChallengeApiService - advancePhase scheduling', () => { + const fixedNow = new Date('2025-09-27T06:00:00.000Z'); + const futureStart = new Date('2025-09-27T07:00:00.000Z'); + const phaseDurationSeconds = 7200; + const futureEnd = new Date(futureStart.getTime() + phaseDurationSeconds * 1000); + + let prisma: jest.Mocked; + let dbLogger: jest.Mocked; + let challengePhaseUpdate: jest.Mock; + let challengeUpdate: jest.Mock; + let challengeFindUnique: jest.Mock; + let service: ChallengeApiService; + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(fixedNow); + + challengePhaseUpdate = jest.fn().mockResolvedValue(undefined); + challengeUpdate = jest.fn().mockResolvedValue(undefined); + + challengeFindUnique = jest.fn(); + + prisma = { + challenge: { + findUnique: challengeFindUnique, + }, + challengePhase: { + update: challengePhaseUpdate, + }, + $transaction: jest.fn(), + } as unknown as jest.Mocked; + + prisma.$transaction.mockImplementation(async (cb) => { + await cb({ + challengePhase: { update: challengePhaseUpdate }, + challenge: { update: challengeUpdate }, + } as unknown as ChallengePrismaService); + }); + + dbLogger = { + logAction: jest.fn(), + } as unknown as jest.Mocked; + + service = new ChallengeApiService(prisma, dbLogger); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('pulls forward the scheduled dates when opening a phase early', async () => { + const reviewPhase = { + id: 'phase-1', + phaseId: 'template-1', + name: 'Review', + description: null, + isOpen: false, + predecessor: null, + duration: phaseDurationSeconds, + scheduledStartDate: futureStart, + scheduledEndDate: futureEnd, + actualStartDate: null, + actualEndDate: null, + constraints: [], + createdAt: fixedNow, + createdBy: 'tester', + updatedAt: fixedNow, + updatedBy: 'tester', + }; + + const appealsPhase = { + id: 'phase-2', + phaseId: 'template-2', + name: 'Appeals', + description: null, + isOpen: false, + predecessor: reviewPhase.phaseId, + duration: 3600, + scheduledStartDate: new Date(futureEnd.getTime()), + scheduledEndDate: new Date(futureEnd.getTime() + 3600 * 1000), + actualStartDate: null, + actualEndDate: null, + constraints: [], + createdAt: fixedNow, + createdBy: 'tester', + updatedAt: fixedNow, + updatedBy: 'tester', + }; + + const challengeRecord = { + id: 'challenge-1', + name: 'Test Challenge', + description: null, + descriptionFormat: 'markdown', + projectId: 123, + typeId: 'type-1', + trackId: 'track-1', + timelineTemplateId: 'timeline-1', + currentPhaseNames: [], + tags: [], + groups: [], + submissionStartDate: fixedNow, + submissionEndDate: fixedNow, + registrationStartDate: fixedNow, + registrationEndDate: fixedNow, + startDate: fixedNow, + endDate: null, + legacyId: null, + status: ChallengeStatusEnum.ACTIVE, + createdBy: 'tester', + updatedBy: 'tester', + metadata: [], + phases: [reviewPhase, 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: fixedNow, + }; + + challengeFindUnique + .mockResolvedValueOnce(challengeRecord as any) + .mockResolvedValueOnce(challengeRecord as any); + + await service.advancePhase('challenge-1', 'phase-1', 'open'); + + expect(challengeFindUnique).toHaveBeenCalled(); + expect(challengePhaseUpdate).toHaveBeenCalledWith({ + where: { id: reviewPhase.id }, + data: expect.objectContaining({ + scheduledStartDate: fixedNow, + scheduledEndDate: new Date(fixedNow.getTime() + phaseDurationSeconds * 1000), + duration: phaseDurationSeconds, + }), + }); + }); +}); + +describe('ChallengeApiService - end date handling', () => { + const fixedNow = new Date('2025-01-15T12:30:00.000Z'); + + let prisma: jest.Mocked; + let dbLogger: jest.Mocked; + let service: ChallengeApiService; + let challengeUpdate: jest.Mock; + let challengeWinnerDeleteMany: jest.Mock; + let challengeWinnerCreateMany: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(fixedNow); + + challengeUpdate = jest.fn().mockResolvedValue(undefined); + challengeWinnerDeleteMany = jest.fn().mockResolvedValue(undefined); + challengeWinnerCreateMany = jest.fn().mockResolvedValue(undefined); + + prisma = { + challenge: { + update: challengeUpdate, + }, + challengeWinner: { + deleteMany: challengeWinnerDeleteMany, + createMany: challengeWinnerCreateMany, + }, + $transaction: jest.fn(), + } as unknown as jest.Mocked; + + prisma.$transaction.mockImplementation(async (callback) => { + await callback({ + challenge: { update: challengeUpdate }, + challengeWinner: { + deleteMany: challengeWinnerDeleteMany, + createMany: challengeWinnerCreateMany, + }, + } as unknown as ChallengePrismaService); + }); + + dbLogger = { + logAction: jest.fn(), + } as unknown as jest.Mocked; + + service = new ChallengeApiService(prisma, dbLogger); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('sets the endDate when completing a challenge', async () => { + const winners = [ + { userId: 123, handle: 'winner', placement: 1 }, + { userId: 456, handle: 'runner-up', placement: 2 }, + ]; + + await service.completeChallenge('challenge-123', winners); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(challengeUpdate).toHaveBeenCalledWith({ + where: { id: 'challenge-123' }, + data: { + status: ChallengeStatusEnum.COMPLETED, + endDate: fixedNow, + }, + }); + expect(challengeWinnerDeleteMany).toHaveBeenCalledWith({ + where: { challengeId: 'challenge-123' }, + }); + expect(challengeWinnerCreateMany).toHaveBeenCalledWith({ + data: expect.arrayContaining([ + expect.objectContaining({ challengeId: 'challenge-123' }), + ]), + }); + expect(dbLogger.logAction).toHaveBeenCalledWith( + 'challenge.completeChallenge', + expect.objectContaining({ + details: expect.objectContaining({ + winnersCount: winners.length, + endDate: fixedNow.toISOString(), + }), + }), + ); + }); + + it('sets the endDate when cancelling a challenge', async () => { + await service.cancelChallenge( + 'challenge-456', + ChallengeStatusEnum.CANCELLED_FAILED_REVIEW, + ); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(challengeUpdate).toHaveBeenCalledWith({ + where: { id: 'challenge-456' }, + data: { + status: ChallengeStatusEnum.CANCELLED_FAILED_REVIEW, + endDate: fixedNow, + }, + }); + expect(challengeWinnerDeleteMany).toHaveBeenCalledWith({ + where: { challengeId: 'challenge-456' }, + }); + expect(challengeWinnerCreateMany).not.toHaveBeenCalled(); + expect(dbLogger.logAction).toHaveBeenCalledWith( + 'challenge.cancelChallenge', + expect.objectContaining({ + details: { + status: ChallengeStatusEnum.CANCELLED_FAILED_REVIEW, + endDate: fixedNow.toISOString(), + }, + }), + ); + }); +}); diff --git a/src/challenge/challenge-api.service.ts b/src/challenge/challenge-api.service.ts index d852b6d..ec37187 100644 --- a/src/challenge/challenge-api.service.ts +++ b/src/challenge/challenge-api.service.ts @@ -336,6 +336,18 @@ export class ChallengeApiService { const currentPhaseNames = new Set( challenge.currentPhaseNames || [], ); + const scheduledStartDate = targetPhase.scheduledStartDate + ? new Date(targetPhase.scheduledStartDate) + : null; + const durationSeconds = this.computePhaseDurationSeconds(targetPhase); + const shouldAdjustSchedule = + operation === 'open' && + scheduledStartDate !== null && + durationSeconds !== null && + scheduledStartDate.getTime() - now.getTime() > 1000; + const adjustedEndDate = shouldAdjustSchedule + ? new Date(now.getTime() + durationSeconds * 1000) + : null; try { await this.prisma.$transaction(async (tx) => { @@ -347,6 +359,13 @@ export class ChallengeApiService { isOpen: true, actualStartDate: targetPhase.actualStartDate ?? now, actualEndDate: null, + ...(shouldAdjustSchedule + ? { + scheduledStartDate: now, + scheduledEndDate: adjustedEndDate!, + duration: durationSeconds!, + } + : {}), }, }); } else { @@ -453,6 +472,7 @@ export class ChallengeApiService { operation, hasWinningSubmission, nextPhaseCount: nextPhases?.length ?? 0, + scheduleAdjusted: shouldAdjustSchedule, }, }); @@ -473,6 +493,26 @@ export class ChallengeApiService { } } + private computePhaseDurationSeconds( + phase: ChallengePhaseWithConstraints, + ): number | null { + if (typeof phase.duration === 'number' && phase.duration > 0) { + return phase.duration; + } + + if (phase.scheduledStartDate && phase.scheduledEndDate) { + const startMs = new Date(phase.scheduledStartDate).getTime(); + const endMs = new Date(phase.scheduledEndDate).getTime(); + const diffSeconds = Math.round((endMs - startMs) / 1000); + + if (Number.isFinite(diffSeconds) && diffSeconds > 0) { + return diffSeconds; + } + } + + return null; + } + private mapChallenge(challenge: ChallengeWithRelations): IChallenge { return { id: challenge.id, @@ -594,15 +634,258 @@ export class ChallengeApiService { }; } + async createPostMortemPhase( + challengeId: string, + submissionPhaseId: string, + durationHours: number, + ): Promise { + const now = new Date(); + const end = new Date(now.getTime() + durationHours * 60 * 60 * 1000); + + try { + const { createdPhaseId } = await this.prisma.$transaction(async (tx) => { + 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 futurePhaseIds = challenge.phases + .slice(submissionPhaseIndex + 1) + .map((phase) => phase.id); + + if (futurePhaseIds.length) { + await tx.challengePhase.deleteMany({ + where: { id: { in: futurePhaseIds } }, + }); + } + + 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: Math.max( + Math.round((end.getTime() - now.getTime()) / 1000), + 1, + ), + scheduledStartDate: now, + scheduledEndDate: end, + actualStartDate: now, + isOpen: true, + createdBy: 'Autopilot', + updatedBy: 'Autopilot', + }, + }); + + await tx.challenge.update({ + where: { id: challengeId }, + data: { + currentPhaseNames: [postMortemPhaseType.name], + }, + }); + + return { createdPhaseId: created.id }; + }); + + const refreshed = await this.prisma.challenge.findUnique({ + ...challengeWithRelationsArgs, + where: { id: challengeId }, + }); + + const phaseRecord = refreshed?.phases.find( + (phase) => phase.id === createdPhaseId, + ); + + if (!phaseRecord) { + throw new Error( + `Created post-mortem phase ${createdPhaseId} not found after insertion 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, + }, + }); + + return mapped; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('challenge.createPostMortemPhase', { + challengeId, + status: 'ERROR', + source: ChallengeApiService.name, + details: { + submissionPhaseId, + durationHours, + error: err.message, + }, + }); + throw err; + } + } + + async createIterativeReviewPhase( + challengeId: string, + predecessorPhaseId: string, + phaseTypeId: string, + phaseName: string, + phaseDescription: string | null, + durationSeconds: number, + ): Promise { + const now = new Date(); + const scheduledEnd = new Date(now.getTime() + durationSeconds * 1000); + + try { + const { newPhaseId } = await this.prisma.$transaction(async (tx) => { + const challenge = await tx.challenge.findUnique({ + ...challengeWithRelationsArgs, + where: { id: challengeId }, + }); + + if (!challenge) { + throw new NotFoundException( + `Challenge with ID ${challengeId} not found when creating iterative review phase.`, + ); + } + + const predecessorPhase = challenge.phases.find( + (phase) => phase.id === predecessorPhaseId, + ); + + if (!predecessorPhase) { + throw new NotFoundException( + `Predecessor phase ${predecessorPhaseId} not found for challenge ${challengeId}.`, + ); + } + + const created = await tx.challengePhase.create({ + data: { + challengeId, + phaseId: phaseTypeId, + name: phaseName, + description: phaseDescription, + predecessor: predecessorPhase.id, + duration: Math.max(durationSeconds, 1), + scheduledStartDate: now, + scheduledEndDate: scheduledEnd, + actualStartDate: now, + actualEndDate: null, + isOpen: true, + createdBy: 'Autopilot', + updatedBy: 'Autopilot', + }, + }); + + const phaseNames = new Set(challenge.currentPhaseNames ?? []); + phaseNames.add(phaseName); + + await tx.challenge.update({ + where: { id: challengeId }, + data: { + currentPhaseNames: Array.from(phaseNames), + }, + }); + + return { newPhaseId: created.id }; + }); + + const refreshed = await this.prisma.challenge.findUnique({ + ...challengeWithRelationsArgs, + where: { id: challengeId }, + }); + + const phaseRecord = refreshed?.phases.find( + (phase) => phase.id === newPhaseId, + ); + + if (!phaseRecord) { + throw new Error( + `Created iterative review phase ${newPhaseId} not found after insertion for challenge ${challengeId}.`, + ); + } + + const mapped = this.mapPhase(phaseRecord); + + void this.dbLogger.logAction('challenge.createIterativeReviewPhase', { + challengeId, + status: 'SUCCESS', + source: ChallengeApiService.name, + details: { + predecessorPhaseId, + phaseId: mapped.id, + phaseTypeId, + duration: mapped.duration, + }, + }); + + return mapped; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('challenge.createIterativeReviewPhase', { + challengeId, + status: 'ERROR', + source: ChallengeApiService.name, + details: { + predecessorPhaseId, + phaseTypeId, + error: err.message, + }, + }); + throw error; + } + } + async completeChallenge( challengeId: string, winners: IChallengeWinner[], ): Promise { try { + const endDate = new Date(); await this.prisma.$transaction(async (tx) => { await tx.challenge.update({ where: { id: challengeId }, - data: { status: ChallengeStatusEnum.COMPLETED }, + data: { + status: ChallengeStatusEnum.COMPLETED, + endDate, + }, }); await tx.challengeWinner.deleteMany({ where: { challengeId } }); @@ -626,7 +909,7 @@ export class ChallengeApiService { challengeId, status: 'SUCCESS', source: ChallengeApiService.name, - details: { winnersCount: winners.length }, + details: { winnersCount: winners.length, endDate: endDate.toISOString() }, }); } catch (error) { const err = error as Error; @@ -634,7 +917,46 @@ export class ChallengeApiService { challengeId, status: 'ERROR', source: ChallengeApiService.name, - details: { winnersCount: winners.length, error: err.message }, + details: { + winnersCount: winners.length, + error: err.message, + }, + }); + throw err; + } + } + + async cancelChallenge( + challengeId: string, + status: ChallengeStatusEnum, + ): Promise { + try { + const endDate = new Date(); + await this.prisma.$transaction(async (tx) => { + await tx.challenge.update({ + where: { id: challengeId }, + data: { + status, + endDate, + }, + }); + + await tx.challengeWinner.deleteMany({ where: { challengeId } }); + }); + + void this.dbLogger.logAction('challenge.cancelChallenge', { + challengeId, + status: 'SUCCESS', + source: ChallengeApiService.name, + details: { status, endDate: endDate.toISOString() }, + }); + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('challenge.cancelChallenge', { + challengeId, + status: 'ERROR', + source: ChallengeApiService.name, + details: { status, error: err.message }, }); throw err; } diff --git a/src/config/sections/autopilot.config.ts b/src/config/sections/autopilot.config.ts index 76a8cdd..a3cae4a 100644 --- a/src/config/sections/autopilot.config.ts +++ b/src/config/sections/autopilot.config.ts @@ -1,6 +1,47 @@ import { registerAs } from '@nestjs/config'; +function parseList(value: string | undefined, fallback: string[]): string[] { + if (!value) { + return fallback; + } + + return value + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +function parseNumber(value: string | undefined, fallback: number): number { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + export default registerAs('autopilot', () => ({ dbUrl: process.env.AUTOPILOT_DB_URL, dbDebug: process.env.DB_DEBUG === 'true', + postMortemScorecardId: process.env.POST_MORTEM_SCORECARD_ID || null, + topgearPostMortemScorecardId: + process.env.TOPGEAR_POST_MORTEM_SCORECARD_ID || null, + postMortemDurationHours: parseNumber( + process.env.POST_MORTEM_DURATION_HOURS, + 72, + ), + postMortemRoles: parseList(process.env.POST_MORTEM_REVIEW_ROLES, [ + 'Reviewer', + 'Copilot', + ]), + submitterRoles: parseList(process.env.SUBMITTER_ROLE_NAMES, ['Submitter']), + iterativeReviewDurationHours: parseNumber( + process.env.ITERATIVE_REVIEW_DURATION_HOURS, + 24, + ), + iterativeReviewAssignmentRetrySeconds: parseNumber( + process.env.ITERATIVE_REVIEW_ASSIGNMENT_RETRY_SECONDS, + 30, + ), + appealsPhaseNames: parseList(process.env.APPEALS_PHASE_NAMES, ['Appeals']), + appealsResponsePhaseNames: parseList( + process.env.APPEALS_RESPONSE_PHASE_NAMES, + ['Appeals Response'], + ), })); diff --git a/src/config/validation.ts b/src/config/validation.ts index a121fd0..152444c 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -55,6 +55,17 @@ export const validationSchema = Joi.object({ .integer() .positive() .default(5 * 60 * 1000), + POST_MORTEM_SCORECARD_ID: Joi.string().optional().allow(null, ''), + TOPGEAR_POST_MORTEM_SCORECARD_ID: Joi.string().optional().allow(null, ''), + POST_MORTEM_DURATION_HOURS: Joi.number().integer().positive().default(72), + POST_MORTEM_REVIEW_ROLES: Joi.string().default('Reviewer,Copilot'), + SUBMITTER_ROLE_NAMES: Joi.string().default('Submitter'), + ITERATIVE_REVIEW_DURATION_HOURS: Joi.number() + .integer() + .positive() + .default(24), + APPEALS_PHASE_NAMES: Joi.string().default('Appeals'), + APPEALS_RESPONSE_PHASE_NAMES: Joi.string().default('Appeals Response'), // Auth0 Configuration (optional in test environment) AUTH0_URL: Joi.string() diff --git a/src/kafka/constants/topics.ts b/src/kafka/constants/topics.ts index 7bbfa28..af8724c 100644 --- a/src/kafka/constants/topics.ts +++ b/src/kafka/constants/topics.ts @@ -5,6 +5,12 @@ export const KAFKA_TOPICS = { CHALLENGE_UPDATED: 'challenge.notification.update', COMMAND: 'autopilot.command', SUBMISSION_NOTIFICATION_AGGREGATE: 'submission.notification.aggregate', + RESOURCE_CREATED: 'challenge.action.resource.create', + RESOURCE_DELETED: 'challenge.action.resource.delete', + REVIEW_COMPLETED: 'review.action.completed', + REVIEW_APPEAL_RESPONDED: 'review.action.appeal.responded', + FIRST2FINISH_SUBMISSION_RECEIVED: 'first2finish.submission.received', + TOPGEAR_SUBMISSION_RECEIVED: 'topgear.submission.received', } as const; export type KafkaTopic = (typeof KAFKA_TOPICS)[keyof typeof KAFKA_TOPICS]; diff --git a/src/kafka/consumers/autopilot.consumer.ts b/src/kafka/consumers/autopilot.consumer.ts index 2f98e56..4818e2f 100644 --- a/src/kafka/consumers/autopilot.consumer.ts +++ b/src/kafka/consumers/autopilot.consumer.ts @@ -7,7 +7,12 @@ import { TopicPayloadMap } from '../types/topic-payload-map.type'; import { ChallengeUpdatePayload, CommandPayload, + AppealRespondedPayload, + First2FinishSubmissionPayload, + TopgearSubmissionPayload, PhaseTransitionPayload, + ResourceEventPayload, + ReviewCompletedPayload, SubmissionAggregatePayload, } from 'src/autopilot/interfaces/autopilot.interface'; @@ -49,6 +54,30 @@ export class AutopilotConsumer { this.autopilotService.handleSubmissionNotificationAggregate.bind( this.autopilotService, ) as (message: SubmissionAggregatePayload) => Promise, + [KAFKA_TOPICS.RESOURCE_CREATED]: + this.autopilotService.handleResourceCreated.bind( + this.autopilotService, + ) as (message: ResourceEventPayload) => Promise, + [KAFKA_TOPICS.RESOURCE_DELETED]: + this.autopilotService.handleResourceDeleted.bind( + this.autopilotService, + ) as (message: ResourceEventPayload) => Promise, + [KAFKA_TOPICS.REVIEW_COMPLETED]: + this.autopilotService.handleReviewCompleted.bind( + this.autopilotService, + ) as (message: ReviewCompletedPayload) => Promise, + [KAFKA_TOPICS.REVIEW_APPEAL_RESPONDED]: + this.autopilotService.handleAppealResponded.bind( + this.autopilotService, + ) as (message: AppealRespondedPayload) => Promise, + [KAFKA_TOPICS.FIRST2FINISH_SUBMISSION_RECEIVED]: + this.autopilotService.handleFirst2FinishSubmission.bind( + this.autopilotService, + ) as (message: First2FinishSubmissionPayload) => Promise, + [KAFKA_TOPICS.TOPGEAR_SUBMISSION_RECEIVED]: + this.autopilotService.handleTopgearSubmission.bind( + this.autopilotService, + ) as (message: TopgearSubmissionPayload) => Promise, }; } @@ -85,6 +114,36 @@ export class AutopilotConsumer { payload as SubmissionAggregatePayload, ); break; + case KAFKA_TOPICS.RESOURCE_CREATED: + await this.autopilotService.handleResourceCreated( + payload as ResourceEventPayload, + ); + break; + case KAFKA_TOPICS.RESOURCE_DELETED: + await this.autopilotService.handleResourceDeleted( + payload as ResourceEventPayload, + ); + break; + case KAFKA_TOPICS.REVIEW_COMPLETED: + await this.autopilotService.handleReviewCompleted( + payload as ReviewCompletedPayload, + ); + break; + case KAFKA_TOPICS.REVIEW_APPEAL_RESPONDED: + await this.autopilotService.handleAppealResponded( + payload as AppealRespondedPayload, + ); + break; + case KAFKA_TOPICS.FIRST2FINISH_SUBMISSION_RECEIVED: + await this.autopilotService.handleFirst2FinishSubmission( + payload as First2FinishSubmissionPayload, + ); + break; + case KAFKA_TOPICS.TOPGEAR_SUBMISSION_RECEIVED: + await this.autopilotService.handleTopgearSubmission( + payload as TopgearSubmissionPayload, + ); + break; default: throw new Error(`Unexpected topic: ${topic as string}`); } diff --git a/src/kafka/kafka.service.ts b/src/kafka/kafka.service.ts index e38d4cf..197240a 100644 --- a/src/kafka/kafka.service.ts +++ b/src/kafka/kafka.service.ts @@ -284,14 +284,14 @@ export class KafkaService implements OnApplicationShutdown, OnModuleInit { } } - async isConnected(): Promise { + isConnected(): Promise { try { - return ( - this.producer.isConnected() && - Array.from(this.consumers.values()).every((consumer) => - consumer.isConnected(), - ) + const producerConnected = this.producer.isConnected(); + const consumersConnected = Array.from(this.consumers.values()).every( + (consumer) => consumer.isConnected(), ); + + return Promise.resolve(producerConnected && consumersConnected); } catch (error) { const err = this.normalizeError( error, @@ -301,7 +301,7 @@ export class KafkaService implements OnApplicationShutdown, OnModuleInit { error: err.stack || err.message, timestamp: new Date().toISOString(), }); - return false; + return Promise.resolve(false); } } diff --git a/src/kafka/types/topic-payload-map.type.ts b/src/kafka/types/topic-payload-map.type.ts index b30ea18..8238358 100644 --- a/src/kafka/types/topic-payload-map.type.ts +++ b/src/kafka/types/topic-payload-map.type.ts @@ -2,7 +2,12 @@ import { KAFKA_TOPICS } from '../constants/topics'; import { ChallengeUpdatePayload, CommandPayload, + AppealRespondedPayload, + First2FinishSubmissionPayload, + TopgearSubmissionPayload, PhaseTransitionPayload, + ResourceEventPayload, + ReviewCompletedPayload, SubmissionAggregatePayload, } from 'src/autopilot/interfaces/autopilot.interface'; @@ -13,4 +18,10 @@ export type TopicPayloadMap = { [KAFKA_TOPICS.CHALLENGE_UPDATED]: ChallengeUpdatePayload; [KAFKA_TOPICS.COMMAND]: CommandPayload; [KAFKA_TOPICS.SUBMISSION_NOTIFICATION_AGGREGATE]: SubmissionAggregatePayload; + [KAFKA_TOPICS.RESOURCE_CREATED]: ResourceEventPayload; + [KAFKA_TOPICS.RESOURCE_DELETED]: ResourceEventPayload; + [KAFKA_TOPICS.REVIEW_COMPLETED]: ReviewCompletedPayload; + [KAFKA_TOPICS.REVIEW_APPEAL_RESPONDED]: AppealRespondedPayload; + [KAFKA_TOPICS.FIRST2FINISH_SUBMISSION_RECEIVED]: First2FinishSubmissionPayload; + [KAFKA_TOPICS.TOPGEAR_SUBMISSION_RECEIVED]: TopgearSubmissionPayload; }; diff --git a/src/recovery/recovery.service.ts b/src/recovery/recovery.service.ts index d45e258..e21be57 100644 --- a/src/recovery/recovery.service.ts +++ b/src/recovery/recovery.service.ts @@ -47,7 +47,6 @@ export class RecoveryService implements OnApplicationBootstrap { if (!challenge.phases || challenge.phases.length === 0) { this.logger.warn( `Challenge ${challenge.id} has no phases to schedule.`, - { projectId: challenge.projectId }, ); continue; } @@ -58,7 +57,6 @@ export class RecoveryService implements OnApplicationBootstrap { if (phasesToProcess.length === 0) { this.logger.log( `No phases need to be processed for challenge ${challenge.id}`, - { projectId: challenge.projectId }, ); continue; } diff --git a/src/resources/resources.service.ts b/src/resources/resources.service.ts index d48c2a0..03325c8 100644 --- a/src/resources/resources.service.ts +++ b/src/resources/resources.service.ts @@ -10,6 +10,15 @@ export interface ReviewerResourceRecord { roleName: string; } +interface CountRecord { + count: number | string; +} + +export interface ResourceRecord extends ReviewerResourceRecord { + challengeId: string; + roleId: string; +} + @Injectable() export class ResourcesService { private static readonly RESOURCE_TABLE = Prisma.sql`"Resource"`; @@ -121,4 +130,259 @@ export class ResourcesService { throw err; } } + + async getResourcesByRoleNames( + challengeId: string, + roleNames: string[], + ): Promise { + if (!roleNames.length) { + return []; + } + + const roleList = Prisma.join(roleNames); + + 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 rr."name" IN (${roleList}) + `; + + try { + const records = + await this.prisma.$queryRaw(query); + + void this.dbLogger.logAction('resources.getResourcesByRoleNames', { + challengeId, + status: 'SUCCESS', + source: ResourcesService.name, + details: { + roleCount: roleNames.length, + resourceCount: records.length, + }, + }); + + return records; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('resources.getResourcesByRoleNames', { + challengeId, + status: 'ERROR', + source: ResourcesService.name, + details: { roleCount: roleNames.length, error: err.message }, + }); + throw err; + } + } + + async getResourceByMemberHandle( + challengeId: string, + memberHandle: string, + ): Promise { + if (!challengeId || !memberHandle) { + return null; + } + + const query = Prisma.sql` + SELECT + r."id", + r."challengeId", + r."memberId", + r."memberHandle", + r."roleId", + 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 LOWER(r."memberHandle") = LOWER(${memberHandle}) + LIMIT 1 + `; + + try { + const [record] = await this.prisma.$queryRaw(query); + + if (!record) { + void this.dbLogger.logAction('resources.getResourceByMemberHandle', { + challengeId, + status: 'SUCCESS', + source: ResourcesService.name, + details: { + memberHandle, + found: false, + }, + }); + + return null; + } + + void this.dbLogger.logAction('resources.getResourceByMemberHandle', { + challengeId, + status: 'SUCCESS', + source: ResourcesService.name, + details: { + memberHandle, + resourceId: record.id, + roleName: record.roleName, + }, + }); + + return record; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('resources.getResourceByMemberHandle', { + challengeId, + status: 'ERROR', + source: ResourcesService.name, + details: { + memberHandle, + error: err.message, + }, + }); + throw err; + } + } + + async hasSubmitterResource( + challengeId: string, + roleNames: string[], + ): Promise { + if (!roleNames.length) { + return false; + } + + const roleList = Prisma.join(roleNames); + + const query = Prisma.sql` + SELECT COUNT(*)::int AS count + FROM ${ResourcesService.RESOURCE_TABLE} r + INNER JOIN ${ResourcesService.RESOURCE_ROLE_TABLE} rr ON rr."id" = r."roleId" + WHERE r."challengeId" = ${challengeId} + AND rr."name" IN (${roleList}) + `; + + try { + const [record] = await this.prisma.$queryRaw(query); + const count = Number(record?.count ?? 0); + + void this.dbLogger.logAction('resources.hasSubmitterResource', { + challengeId, + status: 'SUCCESS', + source: ResourcesService.name, + details: { + roleCount: roleNames.length, + submitterCount: count, + }, + }); + + return count > 0; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('resources.hasSubmitterResource', { + challengeId, + status: 'ERROR', + source: ResourcesService.name, + details: { roleCount: roleNames.length, error: err.message }, + }); + throw err; + } + } + + async getResourceById(id: string): Promise { + if (!id) { + return null; + } + + const query = Prisma.sql` + SELECT + r."id", + r."challengeId", + r."memberId", + r."memberHandle", + r."roleId", + rr."name" AS "roleName" + FROM ${ResourcesService.RESOURCE_TABLE} r + INNER JOIN ${ResourcesService.RESOURCE_ROLE_TABLE} rr + ON rr."id" = r."roleId" + WHERE r."id" = ${id} + LIMIT 1 + `; + + try { + const [record] = await this.prisma.$queryRaw(query); + + if (!record) { + void this.dbLogger.logAction('resources.getResourceById', { + challengeId: null, + status: 'SUCCESS', + source: ResourcesService.name, + details: { resourceId: id, found: false }, + }); + return null; + } + + void this.dbLogger.logAction('resources.getResourceById', { + challengeId: record.challengeId, + status: 'SUCCESS', + source: ResourcesService.name, + details: { resourceId: id, roleName: record.roleName }, + }); + + return record; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('resources.getResourceById', { + challengeId: null, + status: 'ERROR', + source: ResourcesService.name, + details: { resourceId: id, error: err.message }, + }); + throw err; + } + } + + async getRoleNameById(roleId: string): Promise { + if (!roleId) { + return null; + } + + const query = Prisma.sql` + SELECT "name" + FROM ${ResourcesService.RESOURCE_ROLE_TABLE} + WHERE "id" = ${roleId} + LIMIT 1 + `; + + try { + const [record] = + await this.prisma.$queryRaw<{ name: string | null }[]>(query); + + const roleName = record?.name ?? null; + + void this.dbLogger.logAction('resources.getRoleNameById', { + challengeId: null, + status: 'SUCCESS', + source: ResourcesService.name, + details: { + roleId, + roleName, + }, + }); + + return roleName; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('resources.getRoleNameById', { + challengeId: null, + status: 'ERROR', + source: ResourcesService.name, + details: { + roleId, + error: err.message, + }, + }); + throw err; + } + } } diff --git a/src/review/review.service.ts b/src/review/review.service.ts index c356bd4..0ea0e03 100644 --- a/src/review/review.service.ts +++ b/src/review/review.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { createHash } from 'crypto'; import { Prisma } from '@prisma/client'; import { ReviewPrismaService } from './review-prisma.service'; import { AutopilotDbLoggerService } from '../autopilot/services/autopilot-db-logger.service'; @@ -16,17 +17,94 @@ interface PendingCountRecord { count: number | string; } +interface ReviewDetailRecord { + id: string; + phaseId: string | null; + resourceId: string; + submissionId: string | null; + scorecardId: string | null; + score: number | string | null; + status: string | null; +} + +interface ScorecardRecord { + minimumPassingScore: number | string | null; +} + +interface AppealCountRecord { + count: number | string; +} + +interface SubmissionAggregationRecord { + submissionId: string; + legacySubmissionId: string | null; + memberId: string | null; + submittedDate: Date | null; + aggregateScore: number | string | null; + scorecardId: string | null; + scorecardLegacyId: string | null; + minimumPassingScore: number | string | null; +} + +export interface SubmissionSummary { + submissionId: string; + legacySubmissionId: string | null; + memberId: string | null; + submittedDate: Date | null; + aggregateScore: number; + scorecardId: string | null; + scorecardLegacyId: string | null; + passingScore: number; + isPassing: boolean; +} + @Injectable() export class ReviewService { private static readonly REVIEW_TABLE = Prisma.sql`"review"`; private static readonly SUBMISSION_TABLE = Prisma.sql`"submission"`; private static readonly REVIEW_SUMMATION_TABLE = Prisma.sql`"reviewSummation"`; + private static readonly SCORECARD_TABLE = Prisma.sql`"scorecard"`; + private static readonly APPEAL_TABLE = Prisma.sql`"appeal"`; + private static readonly APPEAL_RESPONSE_TABLE = Prisma.sql`"appealResponse"`; + private static readonly REVIEW_ITEM_COMMENT_TABLE = Prisma.sql`"reviewItemComment"`; + private static readonly REVIEW_ITEM_TABLE = Prisma.sql`"reviewItem"`; constructor( private readonly prisma: ReviewPrismaService, private readonly dbLogger: AutopilotDbLoggerService, ) {} + private resolvePassingScore( + value: number | string | null | undefined, + ): number { + if (value === null || value === undefined) { + return 50; + } + + const numericValue = + typeof value === 'number' ? value : Number(value); + + return Number.isFinite(numericValue) ? numericValue : 50; + } + + private buildPendingReviewLockId( + phaseId: string, + resourceId: string, + submissionId: string | null, + scorecardId: string | null, + ): bigint { + const key = [ + phaseId?.trim() ?? '', + resourceId?.trim() ?? '', + submissionId?.trim() ?? 'null', + scorecardId?.trim() ?? 'null', + ].join('|'); + + const hash = createHash('sha256').update(key).digest('hex').slice(0, 16); + + return BigInt.asIntN(64, BigInt(`0x${hash || '0'}`)); + } + async getTopFinalReviewScores( challengeId: string, limit = 3, @@ -182,6 +260,10 @@ export class ReviewService { SELECT "submissionId", "resourceId" FROM ${ReviewService.REVIEW_TABLE} WHERE "phaseId" = ${phaseId} + AND ( + "status" IS NULL + OR UPPER(("status")::text) NOT IN ('COMPLETED', 'NO_REVIEW') + ) `; try { @@ -267,7 +349,7 @@ export class ReviewService { } async createPendingReview( - submissionId: string, + submissionId: string | null, resourceId: string, phaseId: string, scorecardId: string, @@ -296,20 +378,31 @@ export class ReviewService { FROM ${ReviewService.REVIEW_TABLE} existing WHERE existing."resourceId" = ${resourceId} AND existing."phaseId" = ${phaseId} - AND existing."submissionId" = ${submissionId} + AND existing."submissionId" IS NOT DISTINCT FROM ${submissionId} + AND existing."scorecardId" IS NOT DISTINCT FROM ${scorecardId} AND ( - existing."scorecardId" = ${scorecardId} - OR ( - existing."scorecardId" IS NULL - AND ${scorecardId} IS NULL - ) + existing."status" IS NULL + OR UPPER((existing."status")::text) NOT IN ('COMPLETED', 'NO_REVIEW') ) ) `; try { - const rowsInserted = await this.prisma.$executeRaw(insert); - const created = rowsInserted > 0; + const lockId = this.buildPendingReviewLockId( + phaseId, + resourceId, + submissionId, + 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; + }); void this.dbLogger.logAction('review.createPendingReview', { challengeId, @@ -341,7 +434,427 @@ export class ReviewService { } } + async deletePendingReviewsForResource( + phaseId: string, + resourceId: string, + challengeId: string, + ): Promise { + const query = Prisma.sql` + DELETE FROM ${ReviewService.REVIEW_TABLE} + WHERE "phaseId" = ${phaseId} + AND "resourceId" = ${resourceId} + AND ( + "status" IS NULL + OR UPPER(("status")::text) = 'PENDING' + ) + `; + + try { + const deleted = await this.prisma.$executeRaw(query); + + void this.dbLogger.logAction('review.deletePendingReviewsForResource', { + challengeId, + status: 'SUCCESS', + source: ReviewService.name, + details: { + phaseId, + resourceId, + deletedCount: deleted, + }, + }); + + return deleted; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('review.deletePendingReviewsForResource', { + challengeId, + status: 'ERROR', + source: ReviewService.name, + details: { + phaseId, + resourceId, + error: err.message, + }, + }); + throw err; + } + } + + async getActiveSubmissionCount(challengeId: string): Promise { + const query = Prisma.sql` + SELECT COUNT(*)::int AS count + FROM ${ReviewService.SUBMISSION_TABLE} + WHERE "challengeId" = ${challengeId} + AND ( + "status" = 'ACTIVE' + OR "status" IS NULL + ) + `; + + 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.getActiveSubmissionCount', { + challengeId, + status: 'SUCCESS', + source: ReviewService.name, + details: { + submissionCount: count, + }, + }); + + return count; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('review.getActiveSubmissionCount', { + challengeId, + status: 'ERROR', + source: ReviewService.name, + details: { + error: err.message, + }, + }); + throw err; + } + } + + async getAllSubmissionIdsOrdered(challengeId: string): Promise { + const query = Prisma.sql` + SELECT "id" + FROM ${ReviewService.SUBMISSION_TABLE} + WHERE "challengeId" = ${challengeId} + ORDER BY "submittedDate" ASC NULLS LAST, "createdAt" ASC, "id" ASC + `; + + try { + const submissions = + await this.prisma.$queryRaw(query); + const submissionIds = submissions + .map((record) => record.id) + .filter(Boolean); + + void this.dbLogger.logAction('review.getAllSubmissionIdsOrdered', { + challengeId, + status: 'SUCCESS', + source: ReviewService.name, + details: { + submissionCount: submissionIds.length, + }, + }); + + return submissionIds; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('review.getAllSubmissionIdsOrdered', { + challengeId, + status: 'ERROR', + source: ReviewService.name, + details: { + error: err.message, + }, + }); + throw err; + } + } + + async getCompletedReviewCountForPhase(phaseId: string): Promise { + const query = Prisma.sql` + SELECT COUNT(*)::int AS count + FROM ${ReviewService.REVIEW_TABLE} + WHERE "phaseId" = ${phaseId} + AND UPPER(("status")::text) = 'COMPLETED' + `; + + 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.getCompletedReviewCountForPhase', { + challengeId: null, + status: 'SUCCESS', + source: ReviewService.name, + details: { + phaseId, + completedCount: count, + }, + }); + + return count; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('review.getCompletedReviewCountForPhase', { + challengeId: null, + status: 'ERROR', + source: ReviewService.name, + details: { + phaseId, + error: err.message, + }, + }); + throw err; + } + } + + async generateReviewSummaries( + challengeId: string, + ): Promise { + if (!challengeId) { + return []; + } + + const aggregationQuery = Prisma.sql` + SELECT + s."id" AS "submissionId", + s."legacySubmissionId" AS "legacySubmissionId", + s."memberId" AS "memberId", + s."submittedDate" AS "submittedDate", + COALESCE(AVG(r."finalScore"), 0) AS "aggregateScore", + MAX(r."scorecardId") AS "scorecardId", + MAX(sc."legacyId") AS "scorecardLegacyId", + MAX(sc."minimumPassingScore") AS "minimumPassingScore" + FROM ${ReviewService.SUBMISSION_TABLE} s + LEFT JOIN ${ReviewService.REVIEW_TABLE} r + ON r."submissionId" = s."id" + AND (r."status"::text = 'COMPLETED') + LEFT JOIN ${ReviewService.SCORECARD_TABLE} sc + ON sc."id" = r."scorecardId" + WHERE s."challengeId" = ${challengeId} + GROUP BY s."id", s."legacySubmissionId", s."memberId", s."submittedDate" + `; + + const aggregationRows = + await this.prisma.$queryRaw( + aggregationQuery, + ); + + const summaries: SubmissionSummary[] = aggregationRows.map((row) => { + const aggregateScore = Number(row.aggregateScore ?? 0); + const passingScore = this.resolvePassingScore(row.minimumPassingScore); + const isPassing = aggregateScore >= passingScore; + + return { + submissionId: row.submissionId, + legacySubmissionId: row.legacySubmissionId ?? null, + memberId: row.memberId ?? null, + submittedDate: row.submittedDate ? new Date(row.submittedDate) : null, + aggregateScore, + scorecardId: row.scorecardId ?? null, + scorecardLegacyId: row.scorecardLegacyId ?? null, + passingScore, + isPassing, + }; + }); + + const now = new Date(); + + await this.prisma.$transaction(async (tx) => { + await tx.$executeRaw( + Prisma.sql` + DELETE FROM ${ReviewService.REVIEW_SUMMATION_TABLE} + WHERE "submissionId" IN ( + SELECT "id" + FROM ${ReviewService.SUBMISSION_TABLE} + WHERE "challengeId" = ${challengeId} + ) + `, + ); + + for (const summary of summaries) { + await tx.$executeRaw( + Prisma.sql` + INSERT INTO ${ReviewService.REVIEW_SUMMATION_TABLE} ( + "submissionId", + "legacySubmissionId", + "aggregateScore", + "scorecardId", + "scorecardLegacyId", + "isPassing", + "isFinal", + "reviewedDate", + "createdAt", + "createdBy", + "updatedAt", + "updatedBy" + ) + VALUES ( + ${summary.submissionId}, + ${summary.legacySubmissionId}, + ${summary.aggregateScore}, + ${summary.scorecardId}, + ${summary.scorecardLegacyId}, + ${summary.isPassing}, + true, + ${now}, + ${now}, + 'autopilot', + ${now}, + 'autopilot' + ) + `, + ); + } + }); + + void this.dbLogger.logAction('review.generateReviewSummaries', { + challengeId, + status: 'SUCCESS', + source: ReviewService.name, + details: { + submissionCount: summaries.length, + passingCount: summaries.filter((summary) => summary.isPassing).length, + }, + }); + + return summaries; + } + private composeKey(resourceId: string, submissionId: string): string { return `${resourceId}:${submissionId}`; } + + async getReviewById(reviewId: string): Promise { + if (!reviewId) { + return null; + } + + const query = Prisma.sql` + SELECT + "id", + "phaseId", + "resourceId", + "submissionId", + "scorecardId", + "finalScore" AS "score", + "status" + FROM ${ReviewService.REVIEW_TABLE} + WHERE "id" = ${reviewId} + LIMIT 1 + `; + + try { + const [record] = await this.prisma.$queryRaw(query); + + void this.dbLogger.logAction('review.getReviewById', { + challengeId: null, + status: 'SUCCESS', + source: ReviewService.name, + details: { + reviewId, + found: Boolean(record), + phaseId: record?.phaseId ?? null, + }, + }); + + return record ?? null; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('review.getReviewById', { + challengeId: null, + status: 'ERROR', + source: ReviewService.name, + details: { + reviewId, + error: err.message, + }, + }); + throw err; + } + } + + async getScorecardPassingScore( + scorecardId: string | null, + ): Promise { + if (!scorecardId) { + return 50; + } + + const query = Prisma.sql` + SELECT "minimumPassingScore" + FROM "scorecard" + WHERE "id" = ${scorecardId} + LIMIT 1 + `; + + try { + const [record] = await this.prisma.$queryRaw(query); + const passingScore = this.resolvePassingScore( + record?.minimumPassingScore ?? null, + ); + + void this.dbLogger.logAction('review.getScorecardPassingScore', { + challengeId: null, + status: 'SUCCESS', + source: ReviewService.name, + details: { + scorecardId, + passingScore, + minimumPassingScore: record?.minimumPassingScore ?? null, + }, + }); + + return passingScore; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('review.getScorecardPassingScore', { + challengeId: null, + status: 'ERROR', + source: ReviewService.name, + details: { + scorecardId, + error: err.message, + }, + }); + throw err; + } + } + + async getPendingAppealCount(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" + LEFT JOIN ${ReviewService.APPEAL_RESPONSE_TABLE} ar + ON ar."appealId" = a."id" + WHERE s."challengeId" = ${challengeId} + AND ar."id" IS NULL + `; + + 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.getPendingAppealCount', { + challengeId, + status: 'SUCCESS', + source: ReviewService.name, + details: { + pendingAppeals: count, + }, + }); + + return count; + } catch (error) { + const err = error as Error; + void this.dbLogger.logAction('review.getPendingAppealCount', { + challengeId, + status: 'ERROR', + source: ReviewService.name, + details: { + error: err.message, + }, + }); + throw err; + } + } } diff --git a/test/autopilot.e2e-spec.ts b/test/autopilot.e2e-spec.ts index d1674f5..04e5fe3 100644 --- a/test/autopilot.e2e-spec.ts +++ b/test/autopilot.e2e-spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import * as request from 'supertest'; -import { AppModule } from '../src/app.module'; +import type { Response } from 'supertest'; import { KafkaService } from '../src/kafka/kafka.service'; import { SchedulerService } from '../src/autopilot/services/scheduler.service'; import { ChallengeApiService } from '../src/challenge/challenge-api.service'; @@ -9,11 +9,126 @@ import { Auth0Service } from '../src/auth/auth0.service'; import { KAFKA_TOPICS } from '../src/kafka/constants/topics'; import { AutopilotConsumer } from '../src/kafka/consumers/autopilot.consumer'; import { RecoveryService } from '../src/recovery/recovery.service'; -import { +import { ChallengeUpdatePayload, AutopilotOperator, + ResourceEventPayload, + ReviewCompletedPayload, + First2FinishSubmissionPayload, + TopgearSubmissionPayload, } from '../src/autopilot/interfaces/autopilot.interface'; import { AutopilotService } from '../src/autopilot/services/autopilot.service'; +import { ReviewService } 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'; +import { ChallengeCompletionService } from '../src/autopilot/services/challenge-completion.service'; +import { PhaseScheduleManager } from '../src/autopilot/services/phase-schedule-manager.service'; + +jest.mock('@platformatic/kafka', () => { + class MockProducer { + metadata = jest.fn().mockResolvedValue(undefined); + close = jest.fn().mockResolvedValue(undefined); + send = jest.fn().mockResolvedValue(undefined); + isConnected = jest.fn().mockReturnValue(true); + } + + class MockConsumer { + on = jest.fn(); + subscribe = jest.fn().mockResolvedValue(undefined); + run = jest.fn().mockResolvedValue(undefined); + stop = jest.fn().mockResolvedValue(undefined); + close = jest.fn().mockResolvedValue(undefined); + isConnected = jest.fn().mockReturnValue(true); + stream = jest.fn().mockReturnValue({ + [Symbol.asyncIterator]: async function* () { + // No messages by default + }, + }); + } + + return { + Producer: MockProducer, + Consumer: MockConsumer, + MessagesStream: class MockMessagesStream {}, + ProduceAcks: { ALL: 'all' }, + jsonDeserializer: jest.fn(), + jsonSerializer: jest.fn(), + stringDeserializer: jest.fn(), + stringSerializer: jest.fn(), + }; +}); + +jest.mock('bullmq', () => { + const jobs = new Map(); + + class MockJob { + id: string; + data: any; + remove = jest.fn().mockImplementation(async () => { + jobs.delete(this.id); + }); + + constructor(id: string, data: any) { + this.id = id; + this.data = data; + } + } + + class MockQueue { + add = jest.fn(async (_name: string, data: any, options?: any) => { + const jobId = options?.jobId ?? `${Date.now()}`; + const job = new MockJob(jobId, data); + jobs.set(jobId, job); + return job; + }); + + getJob = jest.fn(async (id: string) => jobs.get(id) ?? null); + + close = jest.fn().mockResolvedValue(undefined); + + constructor(_name: string, _opts?: any) {} + } + + class MockWorker { + on = jest.fn(); + close = jest.fn().mockResolvedValue(undefined); + waitUntilReady = jest.fn().mockResolvedValue(undefined); + + constructor( + _name: string, + _processor: (job: MockJob) => Promise, + _opts?: any, + ) {} + } + + return { + Queue: MockQueue, + Worker: MockWorker, + Job: MockJob, + }; +}); + +type AppModuleType = typeof import('../src/app.module').AppModule; +let AppModule: AppModuleType; + +process.env.POST_MORTEM_SCORECARD_ID = + process.env.POST_MORTEM_SCORECARD_ID ?? 'post-mortem-scorecard'; +process.env.TOPGEAR_POST_MORTEM_SCORECARD_ID = + process.env.TOPGEAR_POST_MORTEM_SCORECARD_ID ?? + 'topgear-post-mortem-scorecard'; +process.env.POST_MORTEM_REVIEW_ROLES = + process.env.POST_MORTEM_REVIEW_ROLES ?? 'Reviewer,Copilot'; +process.env.SUBMITTER_ROLE_NAMES = + process.env.SUBMITTER_ROLE_NAMES ?? 'Submitter'; +process.env.APPEALS_PHASE_NAMES = process.env.APPEALS_PHASE_NAMES ?? 'Appeals'; +process.env.APPEALS_RESPONSE_PHASE_NAMES = + process.env.APPEALS_RESPONSE_PHASE_NAMES ?? 'Appeals Response'; +process.env.NODE_ENV = process.env.NODE_ENV ?? 'test'; +process.env.KAFKA_BROKERS = process.env.KAFKA_BROKERS ?? 'localhost:9092'; + +const flushPromises = async (): Promise => + await new Promise((resolve) => setImmediate(resolve)); // --- Mock Data --- const mockPastPhaseDate = new Date(Date.now() - 1000 * 60 * 60).toISOString(); // 1 hour ago @@ -28,6 +143,9 @@ const mockChallenge = { id: 'a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6', projectId: 12345, status: 'ACTIVE', + type: 'standard', + reviewers: [], + numOfSubmissions: 1, phases: [ { id: 'p1a2b3c4-d5e6-f7a8-b9c0-d1e2f3a4b5c6', @@ -57,11 +175,11 @@ const mockChallenge = { const mockChallengeWithPastPhase = { ...mockChallenge, phases: [ - { - ...mockChallenge.phases[0], + { + ...mockChallenge.phases[0], scheduledEndDate: mockPastPhaseDate, isOpen: true, // This phase is overdue but still open - }, + }, mockChallenge.phases[1], // This one is in the future ], }; @@ -69,102 +187,204 @@ const mockChallengeWithPastPhase = { describe('Autopilot Service (e2e)', () => { let app: INestApplication; let schedulerService: SchedulerService; - let challengeApiService: ChallengeApiService; let autopilotConsumer: AutopilotConsumer; let recoveryService: RecoveryService; let autopilotService: AutopilotService; - - const mockKafkaProduce = jest.fn().mockResolvedValue(null); - const mockGetAllActiveChallenges = jest.fn(); - const mockGetChallenge = jest.fn(); - const mockGetActiveChallenge = jest.fn(); // Mock for the new method + let phaseScheduleManager: PhaseScheduleManager; + let mockKafkaProduce: jest.Mock; + let mockChallengeApiService: { + getAllActiveChallenges: jest.Mock; + getChallenge: jest.Mock; + getChallengeById: jest.Mock; + getPhaseDetails: jest.Mock; + getChallengePhases: jest.Mock; + getPhaseTypeName: jest.Mock; + advancePhase: jest.Mock; + createPostMortemPhase: jest.Mock; + cancelChallenge: jest.Mock; + completeChallenge: jest.Mock; + }; + let mockReviewService: jest.Mocked; + let mockResourcesService: jest.Mocked; + let mockPhaseReviewService: jest.Mocked; + let mockReviewAssignmentService: jest.Mocked; + let reviewServiceMockFns: Record; + let resourcesServiceMockFns: Record; + let phaseReviewServiceMockFns: Record; + let reviewAssignmentServiceMockFns: Record; beforeEach(async () => { - mockKafkaProduce.mockClear(); - mockGetAllActiveChallenges.mockClear(); - mockGetChallenge.mockClear(); - mockGetActiveChallenge.mockClear(); - - // Corrected: Provide a default mock implementation before the module compiles - mockGetAllActiveChallenges.mockResolvedValue([]); - - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }) - .overrideProvider(KafkaService) - .useValue({ - produce: mockKafkaProduce, - consume: jest.fn(), - isConnected: jest.fn().mockResolvedValue(true), - }) - .overrideProvider(Auth0Service) - .useValue({ - getAccessToken: jest.fn().mockResolvedValue('mock-access-token'), - clearTokenCache: jest.fn(), - }) - .overrideProvider(ChallengeApiService) - .useValue({ - getAllActiveChallenges: mockGetAllActiveChallenges, - getChallenge: mockGetChallenge, - getChallengeById: mockGetActiveChallenge, // Add the new method to the mock - getPhaseDetails: jest.fn().mockImplementation((challengeId, phaseId) => { - // Return mock phase details based on phaseId - const phase = mockChallenge.phases.find(p => p.id === phaseId); + jest.clearAllMocks(); + + ({ AppModule } = await import('../src/app.module')); + + mockKafkaProduce = jest.fn().mockResolvedValue(null); + + const reviewServiceMock = { + getPendingReviewCount: jest.fn().mockResolvedValue(0), + deletePendingReviewsForResource: jest.fn().mockResolvedValue(0), + createPendingReview: jest.fn().mockResolvedValue(true), + getActiveSubmissionCount: jest.fn().mockResolvedValue(1), + getAllSubmissionIdsOrdered: jest.fn().mockResolvedValue([]), + getExistingReviewPairs: jest.fn().mockResolvedValue(new Set()), + getReviewById: jest.fn().mockResolvedValue(null), + getScorecardPassingScore: jest.fn().mockResolvedValue(50), + getCompletedReviewCountForPhase: jest.fn().mockResolvedValue(0), + getPendingAppealCount: jest.fn().mockResolvedValue(0), + getActiveSubmissionIds: jest.fn().mockResolvedValue([]), + generateReviewSummaries: jest.fn().mockResolvedValue([]), + } satisfies Record; + reviewServiceMockFns = reviewServiceMock; + mockReviewService = + reviewServiceMock as unknown as jest.Mocked; + + const resourcesServiceMock = { + getResourceById: jest.fn(), + getRoleNameById: jest.fn(), + getReviewerResources: jest.fn().mockResolvedValue([]), + getResourcesByRoleNames: jest.fn().mockResolvedValue([]), + hasSubmitterResource: jest.fn().mockResolvedValue(true), + getMemberHandleMap: jest.fn().mockResolvedValue(new Map()), + getResourceByMemberHandle: jest.fn(), + } satisfies Record; + resourcesServiceMockFns = resourcesServiceMock; + mockResourcesService = + resourcesServiceMock as unknown as jest.Mocked; + + const phaseReviewServiceMock = { + handlePhaseOpened: jest.fn().mockResolvedValue(undefined), + } satisfies Record; + phaseReviewServiceMockFns = phaseReviewServiceMock; + mockPhaseReviewService = + phaseReviewServiceMock as unknown as jest.Mocked; + + const reviewAssignmentServiceMock = { + ensureAssignmentsOrSchedule: jest.fn().mockResolvedValue(true), + handleReviewerRemoved: jest.fn().mockResolvedValue(undefined), + clearPolling: jest.fn(), + startPolling: jest.fn(), + } satisfies Record; + reviewAssignmentServiceMockFns = reviewAssignmentServiceMock; + mockReviewAssignmentService = + reviewAssignmentServiceMock as unknown as jest.Mocked; + + mockChallengeApiService = { + getAllActiveChallenges: jest.fn().mockResolvedValue([]), + getChallenge: jest.fn().mockResolvedValue(mockChallenge), + getChallengeById: jest.fn().mockResolvedValue(mockChallenge), + getPhaseDetails: jest + .fn() + .mockImplementation((challengeId: string, phaseId: string) => { + const challenge = + challengeId === mockChallenge.id ? mockChallenge : mockChallenge; + const phase = challenge.phases.find((p) => p.id === phaseId); if (phase) { return Promise.resolve(phase); } - // Default mock for test phases return Promise.resolve({ id: phaseId, name: 'Test Phase', - isOpen: true, // Default to open for most tests + isOpen: true, scheduledStartDate: mockPastPhaseDate, scheduledEndDate: mockFuturePhaseDate1, }); }), - advancePhase: jest.fn().mockImplementation((challengeId, phaseId, operation) => { - if (operation === 'close') { - return Promise.resolve({ - success: true, - message: 'Phase closed', - next: { - operation: 'open', - phases: [ + getChallengePhases: jest + .fn() + .mockImplementation(async (challengeId: string) => { + if (challengeId === mockChallenge.id) { + return mockChallenge.phases; + } + return mockChallenge.phases; + }), + getPhaseTypeName: jest.fn().mockResolvedValue('Mock Phase'), + advancePhase: jest + .fn() + .mockImplementation( + ( + challengeId: string, + phaseId: string, + operation: 'open' | 'close', + ) => { + if (operation === 'close') { + return Promise.resolve({ + success: true, + message: 'Phase closed', + next: { + operation: 'open', + phases: [ + { + id: 'p2a3b4c5-d6e7-f8a9-b0c1-d2e3f4a5b6c7', + name: 'Submission', + scheduledEndDate: mockFuturePhaseDate2, + }, + ], + }, + }); + } + + if (operation === 'open') { + return Promise.resolve({ + success: true, + message: 'Phase opened', + updatedPhases: [ { id: 'p2a3b4c5-d6e7-f8a9-b0c1-d2e3f4a5b6c7', name: 'Submission', scheduledEndDate: mockFuturePhaseDate2, - } - ] - } - }); - } else if (operation === 'open') { + isOpen: true, + actualStartDate: new Date().toISOString(), + }, + ], + }); + } + return Promise.resolve({ - success: true, - message: 'Phase opened', - updatedPhases: [ - { - id: 'p2a3b4c5-d6e7-f8a9-b0c1-d2e3f4a5b6c7', - name: 'Submission', - scheduledEndDate: mockFuturePhaseDate2, - isOpen: true, - actualStartDate: new Date().toISOString(), - } - ] + success: false, + message: 'Unknown operation', }); - } - return Promise.resolve({ success: false, message: 'Unknown operation' }); - }), + }, + ), + createPostMortemPhase: jest.fn(), + cancelChallenge: jest.fn(), + completeChallenge: jest.fn(), + }; + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(KafkaService) + .useValue({ + produce: mockKafkaProduce, + consume: jest.fn(), + isConnected: jest.fn().mockResolvedValue(true), }) + .overrideProvider(Auth0Service) + .useValue({ + getAccessToken: jest.fn().mockResolvedValue('mock-access-token'), + clearTokenCache: jest.fn(), + }) + .overrideProvider(ChallengeApiService) + .useValue(mockChallengeApiService) + .overrideProvider(ReviewService) + .useValue(mockReviewService) + .overrideProvider(ResourcesService) + .useValue(mockResourcesService) + .overrideProvider(PhaseReviewService) + .useValue(mockPhaseReviewService) + .overrideProvider(ReviewAssignmentService) + .useValue(mockReviewAssignmentService) + .overrideProvider(ChallengeCompletionService) + .useValue({ finalizeChallenge: jest.fn().mockResolvedValue(true) }) .compile(); app = moduleFixture.createNestApplication(); schedulerService = app.get(SchedulerService); - challengeApiService = app.get(ChallengeApiService); autopilotConsumer = app.get(AutopilotConsumer); recoveryService = app.get(RecoveryService); autopilotService = app.get(AutopilotService); + phaseScheduleManager = app.get(PhaseScheduleManager); await app.init(); }); @@ -179,24 +399,27 @@ describe('Autopilot Service (e2e)', () => { describe('Health Checks', () => { it('/health (GET) should return OK', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument return request(app.getHttpServer()) .get('/health') .expect(200) - .expect((res) => { - expect(res.body.status).toBe('ok'); + .expect(({ body }: Response) => { + const typedBody = body as { status: string }; + expect(typedBody.status).toBe('ok'); }); }); }); describe('Challenge Creation and Scheduling', () => { it('should schedule only the next phase when a challenge.notification.create event is received', async () => { - mockGetActiveChallenge.mockResolvedValue(mockChallenge); // Use the correct mocked method + mockChallengeApiService.getChallengeById.mockResolvedValue(mockChallenge); const scheduleSpy = jest.spyOn( schedulerService, 'schedulePhaseTransition', ); await autopilotConsumer.topicHandlers[KAFKA_TOPICS.CHALLENGE_CREATED]({ + id: mockChallenge.id, challengeId: mockChallenge.id, projectId: mockChallenge.projectId, status: 'ACTIVE', @@ -205,7 +428,7 @@ describe('Autopilot Service (e2e)', () => { await new Promise((resolve) => setTimeout(resolve, 100)); - expect(challengeApiService.getChallengeById).toHaveBeenCalledWith( + expect(mockChallengeApiService.getChallengeById).toHaveBeenCalledWith( mockChallenge.id, ); // Should only schedule 1 phase (the next one), not all phases @@ -232,17 +455,22 @@ describe('Autopilot Service (e2e)', () => { isOpen: false, actualStartDate: null, actualEndDate: null, // This phase is ready to start + scheduledStartDate: mockPastPhaseDate, + scheduledEndDate: mockFuturePhaseDate1, }, ], }; - - mockGetActiveChallenge.mockResolvedValue(challengeWithNoOpenPhases); + + mockChallengeApiService.getChallengeById.mockResolvedValue( + challengeWithNoOpenPhases, + ); const scheduleSpy = jest.spyOn( schedulerService, 'schedulePhaseTransition', ); await autopilotConsumer.topicHandlers[KAFKA_TOPICS.CHALLENGE_CREATED]({ + id: challengeWithNoOpenPhases.id, challengeId: challengeWithNoOpenPhases.id, projectId: challengeWithNoOpenPhases.projectId, status: 'ACTIVE', @@ -251,7 +479,7 @@ describe('Autopilot Service (e2e)', () => { await new Promise((resolve) => setTimeout(resolve, 100)); - expect(challengeApiService.getChallengeById).toHaveBeenCalledWith( + expect(mockChallengeApiService.getChallengeById).toHaveBeenCalledWith( challengeWithNoOpenPhases.id, ); expect(scheduleSpy).toHaveBeenCalledTimes(1); @@ -274,13 +502,16 @@ describe('Autopilot Service (e2e)', () => { }, ], }; - mockGetActiveChallenge.mockResolvedValue(updatedChallenge); // Use the correct mocked method + mockChallengeApiService.getChallengeById.mockResolvedValue( + updatedChallenge, + ); const rescheduleSpy = jest.spyOn( - app.get(AutopilotService), + phaseScheduleManager, 'reschedulePhaseTransition', ); await autopilotConsumer.topicHandlers[KAFKA_TOPICS.CHALLENGE_UPDATE]({ + id: updatedChallenge.id, challengeId: updatedChallenge.id, projectId: updatedChallenge.projectId, status: 'ACTIVE', @@ -289,7 +520,7 @@ describe('Autopilot Service (e2e)', () => { await new Promise((resolve) => setTimeout(resolve, 100)); - expect(challengeApiService.getChallengeById).toHaveBeenCalledWith( + expect(mockChallengeApiService.getChallengeById).toHaveBeenCalledWith( updatedChallenge.id, ); expect(rescheduleSpy).toHaveBeenCalledTimes(1); @@ -304,7 +535,7 @@ describe('Autopilot Service (e2e)', () => { describe('Command Handling', () => { it('should cancel a schedule when a cancel_schedule command is received', async () => { - schedulerService.schedulePhaseTransition({ + await schedulerService.schedulePhaseTransition({ projectId: mockChallenge.projectId, phaseId: mockChallenge.phases[0].id, challengeId: mockChallenge.id, @@ -316,7 +547,7 @@ describe('Autopilot Service (e2e)', () => { }); const autopilotCancelSpy = jest.spyOn( - app.get(AutopilotService), + phaseScheduleManager, 'cancelPhaseTransition', ); @@ -339,7 +570,7 @@ describe('Autopilot Service (e2e)', () => { describe('Recovery Service', () => { it('should immediately trigger overdue phases on bootstrap', async () => { - mockGetAllActiveChallenges.mockResolvedValue([ + mockChallengeApiService.getAllActiveChallenges.mockResolvedValue([ mockChallengeWithPastPhase, ]); const scheduleSpy = jest.spyOn( @@ -351,7 +582,7 @@ describe('Autopilot Service (e2e)', () => { await recoveryService.onApplicationBootstrap(); await new Promise((resolve) => setTimeout(resolve, 100)); - expect(challengeApiService.getAllActiveChallenges).toHaveBeenCalled(); + expect(mockChallengeApiService.getAllActiveChallenges).toHaveBeenCalled(); // Should only trigger the overdue phase, not schedule future ones expect(scheduleSpy).toHaveBeenCalledTimes(0); expect(triggerEventSpy).toHaveBeenCalledTimes(1); @@ -363,7 +594,7 @@ describe('Autopilot Service (e2e)', () => { ); }); - it('should schedule future phases when no overdue phases exist', async () => { + it('should skip scheduling when no phases are yet due', async () => { const challengeWithFuturePhase = { ...mockChallenge, phases: [ @@ -378,11 +609,15 @@ describe('Autopilot Service (e2e)', () => { isOpen: false, actualStartDate: null, actualEndDate: null, // This phase is ready to start + scheduledStartDate: mockFuturePhaseDate1, + scheduledEndDate: mockFuturePhaseDate2, }, ], }; - - mockGetAllActiveChallenges.mockResolvedValue([challengeWithFuturePhase]); + + mockChallengeApiService.getAllActiveChallenges.mockResolvedValue([ + challengeWithFuturePhase, + ]); const scheduleSpy = jest.spyOn( schedulerService, 'schedulePhaseTransition', @@ -392,13 +627,13 @@ describe('Autopilot Service (e2e)', () => { await recoveryService.onApplicationBootstrap(); await new Promise((resolve) => setTimeout(resolve, 100)); - expect(challengeApiService.getAllActiveChallenges).toHaveBeenCalled(); - expect(scheduleSpy).toHaveBeenCalledTimes(1); + expect(mockChallengeApiService.getAllActiveChallenges).toHaveBeenCalled(); + expect(scheduleSpy).toHaveBeenCalledTimes(0); expect(triggerEventSpy).toHaveBeenCalledTimes(0); - expect(scheduleSpy).toHaveBeenCalledWith( + expect(scheduleSpy).not.toHaveBeenCalledWith( expect.objectContaining({ - phaseId: challengeWithFuturePhase.phases[1].id, // The next ready phase + phaseId: challengeWithFuturePhase.phases[1].id, }), ); }); @@ -408,10 +643,6 @@ describe('Autopilot Service (e2e)', () => { schedulerService, 'schedulePhaseTransition', ); - const openAndScheduleNextPhasesSpy = jest.spyOn( - autopilotService, - 'openAndScheduleNextPhases', - ); // Simulate a phase transition that triggers the chain const phaseData = { @@ -428,28 +659,24 @@ describe('Autopilot Service (e2e)', () => { await schedulerService.advancePhase(phaseData); await new Promise((resolve) => setTimeout(resolve, 100)); - expect(challengeApiService.advancePhase).toHaveBeenCalledWith( + expect(mockChallengeApiService.advancePhase).toHaveBeenCalledWith( mockChallenge.id, mockChallenge.phases[0].id, 'close', ); - + // Should open and schedule the next phase in the chain - expect(openAndScheduleNextPhasesSpy).toHaveBeenCalledWith( - mockChallenge.id, - mockChallenge.projectId, - 'ACTIVE', - expect.arrayContaining([ - expect.objectContaining({ - id: 'p2a3b4c5-d6e7-f8a9-b0c1-d2e3f4a5b6c7', - name: 'Submission', - }) - ]) + expect(scheduleSpy).toHaveBeenCalledWith( + expect.objectContaining({ + challengeId: mockChallenge.id, + phaseId: 'p2a3b4c5-d6e7-f8a9-b0c1-d2e3f4a5b6c7', + state: 'END', + }), ); }); it('should handle complete phase chain flow from challenge creation to phase completion', async () => { - mockGetActiveChallenge.mockResolvedValue(mockChallenge); + mockChallengeApiService.getChallengeById.mockResolvedValue(mockChallenge); const scheduleSpy = jest.spyOn( schedulerService, 'schedulePhaseTransition', @@ -457,6 +684,7 @@ describe('Autopilot Service (e2e)', () => { // 1. Create a new challenge - should schedule only the first phase await autopilotConsumer.topicHandlers[KAFKA_TOPICS.CHALLENGE_CREATED]({ + id: mockChallenge.id, challengeId: mockChallenge.id, projectId: mockChallenge.projectId, status: 'ACTIVE', @@ -490,14 +718,14 @@ describe('Autopilot Service (e2e)', () => { await new Promise((resolve) => setTimeout(resolve, 100)); // Should have called advancePhase on the API - expect(challengeApiService.advancePhase).toHaveBeenCalledWith( + expect(mockChallengeApiService.advancePhase).toHaveBeenCalledWith( mockChallenge.id, mockChallenge.phases[0].id, 'close', ); // Should have opened the next phase first, then scheduled it for closure - expect(challengeApiService.advancePhase).toHaveBeenCalledWith( + expect(mockChallengeApiService.advancePhase).toHaveBeenCalledWith( mockChallenge.id, 'p2a3b4c5-d6e7-f8a9-b0c1-d2e3f4a5b6c7', 'open', @@ -515,46 +743,60 @@ describe('Autopilot Service (e2e)', () => { }); it('should open phases before scheduling them for closure', async () => { - const advancePhaseCallOrder: Array<{ operation: string; phaseId: string }> = []; - - // Track the order of advancePhase calls - const mockAdvancePhase = jest.fn().mockImplementation((challengeId, phaseId, operation) => { - advancePhaseCallOrder.push({ operation, phaseId }); - - if (operation === 'close') { - return Promise.resolve({ - success: true, - message: 'Phase closed', - next: { - operation: 'open', - phases: [ + const advancePhaseCallOrder: Array<{ + operation: string; + phaseId: string; + }> = []; + const originalAdvanceImplementation = + mockChallengeApiService.advancePhase.getMockImplementation(); + + mockChallengeApiService.advancePhase.mockImplementation( + ( + _challengeId: string, + phaseId: string, + operation: 'open' | 'close', + ) => { + advancePhaseCallOrder.push({ operation, phaseId }); + + if (operation === 'close') { + return Promise.resolve({ + success: true, + message: 'Phase closed', + next: { + operation: 'open', + phases: [ + { + id: 'next-phase-id', + name: 'Next Phase', + scheduledEndDate: mockFuturePhaseDate2, + }, + ], + }, + }); + } + + if (operation === 'open') { + return Promise.resolve({ + success: true, + message: 'Phase opened', + updatedPhases: [ { id: 'next-phase-id', name: 'Next Phase', scheduledEndDate: mockFuturePhaseDate2, - } - ] - } - }); - } else if (operation === 'open') { + isOpen: true, + actualStartDate: new Date().toISOString(), + }, + ], + }); + } + return Promise.resolve({ - success: true, - message: 'Phase opened', - updatedPhases: [ - { - id: 'next-phase-id', - name: 'Next Phase', - scheduledEndDate: mockFuturePhaseDate2, - isOpen: true, - actualStartDate: new Date().toISOString(), - } - ] + success: false, + message: 'Unknown operation', }); - } - return Promise.resolve({ success: false, message: 'Unknown operation' }); - }); - - challengeApiService.advancePhase = mockAdvancePhase; + }, + ); // Trigger phase advancement const phaseData = { @@ -575,27 +817,29 @@ describe('Autopilot Service (e2e)', () => { expect(advancePhaseCallOrder).toHaveLength(2); expect(advancePhaseCallOrder[0]).toEqual({ operation: 'close', - phaseId: 'current-phase-id' + phaseId: 'current-phase-id', }); expect(advancePhaseCallOrder[1]).toEqual({ operation: 'open', - phaseId: 'next-phase-id' + phaseId: 'next-phase-id', }); + + if (originalAdvanceImplementation) { + mockChallengeApiService.advancePhase.mockImplementation( + originalAdvanceImplementation, + ); + } }); it('should not try to close phases that are already closed', async () => { // Mock a closed phase - const mockGetPhaseDetails = jest.fn().mockResolvedValue({ + mockChallengeApiService.getPhaseDetails.mockResolvedValueOnce({ id: 'closed-phase-id', name: 'Closed Phase', - isOpen: false, // This phase is already closed + isOpen: false, scheduledEndDate: mockFuturePhaseDate1, }); - - challengeApiService.getPhaseDetails = mockGetPhaseDetails; - - const mockAdvancePhase = jest.fn(); - challengeApiService.advancePhase = mockAdvancePhase; + mockChallengeApiService.advancePhase.mockClear(); // Trigger phase advancement for a closed phase const phaseData = { @@ -613,22 +857,18 @@ describe('Autopilot Service (e2e)', () => { await new Promise((resolve) => setTimeout(resolve, 100)); // Should not call advancePhase since the phase is already closed - expect(mockAdvancePhase).not.toHaveBeenCalled(); + expect(mockChallengeApiService.advancePhase).not.toHaveBeenCalled(); }); it('should not try to open phases that are already open', async () => { // Mock an open phase - const mockGetPhaseDetails = jest.fn().mockResolvedValue({ + mockChallengeApiService.getPhaseDetails.mockResolvedValueOnce({ id: 'open-phase-id', name: 'Open Phase', - isOpen: true, // This phase is already open + isOpen: true, scheduledStartDate: mockPastPhaseDate, }); - - challengeApiService.getPhaseDetails = mockGetPhaseDetails; - - const mockAdvancePhase = jest.fn(); - challengeApiService.advancePhase = mockAdvancePhase; + mockChallengeApiService.advancePhase.mockClear(); // Trigger phase advancement for an open phase with START state const phaseData = { @@ -646,7 +886,682 @@ describe('Autopilot Service (e2e)', () => { await new Promise((resolve) => setTimeout(resolve, 100)); // Should not call advancePhase since the phase is already open - expect(mockAdvancePhase).not.toHaveBeenCalled(); + expect(mockChallengeApiService.advancePhase).not.toHaveBeenCalled(); + }); + }); + + describe('Resource Events', () => { + it('should create pending reviews when a reviewer resource is added', async () => { + const challengeId = 'resource-challenge'; + const reviewPhase = { + id: 'review-phase-id', + phaseId: 'review-phase-template', + name: 'Review', + isOpen: true, + scheduledStartDate: mockPastPhaseDate, + scheduledEndDate: mockFuturePhaseDate1, + actualStartDate: mockPastPhaseDate, + actualEndDate: null, + predecessor: null, + }; + const challengeWithOpenReview = { + ...mockChallenge, + id: challengeId, + phases: [reviewPhase], + }; + + mockChallengeApiService.getChallengeById.mockResolvedValueOnce( + challengeWithOpenReview, + ); + resourcesServiceMockFns.getResourceById.mockResolvedValue({ + id: 'resource-1', + challengeId, + roleName: 'Reviewer', + }); + + await autopilotService.handleResourceCreated({ + id: 'resource-1', + challengeId, + memberId: '111', + memberHandle: 'reviewer', + roleId: 'role-1', + created: new Date().toISOString(), + createdBy: 'tester', + } as ResourceEventPayload); + + expect(phaseReviewServiceMockFns.handlePhaseOpened).toHaveBeenCalledWith( + challengeId, + reviewPhase.id, + ); + }); + + it('should remove pending reviews when a reviewer resource is deleted', async () => { + const challengeId = 'resource-challenge-delete'; + const reviewPhase = { + id: 'review-phase-id', + phaseId: 'review-phase-template', + name: 'Review', + isOpen: true, + scheduledStartDate: mockPastPhaseDate, + scheduledEndDate: mockFuturePhaseDate1, + actualStartDate: mockPastPhaseDate, + actualEndDate: null, + predecessor: null, + }; + const challengeWithReviewPhases = { + ...mockChallenge, + id: challengeId, + phases: [reviewPhase], + }; + + mockChallengeApiService.getChallengeById.mockResolvedValueOnce( + challengeWithReviewPhases, + ); + resourcesServiceMockFns.getRoleNameById.mockResolvedValue('Reviewer'); + reviewServiceMockFns.deletePendingReviewsForResource.mockResolvedValueOnce( + 2, + ); + + await autopilotService.handleResourceDeleted({ + id: 'resource-1', + challengeId, + memberId: '111', + memberHandle: 'reviewer', + roleId: 'role-1', + created: new Date().toISOString(), + createdBy: 'tester', + } as ResourceEventPayload); + + expect( + reviewServiceMockFns.deletePendingReviewsForResource, + ).toHaveBeenCalledWith(reviewPhase.id, 'resource-1', challengeId); + expect( + reviewAssignmentServiceMockFns.handleReviewerRemoved, + ).toHaveBeenCalledWith( + challengeId, + expect.objectContaining({ id: reviewPhase.id, name: reviewPhase.name }), + ); + }); + }); + + describe('Zero Submission handling', () => { + it('should create a post-mortem phase when submission phase closes with zero submissions', async () => { + const challengeId = 'zero-submission-challenge'; + const submissionPhase = { + id: 'submission-phase-id', + phaseId: 'submission-template', + name: 'Submission', + isOpen: true, + scheduledStartDate: mockPastPhaseDate, + scheduledEndDate: mockFuturePhaseDate1, + actualStartDate: mockPastPhaseDate, + actualEndDate: null, + predecessor: null, + }; + const challengeWithZeroSubmissions = { + ...mockChallenge, + id: challengeId, + numOfSubmissions: 0, + phases: [submissionPhase], + }; + + mockChallengeApiService.getPhaseDetails.mockResolvedValueOnce( + submissionPhase, + ); + mockChallengeApiService.getChallengeById.mockResolvedValueOnce( + challengeWithZeroSubmissions, + ); + reviewServiceMockFns.getActiveSubmissionCount.mockResolvedValueOnce(0); + resourcesServiceMockFns.getResourcesByRoleNames.mockResolvedValue([ + { id: 'postmortem-reviewer' }, + { id: 'postmortem-copilot' }, + ]); + const postMortemPhase = { + id: 'post-mortem-phase-id', + name: 'Post-Mortem', + scheduledEndDate: new Date(Date.now() + 3600_000).toISOString(), + }; + mockChallengeApiService.createPostMortemPhase.mockResolvedValueOnce( + postMortemPhase, + ); + const scheduleSpy = jest.spyOn( + schedulerService, + 'schedulePhaseTransition', + ); + + await schedulerService.advancePhase({ + projectId: challengeWithZeroSubmissions.projectId, + challengeId, + phaseId: submissionPhase.id, + phaseTypeName: submissionPhase.name, + state: 'END', + operator: AutopilotOperator.SYSTEM_SCHEDULER, + projectStatus: 'ACTIVE', + date: new Date().toISOString(), + }); + + await flushPromises(); + + expect( + mockChallengeApiService.createPostMortemPhase, + ).toHaveBeenCalledWith( + challengeId, + submissionPhase.id, + expect.any(Number), + ); + expect(reviewServiceMockFns.createPendingReview).toHaveBeenCalledTimes(2); + expect(scheduleSpy).toHaveBeenCalledWith( + expect.objectContaining({ phaseId: postMortemPhase.id }), + ); + }); + }); + + describe('First2Finish handling', () => { + it('should open iterative review and assign reviewer when a submission arrives', async () => { + const challengeId = 'f2f-challenge'; + const iterativePhase = { + id: 'iter-phase-id', + phaseId: 'iter-template-id', + name: 'Iterative Review', + isOpen: false, + scheduledStartDate: mockPastPhaseDate, + scheduledEndDate: mockFuturePhaseDate1, + actualStartDate: null, + actualEndDate: null, + predecessor: null, + }; + const submissionPhase = { + ...iterativePhase, + id: 'submission-phase-id', + phaseId: 'submission-template', + name: 'Submission', + }; + const f2fChallenge = { + ...mockChallenge, + id: challengeId, + type: 'first2finish', + phases: [iterativePhase, submissionPhase], + reviewers: [ + { + id: 'rev-config', + scorecardId: 'iter-scorecard', + isMemberReview: true, + memberReviewerCount: 1, + phaseId: iterativePhase.phaseId, + basePayment: null, + incrementalPayment: null, + type: null, + aiWorkflowId: null, + }, + ], + }; + + mockChallengeApiService.getChallengeById.mockResolvedValueOnce( + f2fChallenge, + ); + resourcesServiceMockFns.getReviewerResources.mockResolvedValueOnce([ + { id: 'iter-resource' }, + ]); + reviewServiceMockFns.getAllSubmissionIdsOrdered.mockResolvedValueOnce([ + 'submission-1', + ]); + reviewServiceMockFns.getExistingReviewPairs.mockResolvedValueOnce( + new Set(), + ); + mockChallengeApiService.advancePhase.mockResolvedValueOnce({ + success: true, + message: 'Phase opened', + updatedPhases: [ + { + id: iterativePhase.id, + name: iterativePhase.name, + scheduledEndDate: mockFuturePhaseDate2, + isOpen: true, + actualStartDate: new Date().toISOString(), + }, + ], + }); + + await autopilotService.handleFirst2FinishSubmission({ + challengeId, + submissionId: 'submission-1', + memberId: 'member-1', + memberHandle: 'member-1', + submittedAt: new Date().toISOString(), + } as First2FinishSubmissionPayload); + + expect(mockChallengeApiService.advancePhase).toHaveBeenCalledWith( + challengeId, + iterativePhase.id, + 'open', + ); + expect(reviewServiceMockFns.createPendingReview).toHaveBeenCalledWith( + 'submission-1', + 'iter-resource', + iterativePhase.id, + 'iter-scorecard', + challengeId, + ); + }); + + it('should close iterative review and submission when a passing score is received', async () => { + const challengeId = 'f2f-challenge-pass'; + const iterativePhase = { + id: 'iter-phase-id', + phaseId: 'iter-template-id', + name: 'Iterative Review', + isOpen: true, + scheduledStartDate: mockPastPhaseDate, + scheduledEndDate: mockFuturePhaseDate1, + actualStartDate: mockPastPhaseDate, + actualEndDate: null, + predecessor: null, + }; + const submissionPhase = { + id: 'submission-phase-id', + phaseId: 'submission-template', + name: 'Submission', + isOpen: true, + scheduledStartDate: mockPastPhaseDate, + scheduledEndDate: mockFuturePhaseDate2, + actualStartDate: mockPastPhaseDate, + actualEndDate: null, + predecessor: null, + }; + const f2fChallenge = { + ...mockChallenge, + id: challengeId, + type: 'first2finish', + phases: [iterativePhase, submissionPhase], + }; + + mockChallengeApiService.getChallengeById.mockResolvedValueOnce( + f2fChallenge, + ); + reviewServiceMockFns.getReviewById.mockResolvedValueOnce({ + id: 'review-1', + phaseId: iterativePhase.id, + resourceId: 'iter-resource', + submissionId: 'submission-1', + scorecardId: 'iter-scorecard', + score: 95, + status: 'COMPLETED', + } as any); + reviewServiceMockFns.getScorecardPassingScore.mockResolvedValueOnce(80); + const schedulerAdvanceSpy = jest + .spyOn(schedulerService, 'advancePhase') + .mockResolvedValue(); + + await autopilotService.handleReviewCompleted({ + challengeId, + submissionId: 'submission-1', + reviewId: 'review-1', + scorecardId: 'iter-scorecard', + reviewerResourceId: 'iter-resource', + reviewerHandle: 'iter-reviewer', + reviewerMemberId: 'iter-member', + submitterHandle: 'submitter', + submitterMemberId: 'submitter-id', + completedAt: new Date().toISOString(), + initialScore: 95, + } as ReviewCompletedPayload); + + expect(schedulerAdvanceSpy).toHaveBeenCalledWith( + expect.objectContaining({ phaseId: iterativePhase.id, state: 'END' }), + ); + expect(schedulerAdvanceSpy).toHaveBeenCalledWith( + expect.objectContaining({ phaseId: submissionPhase.id, state: 'END' }), + ); + + schedulerAdvanceSpy.mockRestore(); + }); + + it('should reassign the same submission to the reviewer when iterative review fails', async () => { + const challengeId = 'f2f-challenge-fail'; + const iterativePhase = { + id: 'iter-phase-id', + phaseId: 'iter-template-id', + name: 'Iterative Review', + isOpen: true, + scheduledStartDate: mockPastPhaseDate, + scheduledEndDate: mockFuturePhaseDate1, + actualStartDate: mockPastPhaseDate, + actualEndDate: null, + predecessor: null, + }; + const f2fChallenge = { + ...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, + }, + ], + }; + + mockChallengeApiService.getChallengeById + .mockResolvedValueOnce(f2fChallenge) + .mockResolvedValueOnce(f2fChallenge); + reviewServiceMockFns.getReviewById.mockResolvedValueOnce({ + id: 'review-1', + phaseId: iterativePhase.id, + resourceId: 'iter-resource', + submissionId: 'submission-1', + scorecardId: 'iter-scorecard', + score: 40, + status: 'COMPLETED', + } as any); + reviewServiceMockFns.getScorecardPassingScore.mockResolvedValueOnce(80); + reviewServiceMockFns.getAllSubmissionIdsOrdered.mockResolvedValueOnce([ + 'submission-1', + 'submission-2', + ]); + reviewServiceMockFns.getExistingReviewPairs.mockResolvedValueOnce( + new Set(), + ); + resourcesServiceMockFns.getReviewerResources.mockResolvedValueOnce([ + { id: 'iter-resource' }, + ]); + const schedulerAdvanceSpy = jest + .spyOn(schedulerService, 'advancePhase') + .mockResolvedValue(); + mockChallengeApiService.advancePhase.mockResolvedValueOnce({ + success: true, + message: 'Phase opened', + updatedPhases: [ + { + id: iterativePhase.id, + name: iterativePhase.name, + scheduledEndDate: mockFuturePhaseDate2, + isOpen: true, + actualStartDate: new Date().toISOString(), + }, + ], + }); + + await autopilotService.handleReviewCompleted({ + challengeId, + submissionId: 'submission-1', + reviewId: 'review-1', + scorecardId: 'iter-scorecard', + reviewerResourceId: 'iter-resource', + reviewerHandle: 'iter-reviewer', + reviewerMemberId: 'iter-member', + submitterHandle: 'submitter', + submitterMemberId: 'submitter-id', + completedAt: new Date().toISOString(), + initialScore: 40, + } as ReviewCompletedPayload); + + expect(reviewServiceMockFns.createPendingReview).toHaveBeenCalledWith( + 'submission-1', + 'iter-resource', + iterativePhase.id, + 'iter-scorecard', + challengeId, + ); + + schedulerAdvanceSpy.mockRestore(); + }); + + it('should assign the next submission when another pending review exists for the same reviewer', async () => { + const challengeId = 'f2f-challenge-pending'; + const iterativePhase = { + id: 'iter-phase-id', + phaseId: 'iter-template-id', + name: 'Iterative Review', + isOpen: true, + scheduledStartDate: mockPastPhaseDate, + scheduledEndDate: mockFuturePhaseDate1, + actualStartDate: mockPastPhaseDate, + actualEndDate: null, + predecessor: null, + }; + const f2fChallenge = { + ...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, + }, + ], + }; + + mockChallengeApiService.getChallengeById + .mockResolvedValueOnce(f2fChallenge) + .mockResolvedValueOnce(f2fChallenge); + reviewServiceMockFns.getReviewById.mockResolvedValueOnce({ + id: 'review-1', + phaseId: iterativePhase.id, + resourceId: 'iter-resource', + submissionId: 'submission-1', + scorecardId: 'iter-scorecard', + score: 40, + status: 'COMPLETED', + } as any); + reviewServiceMockFns.getScorecardPassingScore.mockResolvedValueOnce(80); + reviewServiceMockFns.getAllSubmissionIdsOrdered.mockResolvedValueOnce([ + 'submission-1', + 'submission-2', + ]); + reviewServiceMockFns.getExistingReviewPairs.mockResolvedValueOnce( + new Set(['iter-resource:submission-1']), + ); + resourcesServiceMockFns.getReviewerResources.mockResolvedValueOnce([ + { id: 'iter-resource' }, + ]); + const schedulerAdvanceSpy = jest + .spyOn(schedulerService, 'advancePhase') + .mockResolvedValue(); + mockChallengeApiService.advancePhase.mockResolvedValueOnce({ + success: true, + message: 'Phase opened', + updatedPhases: [ + { + id: iterativePhase.id, + name: iterativePhase.name, + scheduledEndDate: mockFuturePhaseDate2, + isOpen: true, + actualStartDate: new Date().toISOString(), + }, + ], + }); + + await autopilotService.handleReviewCompleted({ + challengeId, + submissionId: 'submission-1', + reviewId: 'review-1', + scorecardId: 'iter-scorecard', + reviewerResourceId: 'iter-resource', + reviewerHandle: 'iter-reviewer', + reviewerMemberId: 'iter-member', + submitterHandle: 'submitter', + submitterMemberId: 'submitter-id', + completedAt: new Date().toISOString(), + initialScore: 40, + } as ReviewCompletedPayload); + + expect(reviewServiceMockFns.createPendingReview).toHaveBeenCalledWith( + 'submission-2', + 'iter-resource', + iterativePhase.id, + 'iter-scorecard', + challengeId, + ); + + schedulerAdvanceSpy.mockRestore(); + }); + }); + + describe('Topgear Task handling', () => { + it('processes Topgear submissions through the iterative review flow', async () => { + const challengeId = 'topgear-challenge'; + const iterativePhase = { + id: 'iter-phase-id', + phaseId: 'iter-template-id', + name: 'Iterative Review', + isOpen: false, + scheduledStartDate: mockPastPhaseDate, + scheduledEndDate: mockFuturePhaseDate1, + actualStartDate: null, + actualEndDate: null, + predecessor: null, + }; + const submissionPhase = { + ...iterativePhase, + id: 'topgear-submission-phase-id', + phaseId: 'topgear-submission-template', + name: 'Topgear Submission', + }; + const challenge = { + ...mockChallenge, + id: challengeId, + type: 'Topgear Task', + phases: [iterativePhase, submissionPhase], + reviewers: [ + { + id: 'rev-config', + scorecardId: 'iter-scorecard', + isMemberReview: true, + memberReviewerCount: 1, + phaseId: iterativePhase.phaseId, + basePayment: null, + incrementalPayment: null, + type: null, + aiWorkflowId: null, + }, + ], + }; + + mockChallengeApiService.getChallengeById.mockResolvedValueOnce(challenge); + resourcesServiceMockFns.getReviewerResources.mockResolvedValueOnce([ + { id: 'iter-resource' }, + ]); + reviewServiceMockFns.getAllSubmissionIdsOrdered.mockResolvedValueOnce([ + 'submission-1', + ]); + reviewServiceMockFns.getExistingReviewPairs.mockResolvedValueOnce( + new Set(), + ); + mockChallengeApiService.advancePhase.mockResolvedValueOnce({ + success: true, + message: 'Phase opened', + updatedPhases: [ + { + id: iterativePhase.id, + name: iterativePhase.name, + scheduledEndDate: mockFuturePhaseDate2, + isOpen: true, + actualStartDate: new Date().toISOString(), + }, + ], + }); + + await autopilotService.handleTopgearSubmission({ + challengeId, + submissionId: 'submission-1', + memberId: 'member-1', + memberHandle: 'member-1', + submittedAt: new Date().toISOString(), + } as TopgearSubmissionPayload); + + expect(mockChallengeApiService.advancePhase).toHaveBeenCalledWith( + challengeId, + iterativePhase.id, + 'open', + ); + expect(reviewServiceMockFns.createPendingReview).toHaveBeenCalledWith( + 'submission-1', + 'iter-resource', + iterativePhase.id, + 'iter-scorecard', + challengeId, + ); + }); + + it('keeps Topgear submission phase open when late and prepares creator post-mortem review', async () => { + const challengeId = 'topgear-late-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.getActiveSubmissionCount.mockResolvedValueOnce(0); + resourcesServiceMockFns.getResourceByMemberHandle.mockResolvedValueOnce({ + id: 'creator-resource-id', + roleName: 'Copilot', + }); + reviewServiceMockFns.createPendingReview.mockResolvedValueOnce(true); + + 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).toHaveBeenCalledWith( + null, + 'creator-resource-id', + postMortemPhase.id, + 'topgear-post-mortem-scorecard', + challengeId, + ); }); }); });