From fa33b4fd9603ac49ec5c9575a2a4640009041485 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 27 Oct 2025 08:48:51 +1100 Subject: [PATCH 01/14] Updates for phase handling --- src/autopilot/constants/review.constants.ts | 19 ++++++++--- .../services/autopilot.service.spec.ts | 33 +++++++++++++++++-- src/autopilot/services/autopilot.service.ts | 4 +-- .../services/phase-review.service.ts | 4 +-- src/autopilot/services/scheduler.service.ts | 8 ++--- src/challenge/challenge-api.service.ts | 20 ++++++----- 6 files changed, 63 insertions(+), 25 deletions(-) diff --git a/src/autopilot/constants/review.constants.ts b/src/autopilot/constants/review.constants.ts index a1ce92c..3909935 100644 --- a/src/autopilot/constants/review.constants.ts +++ b/src/autopilot/constants/review.constants.ts @@ -1,12 +1,16 @@ -export const REVIEW_PHASE_NAMES = new Set([ +export const ITERATIVE_REVIEW_PHASE_NAME = 'Iterative Review'; +export const POST_MORTEM_PHASE_NAME = 'Post-Mortem'; +export const POST_MORTEM_PHASE_ALTERNATE_NAME = 'Post-mortem'; +export const POST_MORTEM_PHASE_NAMES = new Set([ + POST_MORTEM_PHASE_NAME, + POST_MORTEM_PHASE_ALTERNATE_NAME, +]); +export const REVIEW_PHASE_NAMES = new Set([ 'Review', 'Iterative Review', - 'Post-Mortem', + ...POST_MORTEM_PHASE_NAMES, 'Checkpoint Review', ]); - -export const ITERATIVE_REVIEW_PHASE_NAME = 'Iterative Review'; -export const POST_MORTEM_PHASE_NAME = 'Post-Mortem'; export const POST_MORTEM_REVIEWER_ROLE_NAME = 'Post-Mortem Reviewer'; export const REGISTRATION_PHASE_NAME = 'Registration'; export const SUBMISSION_PHASE_NAME = 'Submission'; @@ -39,6 +43,7 @@ export const PHASE_ROLE_MAP: Record = { Review: ['Reviewer'], 'Iterative Review': ['Iterative Reviewer'], 'Post-Mortem': [POST_MORTEM_REVIEWER_ROLE_NAME], + 'Post-mortem': [POST_MORTEM_REVIEWER_ROLE_NAME], 'Checkpoint Review': ['Checkpoint Reviewer'], Screening: ['Screener'], // Use the specific Checkpoint Screener role for checkpoint screening phases @@ -50,6 +55,10 @@ export function getRoleNamesForPhase(phaseName: string): string[] { return PHASE_ROLE_MAP[phaseName] ?? DEFAULT_PHASE_ROLES; } +export function isPostMortemPhaseName(phaseName: string): boolean { + return POST_MORTEM_PHASE_NAMES.has(phaseName); +} + export function isSubmissionPhaseName(phaseName: string): boolean { return SUBMISSION_PHASE_NAMES.has(phaseName); } diff --git a/src/autopilot/services/autopilot.service.spec.ts b/src/autopilot/services/autopilot.service.spec.ts index b87400c..84982dd 100644 --- a/src/autopilot/services/autopilot.service.spec.ts +++ b/src/autopilot/services/autopilot.service.spec.ts @@ -7,7 +7,10 @@ import type { ReviewCompletedPayload, SubmissionAggregatePayload, } from '../interfaces/autopilot.interface'; -import { POST_MORTEM_PHASE_NAME } from '../constants/review.constants'; +import { + POST_MORTEM_PHASE_NAME, + POST_MORTEM_PHASE_ALTERNATE_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'; @@ -232,6 +235,7 @@ describe('AutopilotService - handleSubmissionNotificationAggregate', () => { baseCoefficient: null, incrementalCoefficient: null, type: null, + shouldOpenOpportunity: false, aiWorkflowId: null, }, ], @@ -400,10 +404,10 @@ describe('AutopilotService - handleSubmissionNotificationAggregate', () => { }); describe('handleReviewCompleted (post-mortem)', () => { - const buildPhase = (): IPhase => ({ + const buildPhase = (name = POST_MORTEM_PHASE_NAME): IPhase => ({ id: 'phase-1', phaseId: 'template-1', - name: POST_MORTEM_PHASE_NAME, + name, description: null, isOpen: true, duration: 0, @@ -519,5 +523,28 @@ describe('AutopilotService - handleSubmissionNotificationAggregate', () => { }), ); }); + + it('closes the phase when the Post-mortem alias is used', async () => { + const postMortemAliasPhase = buildPhase( + POST_MORTEM_PHASE_ALTERNATE_NAME, + ); + challengeApiService.getChallengeById.mockResolvedValue( + buildChallenge(postMortemAliasPhase), + ); + + 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_ALTERNATE_NAME, + state: 'END', + }), + ); + }); }); }); diff --git a/src/autopilot/services/autopilot.service.ts b/src/autopilot/services/autopilot.service.ts index 6a198b4..1040c8e 100644 --- a/src/autopilot/services/autopilot.service.ts +++ b/src/autopilot/services/autopilot.service.ts @@ -24,7 +24,7 @@ import { DEFAULT_APPEALS_PHASE_NAMES, DEFAULT_APPEALS_RESPONSE_PHASE_NAMES, ITERATIVE_REVIEW_PHASE_NAME, - POST_MORTEM_PHASE_NAME, + isPostMortemPhaseName, REVIEW_PHASE_NAMES, SCREENING_PHASE_NAMES, APPROVAL_PHASE_NAMES, @@ -257,7 +257,7 @@ export class AutopilotService { return; } - if (phase.name === POST_MORTEM_PHASE_NAME) { + if (isPostMortemPhaseName(phase.name)) { const pendingPostMortemReviews = await this.reviewService.getPendingReviewCount(phase.id, challengeId); diff --git a/src/autopilot/services/phase-review.service.ts b/src/autopilot/services/phase-review.service.ts index c13ffdd..5c0f833 100644 --- a/src/autopilot/services/phase-review.service.ts +++ b/src/autopilot/services/phase-review.service.ts @@ -13,7 +13,7 @@ import { REVIEW_PHASE_NAMES, SCREENING_PHASE_NAMES, APPROVAL_PHASE_NAMES, - POST_MORTEM_PHASE_NAME, + isPostMortemPhaseName, POST_MORTEM_REVIEWER_ROLE_NAME, ITERATIVE_REVIEW_PHASE_NAME, } from '../constants/review.constants'; @@ -87,7 +87,7 @@ export class PhaseReviewService { } // Special handling for Post-Mortem: create challenge-level pending reviews (no submissions) - if (phase.name === POST_MORTEM_PHASE_NAME) { + if (isPostMortemPhaseName(phase.name)) { // Determine scorecard let scorecardId: string | null = null; if (isTopgearTaskChallenge(challenge.type)) { diff --git a/src/autopilot/services/scheduler.service.ts b/src/autopilot/services/scheduler.service.ts index 34bbfda..1c69e0b 100644 --- a/src/autopilot/services/scheduler.service.ts +++ b/src/autopilot/services/scheduler.service.ts @@ -20,7 +20,6 @@ import { Job, Queue, RedisOptions, Worker } from 'bullmq'; import { ChallengeStatusEnum } from '@prisma/client'; import { ReviewService } from '../../review/review.service'; import { - POST_MORTEM_PHASE_NAME, POST_MORTEM_REVIEWER_ROLE_NAME, REGISTRATION_PHASE_NAME, REVIEW_PHASE_NAMES, @@ -29,6 +28,7 @@ import { SUBMISSION_PHASE_NAME, TOPGEAR_SUBMISSION_PHASE_NAME, getRoleNamesForPhase, + isPostMortemPhaseName, } from '../constants/review.constants'; import { ResourcesService } from '../../resources/resources.service'; import { isTopgearTaskChallenge } from '../constants/challenge.constants'; @@ -961,7 +961,7 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { err.stack, ); } - } else if (phaseName === POST_MORTEM_PHASE_NAME) { + } else if (isPostMortemPhaseName(phaseName)) { try { await this.handlePostMortemPhaseClosed(data); skipFinalization = true; @@ -1357,7 +1357,7 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { } let postMortemPhase = - challenge.phases?.find((p) => p.name === POST_MORTEM_PHASE_NAME) ?? null; + challenge.phases?.find((p) => isPostMortemPhaseName(p.name)) ?? null; if (!postMortemPhase) { try { @@ -1883,7 +1883,7 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { } const roleNames = - phaseName === POST_MORTEM_PHASE_NAME && this.postMortemRoles.length + isPostMortemPhaseName(phaseName) && this.postMortemRoles.length ? this.postMortemRoles : getRoleNamesForPhase(phaseName); diff --git a/src/challenge/challenge-api.service.ts b/src/challenge/challenge-api.service.ts index 5517c88..ad17f86 100644 --- a/src/challenge/challenge-api.service.ts +++ b/src/challenge/challenge-api.service.ts @@ -13,6 +13,8 @@ import { DEFAULT_APPEALS_PHASE_NAMES, DEFAULT_APPEALS_RESPONSE_PHASE_NAMES, APPROVAL_PHASE_NAMES, + POST_MORTEM_PHASE_NAMES, + isPostMortemPhaseName, } from '../autopilot/constants/review.constants'; // DTO for filtering challenges @@ -859,8 +861,8 @@ export class ChallengeApiService { const submissionPhase = challenge.phases[submissionPhaseIndex]; const futurePhases = challenge.phases.slice(submissionPhaseIndex + 1); - const postMortemPhases = futurePhases.filter( - (phase) => phase.name === 'Post-Mortem', + const postMortemPhases = futurePhases.filter((phase) => + isPostMortemPhaseName(phase.name), ); const existingPostMortem = postMortemPhases[0] ?? null; @@ -871,7 +873,7 @@ export class ChallengeApiService { } const phasesToDelete = futurePhases - .filter((phase) => phase.name !== 'Post-Mortem') + .filter((phase) => !isPostMortemPhaseName(phase.name)) .map((phase) => phase.id); if (phasesToDelete.length) { @@ -906,8 +908,8 @@ export class ChallengeApiService { return { postMortemPhaseId: existingPostMortem.id }; } - const postMortemPhaseType = await tx.phase.findUnique({ - where: { name: 'Post-Mortem' }, + const postMortemPhaseType = await tx.phase.findFirst({ + where: { name: { in: Array.from(POST_MORTEM_PHASE_NAMES) } }, }); if (!postMortemPhaseType) { @@ -1063,15 +1065,15 @@ export class ChallengeApiService { } // If a Post-Mortem already exists, return it idempotently. - const existing = challenge.phases.find( - (phase) => phase.name === 'Post-Mortem', + const existing = challenge.phases.find((phase) => + isPostMortemPhaseName(phase.name), ); if (existing) { return { createdPhaseId: existing.id }; } - const postMortemPhaseType = await tx.phase.findUnique({ - where: { name: 'Post-Mortem' }, + const postMortemPhaseType = await tx.phase.findFirst({ + where: { name: { in: Array.from(POST_MORTEM_PHASE_NAMES) } }, }); if (!postMortemPhaseType) { From fe28c2b07712224846357eca6ef726a5dccc8485 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 27 Oct 2025 16:52:36 +1100 Subject: [PATCH 02/14] Ignore checkpoint submissions in approval phase --- src/review/review.service.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/review/review.service.ts b/src/review/review.service.ts index 67ae34d..8fd9f1b 100644 --- a/src/review/review.service.ts +++ b/src/review/review.service.ts @@ -1429,6 +1429,10 @@ export class ReviewService { ON sc."id" = rs."scorecardId" WHERE s."challengeId" = ${challengeId} AND rs."isFinal" = true + AND ( + s."type" IS NULL + OR UPPER((s."type")::text) = 'CONTEST_SUBMISSION' + ) `); if (!rows.length) { @@ -1503,6 +1507,10 @@ export class ReviewService { LEFT JOIN ${ReviewService.SCORECARD_TABLE} sc ON sc."id" = r."scorecardId" WHERE s."challengeId" = ${challengeId} + AND ( + s."type" IS NULL + OR UPPER((s."type")::text) = 'CONTEST_SUBMISSION' + ) GROUP BY s."id", s."legacySubmissionId", s."memberId", s."submittedDate" `; @@ -1543,6 +1551,10 @@ export class ReviewService { SELECT "id" FROM ${ReviewService.SUBMISSION_TABLE} WHERE "challengeId" = ${challengeId} + AND ( + "type" IS NULL + OR UPPER(("type")::text) = 'CONTEST_SUBMISSION' + ) ) `, ); From d2590ce0bfddb8d08730e79b2fb39ca6e51bf8e9 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 27 Oct 2025 21:41:51 +1100 Subject: [PATCH 03/14] Fixes for end of challenge generating review summations, also used for approval --- d096b255-c36a-4571-bddd-e8ae2bf761fd.json | 1469 ----------------- src/autopilot/autopilot.module.ts | 2 + .../challenge-completion.service.spec.ts | 37 + .../services/challenge-completion.service.ts | 4 + .../services/phase-review.service.spec.ts | 18 + .../services/phase-review.service.ts | 3 + .../services/review-summation-api.service.ts | 116 ++ src/config/sections/review.config.ts | 10 + src/config/validation.ts | 5 + 9 files changed, 195 insertions(+), 1469 deletions(-) delete mode 100644 d096b255-c36a-4571-bddd-e8ae2bf761fd.json create mode 100644 src/autopilot/services/review-summation-api.service.ts diff --git a/d096b255-c36a-4571-bddd-e8ae2bf761fd.json b/d096b255-c36a-4571-bddd-e8ae2bf761fd.json deleted file mode 100644 index 4f68e37..0000000 --- a/d096b255-c36a-4571-bddd-e8ae2bf761fd.json +++ /dev/null @@ -1,1469 +0,0 @@ - -> autopilot-service@0.0.1 pull:logs /home/jmgasper/Documents/Git/v6/autopilot-v6 -> ts-node scripts/fetch-autopilot-actions.ts d096b255-c36a-4571-bddd-e8ae2bf761fd - -[ - { - "id": "afbe0251-ef6c-4a67-a974-10db472a77f2", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getResourceById", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleName": "Manager", - "resourceId": "c44892b4-1979-4139-bd42-3d954dddad8d" - }, - "createdAt": "2025-10-16T04:01:13.396Z" - }, - { - "id": "a6baeada-c544-4751-be19-48461425d5b3", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T04:01:13.484Z" - }, - { - "id": "326a9b11-859a-4d7b-8cf2-b2e777a1458e", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T04:01:35.838Z" - }, - { - "id": "22d19d0c-cf3e-4c96-b875-68a89f94f359", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getResourceById", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleName": "Copilot", - "resourceId": "3e5a966a-94bf-4025-b829-b69bf46153d0" - }, - "createdAt": "2025-10-16T04:01:39.547Z" - }, - { - "id": "4aad2b1a-919d-4468-a306-7e1d981906ba", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T04:01:39.557Z" - }, - { - "id": "fbd58d38-94b2-4961-8a53-01cb6d41ef12", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T04:01:40.571Z" - }, - { - "id": "bd8d159c-9d6d-4659-9f72-50a5c59227fd", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T04:01:48.983Z" - }, - { - "id": "f2c2a175-e1fe-4ad9-bef3-dd02b0bcc71b", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getResourceById", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleName": "Iterative Reviewer", - "resourceId": "f09b913d-4902-4f30-88ae-efcfd9c33b08" - }, - "createdAt": "2025-10-16T05:16:10.138Z" - }, - { - "id": "892a5262-073f-4a4d-ab13-817dc147583b", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T05:16:10.154Z" - }, - { - "id": "793ea390-7b98-4eaf-854d-8f77c6cb4176", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getReviewerResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "reviewerCount": 1 - }, - "createdAt": "2025-10-16T05:16:10.156Z" - }, - { - "id": "3df96924-99ee-4cee-9eb7-7231808e94bd", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getReviewerResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "reviewerCount": 1 - }, - "createdAt": "2025-10-16T05:16:10.157Z" - }, - { - "id": "5fd71fd2-91d2-46b9-a8f4-8d8ef595ac82", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getAllSubmissionIdsOrdered", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "submissionCount": 0 - }, - "createdAt": "2025-10-16T05:16:10.160Z" - }, - { - "id": "1d88c8ad-c81c-4771-ada6-e167420fbbb3", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getResourceById", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleName": "Submitter", - "resourceId": "eba72cc4-d3f5-4e13-98e1-961baaf0f5da" - }, - "createdAt": "2025-10-16T05:17:24.524Z" - }, - { - "id": "60e7d51d-e611-44ad-b8be-ae52f5f13003", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T05:17:46.468Z" - }, - { - "id": "77a213ad-ae46-47cd-ac74-39faac0d0bfd", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getReviewerResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "reviewerCount": 1 - }, - "createdAt": "2025-10-16T05:17:46.475Z" - }, - { - "id": "c09aeeff-48cf-4be5-8358-01dbef8596bb", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T05:17:46.496Z" - }, - { - "id": "8eb9dc4f-c92b-42d7-ad81-4b5d735210ba", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 3 - }, - "createdAt": "2025-10-16T05:17:46.496Z" - }, - { - "id": "85898321-83cb-4f64-bb01-90f5bc2fabe2", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "71f92da7-338a-431b-ba57-e381f6502d4f" - }, - "createdAt": "2025-10-16T05:17:46.497Z" - }, - { - "id": "f01af962-35e8-40f6-9ad4-3bdf476fb891", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.advancePhase", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseId": "71f92da7-338a-431b-ba57-e381f6502d4f", - "operation": "open", - "nextPhaseCount": 0, - "scheduleAdjusted": true, - "hasWinningSubmission": false - }, - "createdAt": "2025-10-16T05:17:46.539Z" - }, - { - "id": "43b4abf2-4b3f-48e3-abae-0809d9360d33", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getPhaseChangeNotificationResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "recipientCount": 4 - }, - "createdAt": "2025-10-16T05:17:46.543Z" - }, - { - "id": "1dd4a552-13f0-4544-b959-2004af158e2d", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T05:17:46.640Z" - }, - { - "id": "a1ef395c-448c-423f-b533-c22efe4c0e89", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "notifications.phaseChange", - "status": "SUCCESS", - "source": "PhaseChangeNotificationService", - "details": { - "payload": { - "phaseOpen": "Iterative Review", - "phaseClose": null, - "challengeURL": "https://review-v6.topcoder-dev.com/review/active-challenges/d096b255-c36a-4571-bddd-e8ae2bf761fd/challenge-details", - "challengeName": "SS Dev F2F1 Oct 16", - "phaseOpenDate": "16-10-2025 01:17 EDT", - "phaseCloseDate": null - }, - "phaseId": "71f92da7-338a-431b-ba57-e381f6502d4f", - "operation": "open", - "recipients": 3 - }, - "createdAt": "2025-10-16T05:17:46.922Z" - }, - { - "id": "ade5a7f9-6d2e-4c0b-af11-cc02262ea66f", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T05:17:46.943Z" - }, - { - "id": "4457e396-8812-48cb-a80a-ba97ad5d5071", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getReviewerResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "reviewerCount": 1 - }, - "createdAt": "2025-10-16T05:17:46.944Z" - }, - { - "id": "d1956d80-c6cf-4467-bd0c-06c57ad55e75", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getActiveContestSubmissions", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "submissionCount": 1 - }, - "createdAt": "2025-10-16T05:17:46.948Z" - }, - { - "id": "d32a5bb8-9c93-490c-abd4-889d5832b1e9", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getExistingReviewPairs", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "phaseId": "71f92da7-338a-431b-ba57-e381f6502d4f", - "pairCount": 0 - }, - "createdAt": "2025-10-16T05:17:46.951Z" - }, - { - "id": "bfb0cf5a-6071-413b-b583-2246718a2ae3", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.createPendingReview", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "created": true, - "phaseId": "71f92da7-338a-431b-ba57-e381f6502d4f", - "reviewId": "8f3b9f0d-03ea-401f-9d23-6d445cc5b379", - "resourceId": "f09b913d-4902-4f30-88ae-efcfd9c33b08", - "scorecardId": "w4l7tOAFrTEo2_", - "submissionId": "DmQNfAgaBYrHqp", - "pendingReviewIds": [ - "8f3b9f0d-03ea-401f-9d23-6d445cc5b379" - ] - }, - "createdAt": "2025-10-16T05:17:46.963Z" - }, - { - "id": "b3bdbe24-0c18-41f4-b94a-6de6db25c81e", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 3 - }, - "createdAt": "2025-10-16T05:17:46.978Z" - }, - { - "id": "70b7a18e-eb94-4a61-88a6-a1b9a96ac278", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T05:17:46.978Z" - }, - { - "id": "3efed46b-febf-41d1-ace1-089704096566", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "71f92da7-338a-431b-ba57-e381f6502d4f" - }, - "createdAt": "2025-10-16T05:17:46.979Z" - }, - { - "id": "9f36b2a0-c258-41ab-a971-9c256d2c8e9d", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getAllSubmissionIdsOrdered", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "submissionCount": 1 - }, - "createdAt": "2025-10-16T05:17:46.980Z" - }, - { - "id": "c7694382-b087-404d-8092-14b0726c3363", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getExistingReviewPairs", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "phaseId": "71f92da7-338a-431b-ba57-e381f6502d4f", - "pairCount": 1 - }, - "createdAt": "2025-10-16T05:17:46.981Z" - }, - { - "id": "63abdec9-a91e-4107-8614-881b81740148", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getReviewerSubmissionPairs", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "pairCount": 1 - }, - "createdAt": "2025-10-16T05:17:46.990Z" - }, - { - "id": "49e62eda-d053-4e67-8b9c-7c6e7f1fb6f0", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T05:17:47.006Z" - }, - { - "id": "0600c368-398d-4af0-a79a-a122464e9a1b", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 3 - }, - "createdAt": "2025-10-16T05:17:47.006Z" - }, - { - "id": "2ffcad06-9411-4a3e-a8e6-0a1a1a33096e", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "71f92da7-338a-431b-ba57-e381f6502d4f" - }, - "createdAt": "2025-10-16T05:17:47.006Z" - }, - { - "id": "1d5898e8-5453-4986-9c38-861d78c16382", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T05:17:47.022Z" - }, - { - "id": "14c8bc7c-0af7-44bf-bc65-d7939db15d6e", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getReviewerResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "reviewerCount": 1 - }, - "createdAt": "2025-10-16T05:17:47.024Z" - }, - { - "id": "8b28d2a0-2f4a-4352-8960-68ccfe4502cd", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getPendingReviewCount", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "phaseId": "71f92da7-338a-431b-ba57-e381f6502d4f", - "pendingCount": 1 - }, - "createdAt": "2025-10-16T05:17:47.025Z" - }, - { - "id": "46d99934-f121-47bb-aa1d-33b06df51bf0", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getResourceById", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleName": "Submitter", - "resourceId": "4e5493e1-d9e9-4179-acc8-c042443ed5eb" - }, - "createdAt": "2025-10-16T05:18:02.473Z" - }, - { - "id": "41b43a27-e4dd-4812-975f-1776005b386a", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T05:18:49.806Z" - }, - { - "id": "8e076175-5a36-451a-add8-54bb83d7ff4b", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getReviewerResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "reviewerCount": 1 - }, - "createdAt": "2025-10-16T05:18:49.807Z" - }, - { - "id": "32c452a3-df3c-4c47-9eeb-ca30747c7b0e", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getPendingReviewCount", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "phaseId": "71f92da7-338a-431b-ba57-e381f6502d4f", - "pendingCount": 1 - }, - "createdAt": "2025-10-16T05:18:49.809Z" - }, - { - "id": "53640603-154f-42bb-88d6-0e1eb1b3b600", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getResourceById", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleName": "Submitter", - "resourceId": "017b8e6d-a586-4496-a7a4-f556d06bc605" - }, - "createdAt": "2025-10-16T05:19:49.187Z" - }, - { - "id": "3720e99c-30fa-457f-8de7-a8b309fd1562", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T05:20:12.196Z" - }, - { - "id": "a4fa91b8-414d-4902-8bbd-91c6a7fec09a", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getReviewerResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "reviewerCount": 1 - }, - "createdAt": "2025-10-16T05:20:12.229Z" - }, - { - "id": "fe1ff4aa-f2fc-4b75-96c3-0c61c116b042", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getPendingReviewCount", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "phaseId": "71f92da7-338a-431b-ba57-e381f6502d4f", - "pendingCount": 1 - }, - "createdAt": "2025-10-16T05:20:12.260Z" - }, - { - "id": "2250843d-c90b-43e0-8269-3c0aa56ec2e0", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T05:20:43.084Z" - }, - { - "id": "f4b77c91-9f85-4583-a0e6-992fd2a367c1", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 3 - }, - "createdAt": "2025-10-16T05:20:43.104Z" - }, - { - "id": "2e72dfd8-3f78-41cb-b0a3-d657f1d97013", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "71f92da7-338a-431b-ba57-e381f6502d4f" - }, - "createdAt": "2025-10-16T05:20:43.105Z" - }, - { - "id": "05a0193c-8429-48c6-b88a-958ad1093b4c", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T05:20:43.121Z" - }, - { - "id": "c29e13f3-5951-4aaf-a78a-408b73077aa7", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getReviewerResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "reviewerCount": 1 - }, - "createdAt": "2025-10-16T05:20:43.133Z" - }, - { - "id": "5f80fc12-08b8-4a95-b9d6-9fb57fbd65bb", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getPendingReviewCount", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "phaseId": "71f92da7-338a-431b-ba57-e381f6502d4f", - "pendingCount": 0 - }, - "createdAt": "2025-10-16T05:20:43.135Z" - }, - { - "id": "1ef3b413-726d-42c5-80a5-cc797688c510", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T05:20:43.135Z" - }, - { - "id": "8bb676da-f4d7-4b10-8c41-525a53bec8a4", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.advancePhase", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseId": "71f92da7-338a-431b-ba57-e381f6502d4f", - "operation": "close", - "nextPhaseCount": 0, - "scheduleAdjusted": false, - "hasWinningSubmission": false - }, - "createdAt": "2025-10-16T05:20:43.176Z" - }, - { - "id": "bf3b8db0-d36d-4183-b69a-b67797eca493", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getPhaseChangeNotificationResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "recipientCount": 6 - }, - "createdAt": "2025-10-16T05:20:43.178Z" - }, - { - "id": "32ad6315-bc53-45f8-905c-13bc09c07c37", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T05:20:43.202Z" - }, - { - "id": "34bfcf8f-71ef-422a-a91b-c4a0e925fe2f", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "notifications.phaseChange", - "status": "SUCCESS", - "source": "PhaseChangeNotificationService", - "details": { - "payload": { - "phaseOpen": null, - "phaseClose": "Iterative Review", - "challengeURL": "https://review-v6.topcoder-dev.com/review/active-challenges/d096b255-c36a-4571-bddd-e8ae2bf761fd/challenge-details", - "challengeName": "SS Dev F2F1 Oct 16", - "phaseOpenDate": null, - "phaseCloseDate": "16-10-2025 01:20 EDT" - }, - "phaseId": "71f92da7-338a-431b-ba57-e381f6502d4f", - "operation": "close", - "recipients": 5 - }, - "createdAt": "2025-10-16T05:20:43.244Z" - }, - { - "id": "c238c199-c85b-4576-8e2d-d7483ad88c19", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 3 - }, - "createdAt": "2025-10-16T05:20:43.264Z" - }, - { - "id": "8f0e54ea-1063-40f7-95b4-7fbb83f154a6", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getReviewerResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "reviewerCount": 1 - }, - "createdAt": "2025-10-16T05:20:43.265Z" - }, - { - "id": "d1341d41-fd16-41ab-b596-133d1414d853", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getAllSubmissionIdsOrdered", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "submissionCount": 3 - }, - "createdAt": "2025-10-16T05:20:43.268Z" - }, - { - "id": "5ea11e8e-ab95-4e1e-95b7-5b41c6ad9eb7", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getExistingReviewPairs", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "phaseId": "71f92da7-338a-431b-ba57-e381f6502d4f", - "pairCount": 0 - }, - "createdAt": "2025-10-16T05:20:43.270Z" - }, - { - "id": "3bdd5b4f-dafa-4668-9f01-f488a3506b72", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getReviewerSubmissionPairs", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "pairCount": 1 - }, - "createdAt": "2025-10-16T05:20:43.273Z" - }, - { - "id": "3883f727-5f85-437e-9c54-2513643f222d", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.createIterativeReviewPhase", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseId": "94974f40-5132-4d52-bb7d-34db66d7b4c1", - "duration": 86400, - "phaseTypeId": "003a4b14-de5d-43fc-9e35-835dbeb6af1f", - "predecessorPhaseId": "71f92da7-338a-431b-ba57-e381f6502d4f" - }, - "createdAt": "2025-10-16T05:20:43.314Z" - }, - { - "id": "2ab7b053-99a4-4a38-b231-5356681cfc95", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getAllSubmissionIdsOrdered", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "submissionCount": 3 - }, - "createdAt": "2025-10-16T05:20:43.315Z" - }, - { - "id": "db3ef544-8d27-4412-951c-9857b9bf1108", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getExistingReviewPairs", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "phaseId": "94974f40-5132-4d52-bb7d-34db66d7b4c1", - "pairCount": 0 - }, - "createdAt": "2025-10-16T05:20:43.318Z" - }, - { - "id": "16330bd6-5216-4b80-a2cd-cf92fb139ea2", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getReviewerSubmissionPairs", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "pairCount": 1 - }, - "createdAt": "2025-10-16T05:20:43.319Z" - }, - { - "id": "e4b82ef7-6f0c-4689-a1e5-a4e235e3c465", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.createPendingReview", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "created": true, - "phaseId": "94974f40-5132-4d52-bb7d-34db66d7b4c1", - "reviewId": "1d4a3e3f-f441-438a-8bd6-a9a0c67255ab", - "resourceId": "f09b913d-4902-4f30-88ae-efcfd9c33b08", - "scorecardId": "w4l7tOAFrTEo2_", - "submissionId": "cklibActRTveXT", - "pendingReviewIds": [ - "1d4a3e3f-f441-438a-8bd6-a9a0c67255ab" - ] - }, - "createdAt": "2025-10-16T05:20:43.328Z" - }, - { - "id": "999e09fe-53c5-4c1c-8caa-c45854b89236", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 4 - }, - "createdAt": "2025-10-16T05:21:13.346Z" - }, - { - "id": "3907b80d-7889-4d80-802b-7440eb641d3b", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getPendingReviewCount", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "phaseId": "94974f40-5132-4d52-bb7d-34db66d7b4c1", - "pendingCount": 0 - }, - "createdAt": "2025-10-16T05:21:13.352Z" - }, - { - "id": "28985c79-efb0-4581-800c-f5244e56e915", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getReviewerResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "reviewerCount": 1 - }, - "createdAt": "2025-10-16T05:21:13.354Z" - }, - { - "id": "0a336a2a-4bb4-4d5c-8e4f-266dda097dbf", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getAllSubmissionIdsOrdered", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "submissionCount": 3 - }, - "createdAt": "2025-10-16T05:21:13.356Z" - }, - { - "id": "04bf085d-1452-43e4-8db3-97aa1dbca38d", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 4 - }, - "createdAt": "2025-10-16T05:21:13.356Z" - }, - { - "id": "fed2c37e-46a5-4201-a450-280aa85b33aa", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getExistingReviewPairs", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "phaseId": "94974f40-5132-4d52-bb7d-34db66d7b4c1", - "pairCount": 0 - }, - "createdAt": "2025-10-16T05:21:13.360Z" - }, - { - "id": "9e45530c-623e-40d3-be60-0828f2450787", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getReviewerSubmissionPairs", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "pairCount": 2 - }, - "createdAt": "2025-10-16T05:21:13.363Z" - }, - { - "id": "ba068494-98a7-43ca-b971-d36f0b9eb9dd", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 4 - }, - "createdAt": "2025-10-16T05:21:13.374Z" - }, - { - "id": "79d7608c-7fb6-4f55-a080-dbd36c1b48fb", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "94974f40-5132-4d52-bb7d-34db66d7b4c1" - }, - "createdAt": "2025-10-16T05:21:13.374Z" - }, - { - "id": "bbc236ff-b250-4d12-bd68-97f723c1334e", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 4 - }, - "createdAt": "2025-10-16T05:21:13.374Z" - }, - { - "id": "d1cf18ef-1f11-4874-8c06-4e74d8a1ca8a", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.createPendingReview", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "created": true, - "phaseId": "94974f40-5132-4d52-bb7d-34db66d7b4c1", - "reviewId": "a69a7e8c-c83f-405f-afde-49d8f91de962", - "resourceId": "f09b913d-4902-4f30-88ae-efcfd9c33b08", - "scorecardId": "w4l7tOAFrTEo2_", - "submissionId": "wKVZ5a8UeJ8b5v", - "pendingReviewIds": [ - "a69a7e8c-c83f-405f-afde-49d8f91de962" - ] - }, - "createdAt": "2025-10-16T05:21:13.376Z" - }, - { - "id": "dc11bcf5-5414-497e-bc49-1dcbb231b9a0", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 4 - }, - "createdAt": "2025-10-16T05:21:13.389Z" - }, - { - "id": "5c699170-8e0e-4e14-b1a4-3828cd309f57", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getReviewerResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "reviewerCount": 1 - }, - "createdAt": "2025-10-16T05:21:13.391Z" - }, - { - "id": "ddb0bb98-8c47-4f32-aa1b-29b100afbc14", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getPendingReviewCount", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "phaseId": "94974f40-5132-4d52-bb7d-34db66d7b4c1", - "pendingCount": 1 - }, - "createdAt": "2025-10-16T05:21:13.392Z" - }, - { - "id": "d9845d22-a2dc-41ca-b723-c5e9cf5f6e08", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 4 - }, - "createdAt": "2025-10-16T05:21:13.409Z" - }, - { - "id": "fa020ade-6a81-422c-87ad-fd92463c7546", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 4 - }, - "createdAt": "2025-10-16T05:23:16.491Z" - }, - { - "id": "88cae44a-f956-4782-b92b-6bfc65753735", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 4 - }, - "createdAt": "2025-10-16T05:23:16.496Z" - }, - { - "id": "171a2483-38d3-4a2f-9702-2a2ea8c6560b", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 4 - }, - "createdAt": "2025-10-16T05:23:16.499Z" - }, - { - "id": "942980ea-23e0-4142-9db7-ca4f1f8ac715", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "94974f40-5132-4d52-bb7d-34db66d7b4c1" - }, - "createdAt": "2025-10-16T05:23:16.501Z" - }, - { - "id": "fbd88cc1-48a1-4fe4-bb1a-9c898173ce31", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 4 - }, - "createdAt": "2025-10-16T05:23:16.514Z" - }, - { - "id": "3f4f935d-051e-4d59-bc80-0c8017481580", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getReviewerResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "reviewerCount": 1 - }, - "createdAt": "2025-10-16T05:23:16.544Z" - }, - { - "id": "5273413e-da57-46aa-9266-59e0c3dc7b54", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getPendingReviewCount", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "phaseId": "94974f40-5132-4d52-bb7d-34db66d7b4c1", - "pendingCount": 0 - }, - "createdAt": "2025-10-16T05:23:16.545Z" - }, - { - "id": "9a0ecc8d-b8f4-4862-a501-5b54cdbc7cde", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.advancePhase", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseId": "94974f40-5132-4d52-bb7d-34db66d7b4c1", - "operation": "close", - "nextPhaseCount": 0, - "scheduleAdjusted": false, - "hasWinningSubmission": false - }, - "createdAt": "2025-10-16T05:23:16.583Z" - }, - { - "id": "b2b28978-d300-4cf9-9361-faf7b9d664be", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getPhaseChangeNotificationResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "recipientCount": 6 - }, - "createdAt": "2025-10-16T05:23:16.585Z" - }, - { - "id": "b4287b88-a102-4f6e-9df5-2fa0a5967731", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 4 - }, - "createdAt": "2025-10-16T05:23:16.640Z" - }, - { - "id": "ee551524-4cc6-48b1-8a9d-c558477621cd", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "notifications.phaseChange", - "status": "SUCCESS", - "source": "PhaseChangeNotificationService", - "details": { - "payload": { - "phaseOpen": null, - "phaseClose": "Iterative Review", - "challengeURL": "https://review-v6.topcoder-dev.com/review/active-challenges/d096b255-c36a-4571-bddd-e8ae2bf761fd/challenge-details", - "challengeName": "SS Dev F2F1 Oct 16", - "phaseOpenDate": null, - "phaseCloseDate": "16-10-2025 01:23 EDT" - }, - "phaseId": "94974f40-5132-4d52-bb7d-34db66d7b4c1", - "operation": "close", - "recipients": 5 - }, - "createdAt": "2025-10-16T05:23:16.680Z" - }, - { - "id": "3a6f37ca-da6b-4530-ae52-beda41871324", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 4 - }, - "createdAt": "2025-10-16T05:23:16.700Z" - }, - { - "id": "391dfe11-616e-472c-8fe3-463707fec307", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 4 - }, - "createdAt": "2025-10-16T05:23:16.700Z" - }, - { - "id": "f27ea6a0-e495-4bae-83ba-486a7ab735b9", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "a2cbb24f-f8d8-4750-90de-68a19786323f" - }, - "createdAt": "2025-10-16T05:23:16.700Z" - }, - { - "id": "291a0fef-1049-47e3-99d9-389544d56f5a", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.advancePhase", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseId": "a2cbb24f-f8d8-4750-90de-68a19786323f", - "operation": "close", - "nextPhaseCount": 0, - "scheduleAdjusted": false, - "hasWinningSubmission": false - }, - "createdAt": "2025-10-16T05:23:16.739Z" - }, - { - "id": "844112b1-298b-406f-b891-72773573cd4e", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getPhaseChangeNotificationResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "recipientCount": 6 - }, - "createdAt": "2025-10-16T05:23:16.741Z" - }, - { - "id": "a0f18e88-3562-4887-98d9-cb0d6b2da051", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 4 - }, - "createdAt": "2025-10-16T05:23:16.760Z" - }, - { - "id": "31d1d51c-6600-48af-aea4-11052162307d", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "notifications.phaseChange", - "status": "SUCCESS", - "source": "PhaseChangeNotificationService", - "details": { - "payload": { - "phaseOpen": null, - "phaseClose": "Submission", - "challengeURL": "https://review-v6.topcoder-dev.com/review/active-challenges/d096b255-c36a-4571-bddd-e8ae2bf761fd/challenge-details", - "challengeName": "SS Dev F2F1 Oct 16", - "phaseOpenDate": null, - "phaseCloseDate": "16-10-2025 01:23 EDT" - }, - "phaseId": "a2cbb24f-f8d8-4750-90de-68a19786323f", - "operation": "close", - "recipients": 5 - }, - "createdAt": "2025-10-16T05:23:16.786Z" - }, - { - "id": "f20ba990-e96d-4aaf-97c1-87f39b04f26a", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getActiveContestSubmissionIds", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "submissionCount": 3 - }, - "createdAt": "2025-10-16T05:23:16.791Z" - }, - { - "id": "446a8fe0-33a0-497c-a66c-44c8375a9f4d", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.getActiveContestSubmissions", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "submissionCount": 3 - }, - "createdAt": "2025-10-16T05:23:16.791Z" - }, - { - "id": "9b861157-3de2-4a0e-8c59-5ed35fd67176", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getPhaseDetails", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseId": "2b29b658-bfa1-4913-98d4-2823e20e84c1" - }, - "createdAt": "2025-10-16T05:23:16.806Z" - }, - { - "id": "43cde0ab-1b5d-491a-8eff-3b55646e3bd5", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 4 - }, - "createdAt": "2025-10-16T05:23:16.806Z" - }, - { - "id": "ca7a30a0-9a45-4c73-9523-478d1390710a", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallengePhases", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseCount": 4 - }, - "createdAt": "2025-10-16T05:23:16.806Z" - }, - { - "id": "9dc1b66c-d4ea-478a-a9d5-45f65326c6c7", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.hasSubmitterResource", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "submitterCount": 3 - }, - "createdAt": "2025-10-16T05:23:16.809Z" - }, - { - "id": "ab9a7934-0c68-4832-8d5d-2a66939d98d9", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.advancePhase", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "phaseId": "2b29b658-bfa1-4913-98d4-2823e20e84c1", - "operation": "close", - "nextPhaseCount": 0, - "scheduleAdjusted": false, - "hasWinningSubmission": false - }, - "createdAt": "2025-10-16T05:23:16.847Z" - }, - { - "id": "dc3f0e09-d103-41af-b53f-cfaf3c750ec6", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getPhaseChangeNotificationResources", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "recipientCount": 6 - }, - "createdAt": "2025-10-16T05:23:16.849Z" - }, - { - "id": "6914bd7d-37bc-45e2-952d-f6b35a7596ab", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 4 - }, - "createdAt": "2025-10-16T05:23:16.868Z" - }, - { - "id": "aa7b8471-c0cf-4a47-bc7b-876345e0878d", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "notifications.phaseChange", - "status": "SUCCESS", - "source": "PhaseChangeNotificationService", - "details": { - "payload": { - "phaseOpen": null, - "phaseClose": "Registration", - "challengeURL": "https://review-v6.topcoder-dev.com/review/active-challenges/d096b255-c36a-4571-bddd-e8ae2bf761fd/challenge-details", - "challengeName": "SS Dev F2F1 Oct 16", - "phaseOpenDate": null, - "phaseCloseDate": "16-10-2025 01:23 EDT" - }, - "phaseId": "2b29b658-bfa1-4913-98d4-2823e20e84c1", - "operation": "close", - "recipients": 5 - }, - "createdAt": "2025-10-16T05:23:16.894Z" - }, - { - "id": "4e3c34fd-88c2-481f-8262-ba635a40f710", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.hasSubmitterResource", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "roleCount": 1, - "submitterCount": 3 - }, - "createdAt": "2025-10-16T05:23:16.900Z" - }, - { - "id": "78b05ad3-36a4-4957-b897-dc82310a6399", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.getChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "found": true, - "phaseCount": 4 - }, - "createdAt": "2025-10-16T05:23:16.918Z" - }, - { - "id": "63a86cd1-1a2b-4669-8900-c5791c287eb5", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "review.generateReviewSummaries", - "status": "SUCCESS", - "source": "ReviewService", - "details": { - "passingCount": 1, - "submissionCount": 3 - }, - "createdAt": "2025-10-16T05:23:16.947Z" - }, - { - "id": "ac4e703f-0c5f-48db-ac25-d700478f3523", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "resources.getMemberHandleMap", - "status": "SUCCESS", - "source": "ResourcesService", - "details": { - "inputCount": 1, - "matchedCount": 1 - }, - "createdAt": "2025-10-16T05:23:16.948Z" - }, - { - "id": "6dd85972-22f1-4163-a887-b3b716a07bdf", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "challenge.completeChallenge", - "status": "SUCCESS", - "source": "ChallengeApiService", - "details": { - "endDate": "2025-10-16T05:23:16.946Z", - "winnersCount": 1 - }, - "createdAt": "2025-10-16T05:23:16.967Z" - }, - { - "id": "95905eea-657d-46b1-ac30-fb04d31f13e0", - "challengeId": "d096b255-c36a-4571-bddd-e8ae2bf761fd", - "action": "finance.generatePayments", - "status": "SUCCESS", - "source": "FinanceApiService", - "details": { - "url": "https://api.topcoder-dev.com/v6/finance/challenges/d096b255-c36a-4571-bddd-e8ae2bf761fd", - "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ik5VSkZORGd4UlRVME5EWTBOVVkzTlRkR05qTXlRamxETmpOQk5UYzVRVUV3UlRFeU56TTJRUSJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoib1VNc2VNbmM1UlhtZVVYSUU1VEZhVmpKQThhWjE3bGNAY2xpZW50cyIsImF1ZCI6Imh0dHBzOi8vbTJtLnRvcGNvZGVyLWRldi5jb20vIiwiaWF0IjoxNzYwNTkxODY2LCJleHAiOjE3NjA2NzgyNjYsInNjb3BlIjoiYWxsOmNoYWxsZW5nZXMgcmVhZDpjaGFsbGVuZ2VzIHdyaXRlOmNoYWxsZW5nZXMgcmVhZDpidXNfdG9waWNzIHdyaXRlOmJ1c19hcGkgcmVhZDpyZXNvdXJjZXMgd3JpdGU6cmVzb3VyY2VzIGRlbGV0ZTpyZXNvdXJjZXMgdXBkYXRlOnJlc291cmNlcyBhbGw6cmVzb3VyY2VzIGNyZWF0ZTpjaGFsbGVuZ2VzIGNyZWF0ZTpyZXNvdXJjZXMgdXBkYXRlOmNoYWxsZW5nZXMgY3JlYXRlOnBheW1lbnRzIiwiZ3R5IjoiY2xpZW50LWNyZWRlbnRpYWxzIiwiYXpwIjoib1VNc2VNbmM1UlhtZVVYSUU1VEZhVmpKQThhWjE3bGMifQ.Afy9E_0tFP-1ycIF6WpXimYD1l0m-ubHyZUni2MtqQTxr3-FIBi5a6V50PCDTpV00dZ8rtmK3XIph2hxHdsJqTA3iXvZz9pTgrHunZAWwwtKkiofTiKtGUn8579VGalxDap2DpQbAC05RfRFLInqw3M6KbZt5TyfeiRW5BcEgKBHXarkAAHAYguSMr0OuhNEdy51rQw4eeIxXFOrznTYhuvHmdEyXtQ5UaukFFWXePenJKznVGJ_oBacAQ3u0yX9JUoSqtYuU1oqXj1WfhjzcBYANrBXgjHqAYd4PQJdKinl21dI_Yj8kn1ZFpzPrXHaIFIMMkUmAzT0LxldcJ_O6A", - "status": 201 - }, - "createdAt": "2025-10-16T05:23:17.787Z" - } -] diff --git a/src/autopilot/autopilot.module.ts b/src/autopilot/autopilot.module.ts index 95656e2..0e75bfa 100644 --- a/src/autopilot/autopilot.module.ts +++ b/src/autopilot/autopilot.module.ts @@ -17,6 +17,7 @@ import { MembersModule } from '../members/members.module'; import { Auth0Module } from '../auth/auth0.module'; import { PhaseChangeNotificationService } from './services/phase-change-notification.service'; import { FinanceModule } from '../finance/finance.module'; +import { ReviewSummationApiService } from './services/review-summation-api.service'; @Module({ imports: [ @@ -42,6 +43,7 @@ import { FinanceModule } from '../finance/finance.module'; ReviewAssignmentService, ChallengeCompletionService, PhaseChangeNotificationService, + ReviewSummationApiService, ], exports: [AutopilotService, SchedulerService], }) diff --git a/src/autopilot/services/challenge-completion.service.spec.ts b/src/autopilot/services/challenge-completion.service.spec.ts index 44df644..758fcef 100644 --- a/src/autopilot/services/challenge-completion.service.spec.ts +++ b/src/autopilot/services/challenge-completion.service.spec.ts @@ -12,6 +12,7 @@ import type { IChallengePrizeSet, } from '../../challenge/interfaces/challenge.interface'; import type { ConfigService } from '@nestjs/config'; +import type { ReviewSummationApiService } from './review-summation-api.service'; describe('ChallengeCompletionService', () => { let challengeApiService: { @@ -38,6 +39,7 @@ describe('ChallengeCompletionService', () => { let configService: { get: jest.MockedFunction; }; + let reviewSummationApiService: jest.Mocked; let service: ChallengeCompletionService; const baseTimestamp = '2024-01-01T00:00:00.000Z'; @@ -296,11 +298,16 @@ describe('ChallengeCompletionService', () => { get: jest.fn().mockReturnValue(null), }; + reviewSummationApiService = { + finalizeSummations: jest.fn().mockResolvedValue(true), + } as unknown as jest.Mocked; + service = new ChallengeCompletionService( challengeApiService as unknown as ChallengeApiService, reviewService as unknown as ReviewService, resourcesService as unknown as ResourcesService, financeApiService as unknown as FinanceApiService, + reviewSummationApiService as unknown as ReviewSummationApiService, configService as unknown as ConfigService, ); }); @@ -323,6 +330,12 @@ describe('ChallengeCompletionService', () => { const result = await service.finalizeChallenge(challenge.id); expect(result).toBe(true); + expect(reviewSummationApiService.finalizeSummations).toHaveBeenCalledTimes( + 1, + ); + expect(reviewSummationApiService.finalizeSummations).toHaveBeenCalledWith( + challenge.id, + ); expect(challengeApiService.completeChallenge).toHaveBeenCalledTimes(1); expect(financeApiService.generateChallengePayments).toHaveBeenCalledWith( challenge.id, @@ -390,6 +403,12 @@ describe('ChallengeCompletionService', () => { const result = await service.finalizeChallenge(challenge.id); expect(result).toBe(true); + expect(reviewSummationApiService.finalizeSummations).toHaveBeenCalledTimes( + 1, + ); + expect(reviewSummationApiService.finalizeSummations).toHaveBeenCalledWith( + challenge.id, + ); expect(challengeApiService.completeChallenge).toHaveBeenCalledTimes(1); const [, winners] = challengeApiService.completeChallenge.mock.calls[0]; @@ -412,6 +431,12 @@ describe('ChallengeCompletionService', () => { const result = await service.finalizeChallenge(challenge.id); expect(result).toBe(true); + expect(reviewSummationApiService.finalizeSummations).toHaveBeenCalledTimes( + 1, + ); + expect(reviewSummationApiService.finalizeSummations).toHaveBeenCalledWith( + challenge.id, + ); expect(challengeApiService.completeChallenge).toHaveBeenCalledTimes(1); expect(financeApiService.generateChallengePayments).toHaveBeenCalledWith( challenge.id, @@ -522,6 +547,12 @@ describe('ChallengeCompletionService', () => { const result = await service.finalizeChallenge(challenge.id); expect(result).toBe(true); + expect(reviewSummationApiService.finalizeSummations).toHaveBeenCalledTimes( + 1, + ); + expect(reviewSummationApiService.finalizeSummations).toHaveBeenCalledWith( + challenge.id, + ); expect(challengeApiService.cancelChallenge).toHaveBeenCalledWith( challenge.id, ChallengeStatusEnum.CANCELLED_ZERO_SUBMISSIONS, @@ -612,6 +643,12 @@ describe('ChallengeCompletionService', () => { const result = await service.finalizeChallenge(challenge.id); expect(result).toBe(true); + expect(reviewSummationApiService.finalizeSummations).toHaveBeenCalledTimes( + 1, + ); + expect(reviewSummationApiService.finalizeSummations).toHaveBeenCalledWith( + challenge.id, + ); expect(challengeApiService.cancelChallenge).toHaveBeenCalledWith( challenge.id, ChallengeStatusEnum.CANCELLED_FAILED_REVIEW, diff --git a/src/autopilot/services/challenge-completion.service.ts b/src/autopilot/services/challenge-completion.service.ts index 2a89c89..6f3db8d 100644 --- a/src/autopilot/services/challenge-completion.service.ts +++ b/src/autopilot/services/challenge-completion.service.ts @@ -10,6 +10,7 @@ import { import { ChallengeStatusEnum, PrizeSetTypeEnum } from '@prisma/client'; import { IPhase } from '../../challenge/interfaces/challenge.interface'; import { FinanceApiService } from '../../finance/finance-api.service'; +import { ReviewSummationApiService } from './review-summation-api.service'; import { POST_MORTEM_REVIEWER_ROLE_NAME } from '../constants/review.constants'; @Injectable() @@ -21,6 +22,7 @@ export class ChallengeCompletionService { private readonly reviewService: ReviewService, private readonly resourcesService: ResourcesService, private readonly financeApiService: FinanceApiService, + private readonly reviewSummationApiService: ReviewSummationApiService, private readonly configService: ConfigService, ) {} @@ -277,6 +279,8 @@ export class ChallengeCompletionService { return true; } + await this.reviewSummationApiService.finalizeSummations(challengeId); + const summaries = await this.reviewService.generateReviewSummaries(challengeId); diff --git a/src/autopilot/services/phase-review.service.spec.ts b/src/autopilot/services/phase-review.service.spec.ts index 86d053f..4050624 100644 --- a/src/autopilot/services/phase-review.service.spec.ts +++ b/src/autopilot/services/phase-review.service.spec.ts @@ -15,6 +15,7 @@ import { POST_MORTEM_REVIEWER_ROLE_NAME, ITERATIVE_REVIEW_PHASE_NAME, } from '../constants/review.constants'; +import { ReviewSummationApiService } from './review-summation-api.service'; const basePhase = { id: 'phase-1', @@ -99,6 +100,7 @@ describe('PhaseReviewService', () => { let resourcesService: jest.Mocked; let configService: jest.Mocked; let challengeCompletionService: jest.Mocked; + let reviewSummationApiService: jest.Mocked; let dbLogger: jest.Mocked; beforeAll(() => { @@ -143,6 +145,9 @@ describe('PhaseReviewService', () => { challengeCompletionService = { finalizeChallenge: jest.fn(), } as unknown as jest.Mocked; + reviewSummationApiService = { + finalizeSummations: jest.fn().mockResolvedValue(true), + } as unknown as jest.Mocked; dbLogger = { logAction: jest.fn(), } as unknown as jest.Mocked; @@ -178,6 +183,7 @@ describe('PhaseReviewService', () => { resourcesService, configService, challengeCompletionService, + reviewSummationApiService, dbLogger, ); }); @@ -507,6 +513,12 @@ describe('PhaseReviewService', () => { await service.handlePhaseOpened(challenge.id, challenge.phases[0].id); + expect(reviewSummationApiService.finalizeSummations).toHaveBeenCalledTimes( + 1, + ); + expect(reviewSummationApiService.finalizeSummations).toHaveBeenCalledWith( + challenge.id, + ); expect(reviewService.generateReviewSummaries).toHaveBeenCalledWith( challenge.id, ); @@ -544,6 +556,12 @@ describe('PhaseReviewService', () => { await service.handlePhaseOpened(challenge.id, challenge.phases[0].id); + expect(reviewSummationApiService.finalizeSummations).toHaveBeenCalledTimes( + 1, + ); + expect(reviewSummationApiService.finalizeSummations).toHaveBeenCalledWith( + challenge.id, + ); expect(challengeCompletionService.finalizeChallenge).toHaveBeenCalledWith( challenge.id, ); diff --git a/src/autopilot/services/phase-review.service.ts b/src/autopilot/services/phase-review.service.ts index 5c0f833..7ee48bc 100644 --- a/src/autopilot/services/phase-review.service.ts +++ b/src/autopilot/services/phase-review.service.ts @@ -25,6 +25,7 @@ import { import { isTopgearTaskChallenge } from '../constants/challenge.constants'; import { ChallengeCompletionService } from './challenge-completion.service'; import { AutopilotDbLoggerService } from './autopilot-db-logger.service'; +import { ReviewSummationApiService } from './review-summation-api.service'; @Injectable() export class PhaseReviewService { @@ -36,6 +37,7 @@ export class PhaseReviewService { private readonly resourcesService: ResourcesService, private readonly configService: ConfigService, private readonly challengeCompletionService: ChallengeCompletionService, + private readonly reviewSummationApiService: ReviewSummationApiService, private readonly dbLogger: AutopilotDbLoggerService, ) {} @@ -303,6 +305,7 @@ export class PhaseReviewService { let submissionIds: string[] = []; if (isApprovalPhase) { try { + await this.reviewSummationApiService.finalizeSummations(challengeId); const summaries = await this.reviewService.generateReviewSummaries(challengeId); const passingSummaries = summaries.filter( diff --git a/src/autopilot/services/review-summation-api.service.ts b/src/autopilot/services/review-summation-api.service.ts new file mode 100644 index 0000000..fd78234 --- /dev/null +++ b/src/autopilot/services/review-summation-api.service.ts @@ -0,0 +1,116 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; +import { Auth0Service } from '../../auth/auth0.service'; +import { AutopilotDbLoggerService } from './autopilot-db-logger.service'; + +@Injectable() +export class ReviewSummationApiService { + private readonly logger = new Logger(ReviewSummationApiService.name); + private readonly baseUrl: string; + private readonly timeoutMs: number; + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService, + private readonly auth0Service: Auth0Service, + private readonly dbLogger: AutopilotDbLoggerService, + ) { + this.baseUrl = + (this.configService.get('review.summationApiUrl') || '').trim(); + this.timeoutMs = + this.configService.get('review.summationApiTimeoutMs') ?? 15000; + + if (!this.baseUrl) { + this.logger.warn( + 'REVIEW_SUMMATION_API_URL is not configured. Automatic review summation finalization is disabled.', + ); + } + } + + private buildUrl(path: string): string | null { + if (!this.baseUrl) { + return null; + } + + const normalizedBase = this.baseUrl.endsWith('/') + ? this.baseUrl.slice(0, -1) + : this.baseUrl; + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${normalizedBase}${normalizedPath}`; + } + + async finalizeSummations(challengeId: string): Promise { + const url = this.buildUrl( + `/reviewSummations/challenges/${challengeId}/final`, + ); + + if (!url) { + await this.dbLogger.logAction('reviewSummation.finalize', { + challengeId, + status: 'INFO', + source: ReviewSummationApiService.name, + details: { + note: 'REVIEW_SUMMATION_API_URL not configured; skipping finalize call.', + }, + }); + return false; + } + + let token: string | undefined; + + try { + token = await this.auth0Service.getAccessToken(); + const response = await firstValueFrom( + this.httpService.post(url, undefined, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + timeout: this.timeoutMs, + }), + ); + + const status = response.status; + await this.dbLogger.logAction('reviewSummation.finalize', { + challengeId, + status: 'SUCCESS', + source: ReviewSummationApiService.name, + details: { + url, + status, + }, + }); + + this.logger.log( + `Finalized review summations for challenge ${challengeId} (status ${status}).`, + ); + return true; + } catch (error) { + const err = error as any; + const message = err?.message || 'Unknown error'; + const status = err?.response?.status; + const data = err?.response?.data; + + this.logger.error( + `Failed to finalize review summations for challenge ${challengeId}: ${message}`, + err?.stack, + ); + + await this.dbLogger.logAction('reviewSummation.finalize', { + challengeId, + status: 'ERROR', + source: ReviewSummationApiService.name, + details: { + url, + error: message, + status, + response: data, + }, + }); + + return false; + } + } +} diff --git a/src/config/sections/review.config.ts b/src/config/sections/review.config.ts index 1e6bc98..cbc19ac 100644 --- a/src/config/sections/review.config.ts +++ b/src/config/sections/review.config.ts @@ -2,6 +2,11 @@ import { registerAs } from '@nestjs/config'; const DEFAULT_POLL_INTERVAL_MS = 5 * 60 * 1000; +function parseNumber(value: string | undefined, fallback: number): number { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + export default registerAs('review', () => { const pollIntervalEnv = process.env.REVIEWER_POLL_INTERVAL_MS; const pollInterval = Number(pollIntervalEnv); @@ -12,5 +17,10 @@ export default registerAs('review', () => { Number.isFinite(pollInterval) && pollInterval > 0 ? pollInterval : DEFAULT_POLL_INTERVAL_MS, + summationApiUrl: (process.env.REVIEW_SUMMATION_API_URL || '').trim(), + summationApiTimeoutMs: parseNumber( + process.env.REVIEW_SUMMATION_API_TIMEOUT_MS, + 15000, + ), }; }); diff --git a/src/config/validation.ts b/src/config/validation.ts index d812f1e..5da70a9 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -63,6 +63,11 @@ export const validationSchema = Joi.object({ .integer() .positive() .default(5 * 60 * 1000), + REVIEW_SUMMATION_API_URL: Joi.string().uri().optional(), + REVIEW_SUMMATION_API_TIMEOUT_MS: Joi.number() + .integer() + .positive() + .default(15000), 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), From 400bbcb88906873b4d6ad906b821d9f6d999b124 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 28 Oct 2025 14:08:06 +1100 Subject: [PATCH 04/14] Handling of end of challenge score summations --- src/review/review.service.spec.ts | 79 ++++++++++++++++++++++- src/review/review.service.ts | 100 +++++++++++++++++++++++++++--- 2 files changed, 169 insertions(+), 10 deletions(-) diff --git a/src/review/review.service.spec.ts b/src/review/review.service.spec.ts index c9a9e6a..8992f26 100644 --- a/src/review/review.service.spec.ts +++ b/src/review/review.service.spec.ts @@ -3,14 +3,26 @@ import { ReviewService } from './review.service'; describe('ReviewService', () => { const challengeId = 'challenge-1'; - let prismaMock: { $queryRaw: jest.Mock; $executeRaw: jest.Mock }; + let prismaMock: { + $queryRaw: jest.Mock; + $executeRaw: jest.Mock; + $transaction: jest.Mock; + }; let dbLoggerMock: { logAction: jest.Mock }; let service: ReviewService; beforeEach(() => { + const executeRawMock = jest.fn().mockResolvedValue(undefined); prismaMock = { $queryRaw: jest.fn(), - $executeRaw: jest.fn(), + $executeRaw: executeRawMock, + $transaction: jest + .fn() + .mockImplementation( + async (callback: (tx: { $executeRaw: typeof executeRawMock }) => Promise) => { + await callback({ $executeRaw: executeRawMock }); + }, + ), }; dbLoggerMock = { logAction: jest.fn(), @@ -351,4 +363,67 @@ describe('ReviewService', () => { ); }); }); + + describe('generateReviewSummaries', () => { + const buildReviewSummationRow = (overrides: Partial> = {}) => ({ + submissionId: 'submission-1', + legacySubmissionId: 'legacy-1', + memberId: '123456', + submittedDate: '2024-10-21T10:00:00.000Z', + aggregateScore: '70', + scorecardId: 'scorecard-1', + scorecardLegacyId: 'legacy-scorecard', + isPassing: false, + minimumPassingScore: '80', + ...overrides, + }); + + const buildAggregationRow = (overrides: Partial> = {}) => ({ + submissionId: 'submission-1', + legacySubmissionId: 'legacy-1', + memberId: '123456', + submittedDate: '2024-10-21T10:00:00.000Z', + aggregateScore: '95', + scorecardId: 'scorecard-1', + scorecardLegacyId: 'legacy-scorecard', + minimumPassingScore: '80', + ...overrides, + }); + + it('rebuilds summaries when stored summations have no passing submissions but recomputed data does', async () => { + prismaMock.$queryRaw + .mockResolvedValueOnce([buildReviewSummationRow()]) + .mockResolvedValueOnce([buildAggregationRow()]); + + const summaries = await service.generateReviewSummaries(challengeId); + + expect(prismaMock.$transaction).toHaveBeenCalledTimes(1); + expect(prismaMock.$executeRaw).toHaveBeenCalledTimes(2); + + expect(summaries).toEqual([ + { + submissionId: 'submission-1', + legacySubmissionId: 'legacy-1', + memberId: '123456', + submittedDate: new Date('2024-10-21T10:00:00.000Z'), + aggregateScore: 95, + scorecardId: 'scorecard-1', + scorecardLegacyId: 'legacy-scorecard', + passingScore: 80, + isPassing: true, + }, + ]); + + expect(dbLoggerMock.logAction).toHaveBeenCalledWith( + 'review.generateReviewSummaries', + expect.objectContaining({ + details: expect.objectContaining({ + submissionCount: 1, + passingCount: 1, + rebuiltFromReviews: true, + }), + }), + ); + }); + }); }); diff --git a/src/review/review.service.ts b/src/review/review.service.ts index 8fd9f1b..a6e7f1f 100644 --- a/src/review/review.service.ts +++ b/src/review/review.service.ts @@ -1389,9 +1389,31 @@ export class ReviewService { let summaries = await this.getSummariesFromReviewSummations(challengeId); + let usedRebuild = false; if (!summaries.length) { summaries = await this.rebuildSummariesFromReviews(challengeId); + usedRebuild = summaries.length > 0; + } else if (summaries.every((summary) => !summary.isPassing)) { + const recalculatedSummaries = + await this.fetchSummariesFromReviews(challengeId); + const hasRecalculatedSummaries = recalculatedSummaries.length > 0; + const recalculatedHasPassing = recalculatedSummaries.some( + (summary) => summary.isPassing, + ); + + const summariesDiffer = + hasRecalculatedSummaries && + !this.areSummariesEquivalent(summaries, recalculatedSummaries); + + if (hasRecalculatedSummaries && (recalculatedHasPassing || summariesDiffer)) { + await this.replaceReviewSummations( + challengeId, + recalculatedSummaries, + ); + summaries = recalculatedSummaries; + usedRebuild = true; + } } void this.dbLogger.logAction('review.generateReviewSummaries', { @@ -1401,6 +1423,7 @@ export class ReviewService { details: { submissionCount: summaries.length, passingCount: summaries.filter((summary) => summary.isPassing).length, + rebuiltFromReviews: usedRebuild, }, }); @@ -1463,7 +1486,7 @@ export class ReviewService { }); } - private async rebuildSummariesFromReviews( + private async fetchSummariesFromReviews( challengeId: string, ): Promise { const aggregationQuery = Prisma.sql` @@ -1518,8 +1541,7 @@ export class ReviewService { await this.prisma.$queryRaw( aggregationQuery, ); - - const summaries: SubmissionSummary[] = aggregationRows.map((row) => { + return aggregationRows.map((row) => { const aggregateScore = Number(row.aggregateScore ?? 0); const passingScore = this.resolvePassingScore(row.minimumPassingScore); const isPassing = aggregateScore >= passingScore; @@ -1536,13 +1558,13 @@ export class ReviewService { isPassing, }; }); + } - if (!summaries.length) { - return summaries; - } - + private async replaceReviewSummations( + challengeId: string, + summaries: SubmissionSummary[], + ): Promise { const now = new Date(); - await this.prisma.$transaction(async (tx) => { await tx.$executeRaw( Prisma.sql` @@ -1594,6 +1616,17 @@ export class ReviewService { ); } }); + } + + private async rebuildSummariesFromReviews( + challengeId: string, + ): Promise { + const summaries = await this.fetchSummariesFromReviews(challengeId); + if (!summaries.length) { + return summaries; + } + + await this.replaceReviewSummations(challengeId, summaries); return summaries; } @@ -1833,4 +1866,55 @@ export class ReviewService { throw err; } } + + private areSummariesEquivalent( + current: SubmissionSummary[], + next: SubmissionSummary[], + ): boolean { + if (current.length !== next.length) { + return false; + } + + const normalize = (summary: SubmissionSummary) => ({ + submissionId: summary.submissionId, + legacySubmissionId: summary.legacySubmissionId ?? null, + memberId: summary.memberId ?? null, + submittedDate: summary.submittedDate + ? summary.submittedDate.getTime() + : null, + aggregateScore: summary.aggregateScore, + scorecardId: summary.scorecardId ?? null, + scorecardLegacyId: summary.scorecardLegacyId ?? null, + passingScore: summary.passingScore, + isPassing: summary.isPassing, + }); + + const sortBySubmissionId = ( + a: ReturnType, + b: ReturnType, + ) => { + if (a.submissionId === b.submissionId) { + return 0; + } + return a.submissionId > b.submissionId ? 1 : -1; + }; + + const normalizedCurrent = current.map(normalize).sort(sortBySubmissionId); + const normalizedNext = next.map(normalize).sort(sortBySubmissionId); + + return normalizedCurrent.every((entry, index) => { + const other = normalizedNext[index]; + return ( + entry.submissionId === other.submissionId && + entry.legacySubmissionId === other.legacySubmissionId && + entry.memberId === other.memberId && + entry.submittedDate === other.submittedDate && + entry.aggregateScore === other.aggregateScore && + entry.scorecardId === other.scorecardId && + entry.scorecardLegacyId === other.scorecardLegacyId && + entry.passingScore === other.passingScore && + entry.isPassing === other.isPassing + ); + }); + } } From 19ff1664e32a7a9e930f957fc1739e531a950017 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 28 Oct 2025 14:51:45 +1100 Subject: [PATCH 05/14] Build fix --- src/config/sections/review.config.ts | 10 +--------- src/config/validation.ts | 2 +- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/config/sections/review.config.ts b/src/config/sections/review.config.ts index cbc19ac..458f96d 100644 --- a/src/config/sections/review.config.ts +++ b/src/config/sections/review.config.ts @@ -2,11 +2,6 @@ import { registerAs } from '@nestjs/config'; const DEFAULT_POLL_INTERVAL_MS = 5 * 60 * 1000; -function parseNumber(value: string | undefined, fallback: number): number { - const parsed = Number(value); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} - export default registerAs('review', () => { const pollIntervalEnv = process.env.REVIEWER_POLL_INTERVAL_MS; const pollInterval = Number(pollIntervalEnv); @@ -18,9 +13,6 @@ export default registerAs('review', () => { ? pollInterval : DEFAULT_POLL_INTERVAL_MS, summationApiUrl: (process.env.REVIEW_SUMMATION_API_URL || '').trim(), - summationApiTimeoutMs: parseNumber( - process.env.REVIEW_SUMMATION_API_TIMEOUT_MS, - 15000, - ), + summationApiTimeoutMs: 10000, }; }); diff --git a/src/config/validation.ts b/src/config/validation.ts index 5da70a9..f0379c1 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -63,7 +63,7 @@ export const validationSchema = Joi.object({ .integer() .positive() .default(5 * 60 * 1000), - REVIEW_SUMMATION_API_URL: Joi.string().uri().optional(), + REVIEW_SUMMATION_API_URL: Joi.string(), REVIEW_SUMMATION_API_TIMEOUT_MS: Joi.number() .integer() .positive() From 07bc1736ab27bbbfe6c1e88df3f7bd0cb000fe2a Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 28 Oct 2025 16:58:24 +1100 Subject: [PATCH 06/14] Better logging around end-of-challenge calculations --- .../services/review-summation-api.service.ts | 76 ++++- src/review/review.service.ts | 275 ++++++++++++++---- 2 files changed, 294 insertions(+), 57 deletions(-) diff --git a/src/autopilot/services/review-summation-api.service.ts b/src/autopilot/services/review-summation-api.service.ts index fd78234..10eb115 100644 --- a/src/autopilot/services/review-summation-api.service.ts +++ b/src/autopilot/services/review-summation-api.service.ts @@ -41,6 +41,28 @@ export class ReviewSummationApiService { return `${normalizedBase}${normalizedPath}`; } + private sanitizeHeaders( + headers: Record | undefined, + ): Record | undefined { + if (!headers) { + return undefined; + } + + return Object.entries(headers).reduce>( + (sanitized, [key, value]) => { + const lowerKey = key.toLowerCase(); + if (lowerKey === 'authorization' || lowerKey === 'set-cookie') { + sanitized[key] = '[redacted]'; + } else { + sanitized[key] = value; + } + + return sanitized; + }, + {}, + ); + } + async finalizeSummations(challengeId: string): Promise { const url = this.buildUrl( `/reviewSummations/challenges/${challengeId}/final`, @@ -59,20 +81,49 @@ export class ReviewSummationApiService { } let token: string | undefined; + const requestLog: { + method: string; + url: string; + body: null; + headers: Record; + timeoutMs: number; + } = { + method: 'POST', + url, + body: null, + headers: { + Authorization: '[not available]', + 'Content-Type': 'application/json', + }, + timeoutMs: this.timeoutMs, + }; try { token = await this.auth0Service.getAccessToken(); + if (token) { + requestLog.headers.Authorization = 'Bearer [redacted]'; + } + + const axiosHeaders: Record = { + 'Content-Type': 'application/json', + }; + if (token) { + axiosHeaders.Authorization = `Bearer ${token}`; + } + + const axiosConfig = { + headers: axiosHeaders, + timeout: this.timeoutMs, + }; + const response = await firstValueFrom( - this.httpService.post(url, undefined, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - timeout: this.timeoutMs, - }), + this.httpService.post(url, undefined, axiosConfig), ); const status = response.status; + const sanitizedResponseHeaders = this.sanitizeHeaders( + response.headers as Record | undefined, + ); await this.dbLogger.logAction('reviewSummation.finalize', { challengeId, status: 'SUCCESS', @@ -80,6 +131,12 @@ export class ReviewSummationApiService { details: { url, status, + request: requestLog, + response: { + status, + data: response.data ?? null, + headers: sanitizedResponseHeaders, + }, }, }); @@ -92,6 +149,9 @@ export class ReviewSummationApiService { const message = err?.message || 'Unknown error'; const status = err?.response?.status; const data = err?.response?.data; + const sanitizedResponseHeaders = this.sanitizeHeaders( + err?.response?.headers, + ); this.logger.error( `Failed to finalize review summations for challenge ${challengeId}: ${message}`, @@ -104,9 +164,11 @@ export class ReviewSummationApiService { source: ReviewSummationApiService.name, details: { url, + request: requestLog, error: message, status, response: data, + responseHeaders: sanitizedResponseHeaders, }, }); diff --git a/src/review/review.service.ts b/src/review/review.service.ts index a6e7f1f..05009f3 100644 --- a/src/review/review.service.ts +++ b/src/review/review.service.ts @@ -41,15 +41,17 @@ interface AppealCountRecord { count: number | string; } -interface SubmissionAggregationRecord { +interface ReviewAggregationRow { submissionId: string; legacySubmissionId: string | null; memberId: string | null; submittedDate: Date | null; - aggregateScore: number | string | null; + finalScore: number | string | null; scorecardId: string | null; scorecardLegacyId: string | null; minimumPassingScore: number | string | null; + reviewTypeName: string | null; + scorecardType: string | null; } interface ReviewSummationSummaryRecord { @@ -82,10 +84,29 @@ export class ReviewService { 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 REVIEW_TYPE_TABLE = Prisma.sql`"reviewType"`; 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"`; + private static readonly FINAL_REVIEW_TYPE_NAMES = new Set([ + 'REVIEW', + 'REGULAR REVIEW', + 'ITERATIVE REVIEW', + 'PEER REVIEW', + 'POST-MORTEM REVIEW', + 'COMMITTEE REVIEW', + 'FINAL REVIEW', + ]); + private static readonly EXCLUDED_REVIEW_TYPE_NAMES = new Set([ + 'SCREENING', + 'CHECKPOINT SCREENING', + ]); + private static readonly REVIEW_SCORECARD_TYPES = new Set([ + 'REVIEW', + 'REGULAR_REVIEW', + 'ITERATIVE_REVIEW', + ]); constructor( private readonly prisma: ReviewPrismaService, @@ -105,6 +126,56 @@ export class ReviewService { return Number.isFinite(numericValue) ? numericValue : 50; } + private static normalizeTypeName( + value: string | null | undefined, + ): string | null { + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + return trimmed.toUpperCase(); + } + + private static isFinalReviewType(value: string | null | undefined): boolean { + const normalized = ReviewService.normalizeTypeName(value); + if (!normalized) { + return false; + } + + if (ReviewService.EXCLUDED_REVIEW_TYPE_NAMES.has(normalized)) { + return false; + } + + return ReviewService.FINAL_REVIEW_TYPE_NAMES.has(normalized); + } + + private static isScreeningReviewType( + value: string | null | undefined, + ): boolean { + const normalized = ReviewService.normalizeTypeName(value); + if (!normalized) { + return false; + } + + return ReviewService.EXCLUDED_REVIEW_TYPE_NAMES.has(normalized); + } + + private static isReviewScorecardType( + value: string | null | undefined, + ): boolean { + const normalized = ReviewService.normalizeTypeName(value); + if (!normalized) { + return false; + } + + return ReviewService.REVIEW_SCORECARD_TYPES.has(normalized); + } + private buildPendingReviewLockId( phaseId: string, resourceId: string, @@ -1489,39 +1560,18 @@ export class ReviewService { private async fetchSummariesFromReviews( challengeId: string, ): Promise { - const aggregationQuery = Prisma.sql` + const baseQuery = Prisma.sql` SELECT s."id" AS "submissionId", s."legacySubmissionId" AS "legacySubmissionId", s."memberId" AS "memberId", s."submittedDate" AS "submittedDate", - COALESCE( - AVG( - CASE - WHEN UPPER((sc."type")::text) IN ('REVIEW', 'REGULAR_REVIEW', 'ITERATIVE_REVIEW') - THEN r."finalScore" - END - ), - 0 - ) AS "aggregateScore", - MAX( - CASE - WHEN UPPER((sc."type")::text) IN ('REVIEW', 'REGULAR_REVIEW', 'ITERATIVE_REVIEW') - THEN r."scorecardId" - END - ) AS "scorecardId", - MAX( - CASE - WHEN UPPER((sc."type")::text) IN ('REVIEW', 'REGULAR_REVIEW', 'ITERATIVE_REVIEW') - THEN sc."legacyId" - END - ) AS "scorecardLegacyId", - MAX( - CASE - WHEN UPPER((sc."type")::text) IN ('REVIEW', 'REGULAR_REVIEW', 'ITERATIVE_REVIEW') - THEN sc."minimumPassingScore" - END - ) AS "minimumPassingScore" + r."finalScore" AS "finalScore", + r."scorecardId" AS "scorecardId", + sc."legacyId" AS "scorecardLegacyId", + sc."minimumPassingScore" AS "minimumPassingScore", + sc."type" AS "scorecardType", + rt."name" AS "reviewTypeName" FROM ${ReviewService.SUBMISSION_TABLE} s LEFT JOIN ${ReviewService.REVIEW_TABLE} r ON r."submissionId" = s."id" @@ -1529,35 +1579,160 @@ export class ReviewService { AND r."committed" = true LEFT JOIN ${ReviewService.SCORECARD_TABLE} sc ON sc."id" = r."scorecardId" + LEFT JOIN ${ReviewService.REVIEW_TYPE_TABLE} rt + ON rt."id" = r."typeId" WHERE s."challengeId" = ${challengeId} AND ( s."type" IS NULL OR UPPER((s."type")::text) = 'CONTEST_SUBMISSION' ) - GROUP BY s."id", s."legacySubmissionId", s."memberId", s."submittedDate" `; - const aggregationRows = - await this.prisma.$queryRaw( - aggregationQuery, - ); - return aggregationRows.map((row) => { - const aggregateScore = Number(row.aggregateScore ?? 0); - const passingScore = this.resolvePassingScore(row.minimumPassingScore); - const isPassing = aggregateScore >= passingScore; + const rows = await this.prisma.$queryRaw(baseQuery); + if (!rows.length) { + return []; + } - return { - submissionId: row.submissionId, - legacySubmissionId: row.legacySubmissionId ?? null, - memberId: row.memberId ?? null, - submittedDate: row.submittedDate ? new Date(row.submittedDate) : null, + interface SubmissionAccumulator { + submissionId: string; + legacySubmissionId: string | null; + memberId: string | null; + submittedDate: Date | null; + primarySum: number; + primaryCount: number; + primaryScorecardId: string | null; + primaryScorecardLegacyId: string | null; + primaryPassingScore: number | null; + fallbackSum: number; + fallbackCount: number; + fallbackScorecardId: string | null; + fallbackScorecardLegacyId: string | null; + fallbackPassingScore: number | null; + } + + const summariesBySubmission = new Map(); + + for (const row of rows) { + const submissionId = row.submissionId; + if (!submissionId) { + continue; + } + + let accumulator = summariesBySubmission.get(submissionId); + if (!accumulator) { + accumulator = { + submissionId, + legacySubmissionId: row.legacySubmissionId ?? null, + memberId: row.memberId ?? null, + submittedDate: row.submittedDate + ? new Date(row.submittedDate) + : null, + primarySum: 0, + primaryCount: 0, + primaryScorecardId: null, + primaryScorecardLegacyId: null, + primaryPassingScore: null, + fallbackSum: 0, + fallbackCount: 0, + fallbackScorecardId: null, + fallbackScorecardLegacyId: null, + fallbackPassingScore: null, + }; + summariesBySubmission.set(submissionId, accumulator); + } else { + if (!accumulator.legacySubmissionId && row.legacySubmissionId) { + accumulator.legacySubmissionId = row.legacySubmissionId; + } + if (!accumulator.memberId && row.memberId) { + accumulator.memberId = row.memberId; + } + if (!accumulator.submittedDate && row.submittedDate) { + accumulator.submittedDate = new Date(row.submittedDate); + } + } + + const numericScore = Number(row.finalScore ?? Number.NaN); + const hasScore = Number.isFinite(numericScore); + const reviewTypeName = row.reviewTypeName ?? null; + const scorecardType = row.scorecardType ?? null; + + if ( + hasScore && + ReviewService.isFinalReviewType(reviewTypeName) + ) { + accumulator.primarySum += numericScore; + accumulator.primaryCount += 1; + if (!accumulator.primaryScorecardId && row.scorecardId) { + accumulator.primaryScorecardId = row.scorecardId; + } + if (!accumulator.primaryScorecardLegacyId && row.scorecardLegacyId) { + accumulator.primaryScorecardLegacyId = row.scorecardLegacyId; + } + if (accumulator.primaryPassingScore === null) { + accumulator.primaryPassingScore = this.resolvePassingScore( + row.minimumPassingScore, + ); + } + } else if ( + hasScore && + ReviewService.isReviewScorecardType(scorecardType) && + !ReviewService.isScreeningReviewType(reviewTypeName) + ) { + accumulator.fallbackSum += numericScore; + accumulator.fallbackCount += 1; + if (!accumulator.fallbackScorecardId && row.scorecardId) { + accumulator.fallbackScorecardId = row.scorecardId; + } + if ( + !accumulator.fallbackScorecardLegacyId && + row.scorecardLegacyId + ) { + accumulator.fallbackScorecardLegacyId = row.scorecardLegacyId; + } + if (accumulator.fallbackPassingScore === null) { + accumulator.fallbackPassingScore = this.resolvePassingScore( + row.minimumPassingScore, + ); + } + } + } + + const summaries: SubmissionSummary[] = []; + + for (const accumulator of summariesBySubmission.values()) { + let total = accumulator.primarySum; + let count = accumulator.primaryCount; + let scorecardId = accumulator.primaryScorecardId; + let scorecardLegacyId = accumulator.primaryScorecardLegacyId; + let passingScore = accumulator.primaryPassingScore; + + if (count === 0 && accumulator.fallbackCount > 0) { + total = accumulator.fallbackSum; + count = accumulator.fallbackCount; + scorecardId = scorecardId ?? accumulator.fallbackScorecardId; + scorecardLegacyId = + scorecardLegacyId ?? accumulator.fallbackScorecardLegacyId; + passingScore = passingScore ?? accumulator.fallbackPassingScore; + } + + const resolvedPassingScore = + passingScore ?? this.resolvePassingScore(null); + const aggregateScore = count > 0 ? total / count : 0; + + summaries.push({ + submissionId: accumulator.submissionId, + legacySubmissionId: accumulator.legacySubmissionId ?? null, + memberId: accumulator.memberId ?? null, + submittedDate: accumulator.submittedDate ?? null, aggregateScore, - scorecardId: row.scorecardId ?? null, - scorecardLegacyId: row.scorecardLegacyId ?? null, - passingScore, - isPassing, - }; - }); + scorecardId: scorecardId ?? null, + scorecardLegacyId: scorecardLegacyId ?? null, + passingScore: resolvedPassingScore, + isPassing: aggregateScore >= resolvedPassingScore, + }); + } + + return summaries; } private async replaceReviewSummations( From 91e3c5aa28ffcd59aa629a80a1ed11586460bce2 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 28 Oct 2025 18:27:04 +1100 Subject: [PATCH 07/14] Failed approval phase handling --- .../services/autopilot.service.spec.ts | 167 ++++++++++++++++++ src/autopilot/services/autopilot.service.ts | 91 +++++----- src/challenge/challenge-api.service.ts | 53 +++++- 3 files changed, 264 insertions(+), 47 deletions(-) diff --git a/src/autopilot/services/autopilot.service.spec.ts b/src/autopilot/services/autopilot.service.spec.ts index 84982dd..b639a4c 100644 --- a/src/autopilot/services/autopilot.service.spec.ts +++ b/src/autopilot/services/autopilot.service.spec.ts @@ -106,6 +106,8 @@ describe('AutopilotService - handleSubmissionNotificationAggregate', () => { getChallengeById: jest.fn(), advancePhase: jest.fn(), getPhaseTypeName: jest.fn(), + createIterativeReviewPhase: jest.fn(), + createApprovalPhase: jest.fn(), } as unknown as jest.Mocked; reviewService = { @@ -403,6 +405,171 @@ describe('AutopilotService - handleSubmissionNotificationAggregate', () => { }); }); + describe('handleReviewCompleted (approval phase)', () => { + const buildApprovalPhase = (): IPhase => ({ + id: 'phase-approval', + phaseId: 'template-approval', + name: 'Approval', + description: 'Approval Phase', + 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: [], + }); + + const buildChallenge = (phase: IPhase): IChallenge => ({ + id: 'challenge-approval', + name: 'Approval Challenge', + description: null, + descriptionFormat: 'markdown', + projectId: 999, + typeId: 'type-approval', + trackId: 'track-approval', + timelineTemplateId: 'timeline-approval', + currentPhaseNames: [phase.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: [phase], + reviewers: [], + winners: [], + discussions: [], + events: [], + prizeSets: [], + terms: [], + skills: [], + attachments: [], + track: 'DEVELOP', + type: 'Standard', + legacy: {}, + task: {}, + created: new Date().toISOString(), + updated: new Date().toISOString(), + overview: {}, + numOfSubmissions: 1, + numOfCheckpointSubmissions: 0, + numOfRegistrants: 0, + }); + + const buildPayload = ( + overrides: Partial = {}, + ): ReviewCompletedPayload => ({ + challengeId: 'challenge-approval', + reviewId: 'review-approval', + submissionId: 'submission-approval', + phaseId: 'phase-approval', + scorecardId: 'scorecard-approval', + reviewerResourceId: 'resource-approval', + reviewerHandle: 'approver', + reviewerMemberId: '123', + submitterHandle: 'submitter', + submitterMemberId: '456', + completedAt: new Date().toISOString(), + initialScore: 92, + ...overrides, + }); + + beforeEach(() => { + const approvalPhase = buildApprovalPhase(); + challengeApiService.getChallengeById.mockResolvedValue( + buildChallenge(approvalPhase), + ); + + reviewService.getReviewById.mockResolvedValue({ + id: 'review-approval', + phaseId: approvalPhase.id, + resourceId: 'resource-approval', + submissionId: 'submission-approval', + scorecardId: 'scorecard-approval', + score: 0, + status: 'COMPLETED', + }); + }); + + it('closes the approval phase without creating a follow-up when the score meets the minimum', async () => { + reviewService.getReviewById.mockResolvedValueOnce({ + id: 'review-approval', + phaseId: 'phase-approval', + resourceId: 'resource-approval', + submissionId: 'submission-approval', + scorecardId: 'scorecard-approval', + score: 95, + status: 'COMPLETED', + }); + reviewService.getScorecardPassingScore.mockResolvedValueOnce(75); + + await autopilotService.handleReviewCompleted(buildPayload()); + + expect(schedulerService.advancePhase).toHaveBeenCalledWith( + expect.objectContaining({ + challengeId: 'challenge-approval', + phaseId: 'phase-approval', + state: 'END', + }), + ); + expect(challengeApiService.createApprovalPhase).not.toHaveBeenCalled(); + expect(phaseReviewService.handlePhaseOpened).not.toHaveBeenCalled(); + }); + + it('creates a follow-up approval phase when the score is below the minimum', async () => { + reviewService.getScorecardPassingScore.mockResolvedValueOnce(90); + reviewService.getReviewById.mockResolvedValueOnce({ + id: 'review-approval', + phaseId: 'phase-approval', + resourceId: 'resource-approval', + submissionId: 'submission-approval', + scorecardId: 'scorecard-approval', + score: 72, + status: 'COMPLETED', + }); + + const followUpPhase: IPhase = { + ...buildApprovalPhase(), + id: 'phase-approval-next', + isOpen: true, + }; + challengeApiService.createApprovalPhase.mockResolvedValueOnce( + followUpPhase, + ); + + await autopilotService.handleReviewCompleted(buildPayload()); + + expect(schedulerService.advancePhase).toHaveBeenCalledWith( + expect.objectContaining({ + challengeId: 'challenge-approval', + phaseId: 'phase-approval', + state: 'END', + }), + ); + expect(challengeApiService.createApprovalPhase).toHaveBeenCalledWith( + 'challenge-approval', + 'phase-approval', + 'template-approval', + 'Approval', + 'Approval Phase', + expect.any(Number), + ); + expect(phaseReviewService.handlePhaseOpened).toHaveBeenCalledWith( + 'challenge-approval', + 'phase-approval-next', + ); + }); + }); + describe('handleReviewCompleted (post-mortem)', () => { const buildPhase = (name = POST_MORTEM_PHASE_NAME): IPhase => ({ id: 'phase-1', diff --git a/src/autopilot/services/autopilot.service.ts b/src/autopilot/services/autopilot.service.ts index 1040c8e..4d39797 100644 --- a/src/autopilot/services/autopilot.service.ts +++ b/src/autopilot/services/autopilot.service.ts @@ -294,20 +294,24 @@ export class AutopilotService { return; } - // Special handling: Approval phase pass/fail adds additional Approval if failed + // Approval: compare against minimum passing score and create a follow-up Approval phase if it fails if (APPROVAL_PHASE_NAMES.has(phase.name)) { - const passingScore = await this.reviewService.getScorecardPassingScore( - review.scorecardId, - ); - const rawScore = - typeof review.score === 'number' - ? review.score - : Number(review.score ?? payload.initialScore ?? 0); - const finalScore = Number.isFinite(rawScore) - ? Number(rawScore) - : Number(payload.initialScore ?? 0); - - // Close current approval phase + const scorecardId = review.scorecardId ?? payload.scorecardId ?? null; + const passingScore = + await this.reviewService.getScorecardPassingScore(scorecardId); + + const normalizedScore = (() => { + if (typeof review.score === 'number') { + return review.score; + } + const numeric = Number(review.score ?? payload.initialScore ?? 0); + if (Number.isFinite(numeric)) { + return numeric; + } + const fallback = Number(payload.initialScore ?? 0); + return Number.isFinite(fallback) ? fallback : 0; + })(); + await this.schedulerService.advancePhase({ projectId: challenge.projectId, challengeId: challenge.id, @@ -318,37 +322,44 @@ export class AutopilotService { projectStatus: challenge.status, }); - if (finalScore >= passingScore) { + if (normalizedScore >= passingScore) { this.logger.log( - `Approval review passed for challenge ${challenge.id} (score ${finalScore} / passing ${passingScore}).`, + `Approval review passed for challenge ${challenge.id} (score ${normalizedScore} / passing ${passingScore}).`, ); - } else { - this.logger.log( - `Approval review failed for challenge ${challenge.id} (score ${finalScore} / passing ${passingScore}). Creating another Approval phase.`, + return; + } + + this.logger.log( + `Approval review failed for challenge ${challenge.id} (score ${normalizedScore} / passing ${passingScore}). Creating another Approval phase.`, + ); + + if (!phase.phaseId) { + this.logger.error( + `Cannot create follow-up Approval phase for challenge ${challenge.id}; missing phase template ID on phase ${phase.id}.`, ); - try { - const nextApproval = - await this.challengeApiService.createIterativeReviewPhase( - challenge.id, - phase.id, - phase.phaseId!, - phase.name, - phase.description ?? null, - Math.max(phase.duration || 0, 1), - ); + return; + } - // Create pending reviews for the newly opened Approval - await this.phaseReviewService.handlePhaseOpened( - challenge.id, - nextApproval.id, - ); - } catch (error) { - const err = error as Error; - this.logger.error( - `Failed to create next Approval phase for challenge ${challenge.id}: ${err.message}`, - err.stack, - ); - } + try { + const nextApproval = await this.challengeApiService.createApprovalPhase( + challenge.id, + phase.id, + phase.phaseId, + phase.name, + phase.description ?? null, + Math.max(phase.duration || 0, 1), + ); + + await this.phaseReviewService.handlePhaseOpened( + challenge.id, + nextApproval.id, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to create follow-up Approval phase for challenge ${challenge.id}: ${err.message}`, + err.stack, + ); } return; } diff --git a/src/challenge/challenge-api.service.ts b/src/challenge/challenge-api.service.ts index ad17f86..aeca998 100644 --- a/src/challenge/challenge-api.service.ts +++ b/src/challenge/challenge-api.service.ts @@ -1161,7 +1161,8 @@ export class ChallengeApiService { } } - async createIterativeReviewPhase( + private async createContinuationPhase( + logAction: string, challengeId: string, predecessorPhaseId: string, phaseTypeId: string, @@ -1181,7 +1182,7 @@ export class ChallengeApiService { if (!challenge) { throw new NotFoundException( - `Challenge with ID ${challengeId} not found when creating iterative review phase.`, + `Challenge with ID ${challengeId} not found when creating follow-up phase ${phaseName}.`, ); } @@ -1191,7 +1192,7 @@ export class ChallengeApiService { if (!predecessorPhase) { throw new NotFoundException( - `Predecessor phase ${predecessorPhaseId} not found for challenge ${challengeId}.`, + `Predecessor phase ${predecessorPhaseId} not found for challenge ${challengeId} when creating follow-up phase ${phaseName}.`, ); } @@ -1237,13 +1238,13 @@ export class ChallengeApiService { if (!phaseRecord) { throw new Error( - `Created iterative review phase ${newPhaseId} not found after insertion for challenge ${challengeId}.`, + `Created follow-up phase ${newPhaseId} not found after insertion for challenge ${challengeId}.`, ); } const mapped = this.mapPhase(phaseRecord); - void this.dbLogger.logAction('challenge.createIterativeReviewPhase', { + void this.dbLogger.logAction(logAction, { challengeId, status: 'SUCCESS', source: ChallengeApiService.name, @@ -1258,7 +1259,7 @@ export class ChallengeApiService { return mapped; } catch (error) { const err = error as Error; - void this.dbLogger.logAction('challenge.createIterativeReviewPhase', { + void this.dbLogger.logAction(logAction, { challengeId, status: 'ERROR', source: ChallengeApiService.name, @@ -1268,10 +1269,48 @@ export class ChallengeApiService { error: err.message, }, }); - throw error; + throw err; } } + async createIterativeReviewPhase( + challengeId: string, + predecessorPhaseId: string, + phaseTypeId: string, + phaseName: string, + phaseDescription: string | null, + durationSeconds: number, + ): Promise { + return this.createContinuationPhase( + 'challenge.createIterativeReviewPhase', + challengeId, + predecessorPhaseId, + phaseTypeId, + phaseName, + phaseDescription, + durationSeconds, + ); + } + + async createApprovalPhase( + challengeId: string, + predecessorPhaseId: string, + phaseTypeId: string, + phaseName: string, + phaseDescription: string | null, + durationSeconds: number, + ): Promise { + return this.createContinuationPhase( + 'challenge.createApprovalPhase', + challengeId, + predecessorPhaseId, + phaseTypeId, + phaseName, + phaseDescription, + durationSeconds, + ); + } + async completeChallenge( challengeId: string, winners: IChallengeWinner[], From fd0dd8c720461159448794ff252027741030a5f6 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Tue, 28 Oct 2025 15:32:56 +0200 Subject: [PATCH 08/14] Add Trivy scanner workflow for vulnerability scanning --- .github/workflows/trivy.yaml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/trivy.yaml diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml new file mode 100644 index 0000000..7b9fa48 --- /dev/null +++ b/.github/workflows/trivy.yaml @@ -0,0 +1,34 @@ +name: Trivy Scanner + +permissions: + contents: read + security-events: write +on: + push: + branches: + - main + - dev + pull_request: +jobs: + trivy-scan: + name: Use Trivy + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Trivy scanner in repo mode + uses: aquasecurity/trivy-action@0.33.1 + with: + scan-type: "fs" + ignore-unfixed: true + format: "sarif" + output: "trivy-results.sarif" + severity: "CRITICAL,HIGH,UNKNOWN" + scanners: vuln,secret,misconfig,license + github-pat: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: "trivy-results.sarif" From 9e8e2e316e0b68ab8008ae5557313a6db07fb089 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 29 Oct 2025 08:32:30 +1100 Subject: [PATCH 09/14] Fallback handling if review summation creation doesn't work --- src/review/review.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/review/review.service.ts b/src/review/review.service.ts index 05009f3..200492d 100644 --- a/src/review/review.service.ts +++ b/src/review/review.service.ts @@ -1571,7 +1571,7 @@ export class ReviewService { sc."legacyId" AS "scorecardLegacyId", sc."minimumPassingScore" AS "minimumPassingScore", sc."type" AS "scorecardType", - rt."name" AS "reviewTypeName" + COALESCE(rt."name", r."typeId") AS "reviewTypeName" FROM ${ReviewService.SUBMISSION_TABLE} s LEFT JOIN ${ReviewService.REVIEW_TABLE} r ON r."submissionId" = s."id" From dddb6ec2796a29c59b68ee3ec00218326d4196fa Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 29 Oct 2025 10:35:35 +1100 Subject: [PATCH 10/14] Zero submission / post-mortem handling --- .../services/scheduler.service.spec.ts | 99 +++++++++++++++++++ src/autopilot/services/scheduler.service.ts | 44 ++++++++- 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/src/autopilot/services/scheduler.service.spec.ts b/src/autopilot/services/scheduler.service.spec.ts index 3ff24ef..2950fc5 100644 --- a/src/autopilot/services/scheduler.service.spec.ts +++ b/src/autopilot/services/scheduler.service.spec.ts @@ -613,4 +613,103 @@ describe('SchedulerService (review phase deferral)', () => { coverageSpy.mockRestore(); scheduleSpy.mockRestore(); }); + + describe('handleSubmissionPhaseClosed', () => { + it('cancels challenge as zero submissions while keeping post-mortem open', async () => { + const payload = createPayload({ + phaseId: 'submission-phase', + phaseTypeName: 'Submission', + }); + + const postMortemPhase = createPhase({ + id: 'post-mortem-phase', + name: 'Post-Mortem', + }); + + challengeApiService.createPostMortemPhase.mockResolvedValue( + postMortemPhase, + ); + + const pendingReviewSpy = jest + .spyOn(scheduler as any, 'createPostMortemPendingReviews') + .mockResolvedValue(undefined); + const scheduleSpy = jest + .spyOn(scheduler, 'schedulePhaseTransition') + .mockResolvedValue('scheduled-job'); + + const result = await (scheduler as any).handleSubmissionPhaseClosed( + payload, + ); + + expect(result).toBe(true); + expect(resourcesService.hasSubmitterResource).toHaveBeenCalledWith( + payload.challengeId, + expect.any(Array), + ); + expect(challengeApiService.cancelChallenge).toHaveBeenCalledWith( + payload.challengeId, + ChallengeStatusEnum.CANCELLED_ZERO_SUBMISSIONS, + ); + expect(scheduleSpy).toHaveBeenCalled(); + + pendingReviewSpy.mockRestore(); + scheduleSpy.mockRestore(); + }); + + it('cancels challenge as zero registrations when no submitters exist', async () => { + resourcesService.hasSubmitterResource.mockResolvedValue(false); + + const payload = createPayload({ + phaseId: 'submission-phase', + phaseTypeName: 'Submission', + }); + + const postMortemPhase = createPhase({ + id: 'post-mortem-phase', + name: 'Post-Mortem', + }); + + challengeApiService.createPostMortemPhase.mockResolvedValue( + postMortemPhase, + ); + + const pendingReviewSpy = jest + .spyOn(scheduler as any, 'createPostMortemPendingReviews') + .mockResolvedValue(undefined); + const scheduleSpy = jest + .spyOn(scheduler, 'schedulePhaseTransition') + .mockResolvedValue('scheduled-job'); + + await (scheduler as any).handleSubmissionPhaseClosed(payload); + + expect(challengeApiService.cancelChallenge).toHaveBeenCalledWith( + payload.challengeId, + ChallengeStatusEnum.CANCELLED_ZERO_REGISTRATIONS, + ); + + pendingReviewSpy.mockRestore(); + scheduleSpy.mockRestore(); + }); + }); + + describe('handlePostMortemPhaseClosed', () => { + it('skips cancelling when already marked with the target status', async () => { + const payload = createPayload({ + phaseId: 'post-mortem-phase', + phaseTypeName: 'Post-Mortem', + }); + + challengeApiService.getChallengeById.mockResolvedValueOnce({ + id: payload.challengeId, + phases: [], + reviewers: [], + legacy: {}, + status: ChallengeStatusEnum.CANCELLED_ZERO_SUBMISSIONS, + } as unknown as IChallenge); + + await (scheduler as any).handlePostMortemPhaseClosed(payload); + + expect(challengeApiService.cancelChallenge).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/autopilot/services/scheduler.service.ts b/src/autopilot/services/scheduler.service.ts index 1c69e0b..0fd48ae 100644 --- a/src/autopilot/services/scheduler.service.ts +++ b/src/autopilot/services/scheduler.service.ts @@ -1410,6 +1410,11 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { `[ZERO SUBMISSIONS] No active submissions found for challenge ${data.challengeId}; transitioning to Post-Mortem phase.`, ); + const hasSubmitter = await this.resourcesService.hasSubmitterResource( + data.challengeId, + this.submitterRoles, + ); + const postMortemPhase = await this.challengeApiService.createPostMortemPhase( data.challengeId, @@ -1422,6 +1427,19 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { postMortemPhase.id, ); + const cancelStatus = hasSubmitter + ? ChallengeStatusEnum.CANCELLED_ZERO_SUBMISSIONS + : ChallengeStatusEnum.CANCELLED_ZERO_REGISTRATIONS; + + await this.challengeApiService.cancelChallenge( + data.challengeId, + cancelStatus, + ); + + this.logger.log( + `${hasSubmitter ? '[ZERO SUBMISSIONS]' : '[ZERO REGISTRATIONS]'} Marked challenge ${data.challengeId} as ${cancelStatus} while keeping Post-Mortem phase ${postMortemPhase.id} open.`, + ); + 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.`, @@ -1782,15 +1800,39 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { data.challengeId, this.submitterRoles, ); + const statusTag = hasSubmitter + ? '[ZERO SUBMISSIONS]' + : '[ZERO REGISTRATIONS]'; const status = hasSubmitter ? ChallengeStatusEnum.CANCELLED_ZERO_SUBMISSIONS : ChallengeStatusEnum.CANCELLED_ZERO_REGISTRATIONS; + const challenge = + await this.challengeApiService.getChallengeById(data.challengeId); + const currentStatus = (challenge.status ?? '').toUpperCase(); + + if (currentStatus === status) { + this.logger.log( + `${statusTag} Challenge ${data.challengeId} already ${status}; no additional cancellation required after Post-Mortem completion.`, + ); + return; + } + + if ( + currentStatus.startsWith('CANCELLED') && + currentStatus !== status + ) { + this.logger.warn( + `${statusTag} Challenge ${data.challengeId} already cancelled as ${currentStatus}; skipping status override to ${status} after Post-Mortem completion.`, + ); + return; + } + await this.challengeApiService.cancelChallenge(data.challengeId, status); this.logger.log( - `${hasSubmitter ? '[ZERO SUBMISSIONS]' : '[ZERO REGISTRATIONS]'} Marked challenge ${data.challengeId} as ${status} after Post-Mortem completion.`, + `${statusTag} Marked challenge ${data.challengeId} as ${status} after Post-Mortem completion.`, ); } catch (error) { const err = error as Error; From c515121c2f25ce26c2520124579dc20dccbb92eb Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 29 Oct 2025 14:31:40 +1100 Subject: [PATCH 11/14] Better handling of approval phases --- .../services/autopilot.service.spec.ts | 32 +++++ src/autopilot/services/autopilot.service.ts | 128 +++++++++++++++++- 2 files changed, 159 insertions(+), 1 deletion(-) diff --git a/src/autopilot/services/autopilot.service.spec.ts b/src/autopilot/services/autopilot.service.spec.ts index b639a4c..1d62d09 100644 --- a/src/autopilot/services/autopilot.service.spec.ts +++ b/src/autopilot/services/autopilot.service.spec.ts @@ -23,6 +23,7 @@ import type { IChallenge, IPhase, } from '../../challenge/interfaces/challenge.interface'; +import type { ResourcesService } from '../../resources/resources.service'; const createMockMethod = any>() => jest.fn, Parameters>>(); @@ -55,6 +56,7 @@ describe('AutopilotService - handleSubmissionNotificationAggregate', () => { let schedulerService: jest.Mocked; let challengeApiService: jest.Mocked; let reviewService: jest.Mocked; + let resourcesService: jest.Mocked; let phaseReviewService: jest.Mocked; let configService: jest.Mocked; let autopilotService: AutopilotService; @@ -116,8 +118,17 @@ describe('AutopilotService - handleSubmissionNotificationAggregate', () => { getCompletedReviewCountForPhase: jest.fn(), getScorecardPassingScore: jest.fn(), getPendingReviewCount: jest.fn(), + createPendingReview: jest.fn(), } as unknown as jest.Mocked; + reviewService.createPendingReview.mockResolvedValue(false); + + resourcesService = { + getReviewerResources: jest.fn(), + } as unknown as jest.Mocked; + + resourcesService.getReviewerResources.mockResolvedValue([]); + phaseReviewService = { handlePhaseOpened: jest.fn(), } as unknown as jest.Mocked; @@ -133,6 +144,7 @@ describe('AutopilotService - handleSubmissionNotificationAggregate', () => { schedulerService, challengeApiService, reviewService, + resourcesService, phaseReviewService, configService, ); @@ -536,6 +548,15 @@ describe('AutopilotService - handleSubmissionNotificationAggregate', () => { score: 72, status: 'COMPLETED', }); + resourcesService.getReviewerResources.mockResolvedValueOnce([ + { + id: 'resource-approval', + memberId: '123', + memberHandle: 'approver1', + roleName: 'Approver', + }, + ]); + reviewService.createPendingReview.mockResolvedValueOnce(true); const followUpPhase: IPhase = { ...buildApprovalPhase(), @@ -567,6 +588,17 @@ describe('AutopilotService - handleSubmissionNotificationAggregate', () => { 'challenge-approval', 'phase-approval-next', ); + expect(resourcesService.getReviewerResources).toHaveBeenCalledWith( + 'challenge-approval', + ['Approver'], + ); + expect(reviewService.createPendingReview).toHaveBeenCalledWith( + 'submission-approval', + 'resource-approval', + 'phase-approval-next', + 'scorecard-approval', + 'challenge-approval', + ); }); }); diff --git a/src/autopilot/services/autopilot.service.ts b/src/autopilot/services/autopilot.service.ts index 4d39797..06129e2 100644 --- a/src/autopilot/services/autopilot.service.ts +++ b/src/autopilot/services/autopilot.service.ts @@ -18,7 +18,10 @@ import { TopgearSubmissionPayload, } from '../interfaces/autopilot.interface'; import { ChallengeApiService } from '../../challenge/challenge-api.service'; -import { IPhase } from '../../challenge/interfaces/challenge.interface'; +import { + IChallenge, + IPhase, +} from '../../challenge/interfaces/challenge.interface'; import { AUTOPILOT_COMMANDS } from '../../common/constants/commands.constants'; import { DEFAULT_APPEALS_PHASE_NAMES, @@ -28,12 +31,15 @@ import { REVIEW_PHASE_NAMES, SCREENING_PHASE_NAMES, APPROVAL_PHASE_NAMES, + PHASE_ROLE_MAP, } from '../constants/review.constants'; import { ReviewService } from '../../review/review.service'; +import { ResourcesService } from '../../resources/resources.service'; import { getNormalizedStringArray, isActiveStatus, } from '../utils/config.utils'; +import { selectScorecardId } from '../utils/reviewer.utils'; const SUBMISSION_NOTIFICATION_CREATE_TOPIC = 'submission.notification.create'; @Injectable() @@ -50,6 +56,7 @@ export class AutopilotService { private readonly schedulerService: SchedulerService, private readonly challengeApiService: ChallengeApiService, private readonly reviewService: ReviewService, + private readonly resourcesService: ResourcesService, private readonly phaseReviewService: PhaseReviewService, private readonly configService: ConfigService, ) { @@ -350,6 +357,13 @@ export class AutopilotService { Math.max(phase.duration || 0, 1), ); + await this.createFollowUpApprovalReviews( + challenge, + nextApproval, + review, + payload, + ); + await this.phaseReviewService.handlePhaseOpened( challenge.id, nextApproval.id, @@ -404,6 +418,118 @@ export class AutopilotService { } } + private async createFollowUpApprovalReviews( + challenge: IChallenge, + nextPhase: IPhase, + review: { + submissionId: string | null; + scorecardId: string | null; + resourceId: string; + }, + payload: ReviewCompletedPayload, + ): Promise { + const submissionId = review.submissionId ?? payload.submissionId ?? null; + + if (!submissionId) { + this.logger.warn( + `Unable to assign follow-up approval review for challenge ${challenge.id}; submissionId missing.`, + ); + return; + } + + let scorecardId = + review.scorecardId ?? + payload.scorecardId ?? + selectScorecardId( + challenge.reviewers ?? [], + () => + this.logger.warn( + `Missing scorecard configuration for follow-up Approval phase ${nextPhase.id} on challenge ${challenge.id}.`, + ), + (choices) => + this.logger.warn( + `Multiple scorecards ${choices.join(', ')} detected for follow-up Approval phase ${nextPhase.id} on challenge ${challenge.id}; defaulting to ${choices[0]}.`, + ), + nextPhase.phaseId ?? undefined, + ); + + if (!scorecardId) { + return; + } + + let approverResources: Array<{ id: string }> = []; + const roleNames = PHASE_ROLE_MAP[nextPhase.name] ?? ['Approver']; + + try { + approverResources = await this.resourcesService.getReviewerResources( + challenge.id, + roleNames, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to load approver resources for follow-up Approval phase ${nextPhase.id} on challenge ${challenge.id}: ${err.message}`, + err.stack, + ); + return; + } + + const resourceMap = new Map(); + for (const resource of approverResources) { + if (resource?.id) { + resourceMap.set(resource.id, { id: resource.id }); + } + } + + if (review?.resourceId) { + resourceMap.set(review.resourceId, { id: review.resourceId }); + } + if (payload.reviewerResourceId) { + resourceMap.set(payload.reviewerResourceId, { + id: payload.reviewerResourceId, + }); + } + + if (!resourceMap.size) { + this.logger.warn( + `Unable to assign follow-up approval review for challenge ${challenge.id}; no approver resources found.`, + ); + return; + } + + let createdCount = 0; + for (const resource of resourceMap.values()) { + try { + const created = await this.reviewService.createPendingReview( + submissionId, + resource.id, + nextPhase.id, + scorecardId, + challenge.id, + ); + if (created) { + createdCount += 1; + } + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to create follow-up approval review for challenge ${challenge.id}, phase ${nextPhase.id}, resource ${resource.id}: ${err.message}`, + err.stack, + ); + } + } + + if (createdCount > 0) { + this.logger.log( + `Created ${createdCount} follow-up approval review(s) for challenge ${challenge.id}, phase ${nextPhase.id}, submission ${submissionId}.`, + ); + } else { + this.logger.warn( + `No follow-up approval reviews created for challenge ${challenge.id}, phase ${nextPhase.id}; a pending review may already exist.`, + ); + } + } + async handleAppealResponded(payload: AppealRespondedPayload): Promise { const { challengeId } = payload; From c4839391e44248c9e834afb03d1909ed1c9ff427 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 30 Oct 2025 11:25:39 +1100 Subject: [PATCH 12/14] Create review opportunities when challenges go active, if required --- .../phase-schedule-manager.service.ts | 47 ++++- src/autopilot/utils/config.utils.ts | 10 ++ src/config/sections/review.config.ts | 13 ++ .../dto/create-review-opportunity.dto.ts | 10 ++ src/review/review-api.service.ts | 169 ++++++++++++++++++ src/review/review.module.ts | 9 +- 6 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 src/review/dto/create-review-opportunity.dto.ts create mode 100644 src/review/review-api.service.ts diff --git a/src/autopilot/services/phase-schedule-manager.service.ts b/src/autopilot/services/phase-schedule-manager.service.ts index 2173b11..dfcfb46 100644 --- a/src/autopilot/services/phase-schedule-manager.service.ts +++ b/src/autopilot/services/phase-schedule-manager.service.ts @@ -19,7 +19,11 @@ import { REVIEW_PHASE_NAMES, SCREENING_PHASE_NAMES, } from '../constants/review.constants'; -import { getNormalizedStringArray, isActiveStatus } from '../utils/config.utils'; +import { + getNormalizedStringArray, + hasTransitionedToActive, + isActiveStatus, +} from '../utils/config.utils'; import { ConfigService } from '@nestjs/config'; import { getMemberReviewerConfigs, @@ -31,6 +35,7 @@ import { export class PhaseScheduleManager { private readonly logger = new Logger(PhaseScheduleManager.name); private readonly appealsResponsePhaseNames: Set; + private readonly challengeStatusCache: Map; private static readonly OVERDUE_PHASE_GRACE_PERIOD_MS = 60_000; constructor( @@ -63,6 +68,8 @@ export class PhaseScheduleManager { Array.from(DEFAULT_APPEALS_RESPONSE_PHASE_NAMES), ), ); + + this.challengeStatusCache = new Map(); } async schedulePhaseTransition( @@ -211,6 +218,17 @@ export class PhaseScheduleManager { const challengeDetails = await this.challengeApiService.getChallengeById( challenge.id, ); + const hasTransitioned = hasTransitionedToActive( + null, + challengeDetails.status, + ); + + if (hasTransitioned) { + this.logger.log( + `[REVIEW OPPORTUNITIES] Detected transition to ACTIVE for new challenge ${challenge.id}; review opportunity creation pending.`, + ); + // TODO: createReviewOpportunitiesForChallenge(challengeDetails); + } if (!isActiveStatus(challengeDetails.status)) { this.logger.log( @@ -246,6 +264,8 @@ export class PhaseScheduleManager { `Next phase ${phase.id} for new challenge ${challenge.id} has no scheduled ${stateLabel} date. Skipping.`, ), }); + + this.updateCachedStatus(challenge.id, challengeDetails.status); } catch (error) { const err = error as Error; this.logger.error( @@ -257,11 +277,23 @@ export class PhaseScheduleManager { async handleChallengeUpdate(message: ChallengeUpdatePayload): Promise { this.logger.log(`Handling challenge update: ${JSON.stringify(message)}`); + const previousStatus = this.getCachedStatus(message.id); try { let challengeDetails = await this.challengeApiService.getChallengeById( message.id, ); + const hasTransitioned = hasTransitionedToActive( + previousStatus, + challengeDetails.status, + ); + + if (hasTransitioned) { + this.logger.log( + `[REVIEW OPPORTUNITIES] Detected transition to ACTIVE for updated challenge ${message.id}; review opportunity creation pending.`, + ); + // TODO: createReviewOpportunitiesForChallenge(challengeDetails); + } if (!isActiveStatus(challengeDetails.status)) { this.logger.log( @@ -281,6 +313,9 @@ export class PhaseScheduleManager { challengeDetails = await this.challengeApiService.getChallengeById( message.id, ); + if (challengeDetails.status !== previousStatus) { + this.updateCachedStatus(message.id, challengeDetails.status); + } if (!isActiveStatus(challengeDetails.status)) { this.logger.log( @@ -394,6 +429,8 @@ export class PhaseScheduleManager { err.stack, ); } + + this.updateCachedStatus(message.id, challengeDetails.status); } catch (error) { const err = error as Error; this.logger.error( @@ -526,6 +563,14 @@ export class PhaseScheduleManager { } } + private getCachedStatus(challengeId: string): string | null { + return this.challengeStatusCache.get(challengeId) ?? null; + } + + private updateCachedStatus(challengeId: string, status: string): void { + this.challengeStatusCache.set(challengeId, status); + } + private async processPastDueOpenPhases( challenge: IChallenge, ): Promise { diff --git a/src/autopilot/utils/config.utils.ts b/src/autopilot/utils/config.utils.ts index 757e45f..e488dd2 100644 --- a/src/autopilot/utils/config.utils.ts +++ b/src/autopilot/utils/config.utils.ts @@ -32,6 +32,16 @@ export function isActiveStatus(status?: string): boolean { return (status ?? '').toUpperCase() === 'ACTIVE'; } +export function hasTransitionedToActive( + previousStatus?: string | null, + currentStatus?: string | null, +): boolean { + const wasActive = isActiveStatus(previousStatus ?? undefined); + const isActive = isActiveStatus(currentStatus ?? undefined); + + return !wasActive && isActive; +} + export function parseOperator(operator?: AutopilotOperator | string): string { return typeof operator === 'string' ? operator diff --git a/src/config/sections/review.config.ts b/src/config/sections/review.config.ts index 458f96d..e4ac187 100644 --- a/src/config/sections/review.config.ts +++ b/src/config/sections/review.config.ts @@ -1,10 +1,21 @@ import { registerAs } from '@nestjs/config'; const DEFAULT_POLL_INTERVAL_MS = 5 * 60 * 1000; +const DEFAULT_TIMEOUT_MS = 15000; + +const parseNumber = (value: string | undefined, fallback: number): number => { + if (!value) { + return fallback; + } + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; export default registerAs('review', () => { const pollIntervalEnv = process.env.REVIEWER_POLL_INTERVAL_MS; const pollInterval = Number(pollIntervalEnv); + const baseUrl = (process.env.REVIEW_API_URL || '').trim(); + const timeoutMs = parseNumber(process.env.REVIEW_API_TIMEOUT_MS, DEFAULT_TIMEOUT_MS); return { dbUrl: process.env.REVIEW_DB_URL, @@ -14,5 +25,7 @@ export default registerAs('review', () => { : DEFAULT_POLL_INTERVAL_MS, summationApiUrl: (process.env.REVIEW_SUMMATION_API_URL || '').trim(), summationApiTimeoutMs: 10000, + baseUrl, + timeoutMs, }; }); diff --git a/src/review/dto/create-review-opportunity.dto.ts b/src/review/dto/create-review-opportunity.dto.ts new file mode 100644 index 0000000..92592f7 --- /dev/null +++ b/src/review/dto/create-review-opportunity.dto.ts @@ -0,0 +1,10 @@ +export class CreateReviewOpportunityDto { + challengeId!: string; + status?: string; + type?: string; + openPositions!: number; + startDate!: string; + duration!: number; + basePayment!: number; + incrementalPayment!: number; +} diff --git a/src/review/review-api.service.ts b/src/review/review-api.service.ts new file mode 100644 index 0000000..ca354a4 --- /dev/null +++ b/src/review/review-api.service.ts @@ -0,0 +1,169 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; +import { Auth0Service } from '../auth/auth0.service'; +import { AutopilotDbLoggerService } from '../autopilot/services/autopilot-db-logger.service'; +import { CreateReviewOpportunityDto } from './dto/create-review-opportunity.dto'; + +@Injectable() +export class ReviewApiService { + private readonly logger = new Logger(ReviewApiService.name); + private readonly baseUrl: string; + private readonly timeoutMs: number; + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService, + private readonly auth0Service: Auth0Service, + private readonly dbLogger: AutopilotDbLoggerService, + ) { + this.baseUrl = (this.configService.get('review.baseUrl') || '').trim(); + this.timeoutMs = this.configService.get('review.timeoutMs') ?? 15000; + + if (!this.baseUrl) { + this.logger.warn( + 'REVIEW_API_URL is not configured. Review opportunity creation is disabled.', + ); + } + } + + private buildUrl(path: string): string | null { + if (!this.baseUrl) { + return null; + } + const normalizedBase = this.baseUrl.endsWith('/') + ? this.baseUrl.slice(0, -1) + : this.baseUrl; + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${normalizedBase}${normalizedPath}`; + } + + async createReviewOpportunity( + dto: CreateReviewOpportunityDto, + ): Promise | null> { + const url = this.buildUrl('/review-opportunities'); + if (!url) { + await this.dbLogger.logAction('review.createOpportunity', { + status: 'INFO', + source: ReviewApiService.name, + details: { + note: 'REVIEW_API_URL not configured; skipping review opportunity creation call.', + dto, + }, + }); + return null; + } + + const payload = { + ...dto, + status: dto.status ?? 'OPEN', + type: dto.type ?? 'REGULAR_REVIEW', + }; + + let token: string | undefined; + try { + token = await this.auth0Service.getAccessToken(); + const response = await firstValueFrom( + this.httpService.post(url, payload, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + timeout: this.timeoutMs, + }), + ); + + await this.dbLogger.logAction('review.createOpportunity', { + status: 'SUCCESS', + source: ReviewApiService.name, + details: { + url, + status: response.status, + token, + payload, + }, + }); + this.logger.log( + `Created review opportunity for challenge ${dto.challengeId} (status ${response.status}).`, + ); + return response.data; + } catch (error) { + const err = error as any; + const message = err?.message || 'Unknown error'; + const status = err?.response?.status; + const data = err?.response?.data; + + this.logger.error( + `Failed to create review opportunity for challenge ${dto.challengeId}: ${message}`, + err?.stack, + ); + await this.dbLogger.logAction('review.createOpportunity', { + status: 'ERROR', + source: ReviewApiService.name, + details: { + url, + error: message, + status, + response: data, + token, + payload, + }, + }); + return null; + } + } + + async getReviewOpportunitiesByChallengeId(challengeId: string): Promise { + const url = this.buildUrl(`/review-opportunities/challenge/${challengeId}`); + if (!url) { + await this.dbLogger.logAction('review.getOpportunitiesByChallenge', { + status: 'INFO', + source: ReviewApiService.name, + details: { + note: 'REVIEW_API_URL not configured; skipping review opportunity retrieval.', + challengeId, + }, + }); + return []; + } + + let token: string | undefined; + try { + token = await this.auth0Service.getAccessToken(); + const response = await firstValueFrom( + this.httpService.get(url, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + timeout: this.timeoutMs, + }), + ); + return Array.isArray(response.data) ? response.data : []; + } catch (error) { + const err = error as any; + const message = err?.message || 'Unknown error'; + const status = err?.response?.status; + const data = err?.response?.data; + + this.logger.error( + `Failed to fetch review opportunities for challenge ${challengeId}: ${message}`, + err?.stack, + ); + await this.dbLogger.logAction('review.getOpportunitiesByChallenge', { + status: 'ERROR', + source: ReviewApiService.name, + details: { + url, + error: message, + status, + response: data, + token, + challengeId, + }, + }); + return []; + } + } +} diff --git a/src/review/review.module.ts b/src/review/review.module.ts index d254c98..0d39d69 100644 --- a/src/review/review.module.ts +++ b/src/review/review.module.ts @@ -1,9 +1,14 @@ import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { Auth0Module } from '../auth/auth0.module'; +import { AutopilotLoggingModule } from '../autopilot/autopilot-logging.module'; import { ReviewPrismaService } from './review-prisma.service'; import { ReviewService } from './review.service'; +import { ReviewApiService } from './review-api.service'; @Module({ - providers: [ReviewPrismaService, ReviewService], - exports: [ReviewService], + imports: [HttpModule, Auth0Module, AutopilotLoggingModule], + providers: [ReviewPrismaService, ReviewService, ReviewApiService], + exports: [ReviewService, ReviewApiService], }) export class ReviewModule {} From ec9074ee1b135a240107909ed5495c5181ca03d1 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 30 Oct 2025 14:03:03 +1100 Subject: [PATCH 13/14] Better iterative review handling for TG Task --- .../services/first2finish.service.spec.ts | 32 +++++++++++++++++++ .../services/first2finish.service.ts | 14 ++++++++ 2 files changed, 46 insertions(+) diff --git a/src/autopilot/services/first2finish.service.spec.ts b/src/autopilot/services/first2finish.service.spec.ts index 6a3f024..c5f97b9 100644 --- a/src/autopilot/services/first2finish.service.spec.ts +++ b/src/autopilot/services/first2finish.service.spec.ts @@ -188,6 +188,38 @@ describe('First2FinishService', () => { expect(schedulerService.advancePhase).not.toHaveBeenCalled(); }); + it('keeps the active iterative review phase open when no submissions are available', async () => { + const activePhase = buildIterativePhase({ + isOpen: true, + actualEndDate: null, + }); + + const challenge = buildChallenge({ + phases: [activePhase], + reviewers: [buildReviewer()], + }); + + challengeApiService.getChallengeById.mockResolvedValue(challenge); + + resourcesService.getReviewerResources.mockResolvedValue([ + { + id: 'resource-1', + memberId: '2001', + memberHandle: 'iterativeReviewer', + roleName: 'Iterative Reviewer', + }, + ]); + + reviewService.getAllSubmissionIdsOrdered.mockResolvedValue([]); + reviewService.getExistingReviewPairs.mockResolvedValue(new Set()); + + await service.handleSubmissionByChallengeId(challenge.id); + + expect(reviewService.createPendingReview).not.toHaveBeenCalled(); + expect(schedulerService.advancePhase).not.toHaveBeenCalled(); + expect(schedulerService.schedulePhaseTransition).not.toHaveBeenCalled(); + }); + it('reopens the seeded iterative review phase when the first submission arrives', async () => { const seedPhase = buildIterativePhase({ isOpen: false, diff --git a/src/autopilot/services/first2finish.service.ts b/src/autopilot/services/first2finish.service.ts index e067115..4db2647 100644 --- a/src/autopilot/services/first2finish.service.ts +++ b/src/autopilot/services/first2finish.service.ts @@ -364,6 +364,20 @@ export class First2FinishService { ); if (!assigned) { + const submissionSnapshot = + await this.reviewService.getAllSubmissionIdsOrdered(challenge.id); + + if (!submissionSnapshot.length) { + this.logger.debug( + `No submissions available yet for iterative review on challenge ${challenge.id}; keeping phase ${activePhase.id} open.`, + { + submissionId: submissionId ?? null, + phaseId: activePhase.id, + }, + ); + return; + } + this.logger.debug( `No additional submissions available for iterative review on challenge ${challenge.id}; closing phase ${activePhase.id}.`, ); From 2f9c077644d3cbf1ca37aea5ea7002842643357f Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 30 Oct 2025 18:09:16 +1100 Subject: [PATCH 14/14] Better handling of failed approval and checkpoint screening and chekcpoint reviewer without an initial resource assigned to either. --- .../interfaces/autopilot.interface.ts | 1 + src/autopilot/services/autopilot.service.ts | 5 +- .../resource-event-handler.service.spec.ts | 113 ++++++++++++++++++ .../resource-event-handler.service.ts | 100 +++++++++++++--- src/autopilot/services/scheduler.service.ts | 6 + 5 files changed, 205 insertions(+), 20 deletions(-) diff --git a/src/autopilot/interfaces/autopilot.interface.ts b/src/autopilot/interfaces/autopilot.interface.ts index 1eb2f65..ec0fdb8 100644 --- a/src/autopilot/interfaces/autopilot.interface.ts +++ b/src/autopilot/interfaces/autopilot.interface.ts @@ -30,6 +30,7 @@ export interface PhaseTransitionPayload { projectStatus: string; date?: string; challengeId: string; // Changed to string to support UUIDs + preventFinalization?: boolean; } export interface ChallengeUpdatePayload { diff --git a/src/autopilot/services/autopilot.service.ts b/src/autopilot/services/autopilot.service.ts index 06129e2..408f550 100644 --- a/src/autopilot/services/autopilot.service.ts +++ b/src/autopilot/services/autopilot.service.ts @@ -319,6 +319,8 @@ export class AutopilotService { return Number.isFinite(fallback) ? fallback : 0; })(); + const reviewPassed = normalizedScore >= passingScore; + await this.schedulerService.advancePhase({ projectId: challenge.projectId, challengeId: challenge.id, @@ -327,9 +329,10 @@ export class AutopilotService { state: 'END', operator: AutopilotOperator.SYSTEM, projectStatus: challenge.status, + preventFinalization: !reviewPassed, }); - if (normalizedScore >= passingScore) { + if (reviewPassed) { this.logger.log( `Approval review passed for challenge ${challenge.id} (score ${normalizedScore} / passing ${passingScore}).`, ); diff --git a/src/autopilot/services/resource-event-handler.service.spec.ts b/src/autopilot/services/resource-event-handler.service.spec.ts index c236496..7ed42fc 100644 --- a/src/autopilot/services/resource-event-handler.service.spec.ts +++ b/src/autopilot/services/resource-event-handler.service.spec.ts @@ -59,6 +59,7 @@ describe('ResourceEventHandler', () => { resourcesService = { getResourceById: jest.fn(), getRoleNameById: jest.fn(), + getReviewerResources: jest.fn(), } as unknown as jest.Mocked; configService = { @@ -139,5 +140,117 @@ describe('ResourceEventHandler', () => { ); expect(phaseReviewService.handlePhaseOpened).not.toHaveBeenCalled(); }); + + it('opens checkpoint screening when a screener is assigned after the phase was deferred', async () => { + const payload: ResourceEventPayload = { + id: resourceId, + challengeId, + memberId: '333', + memberHandle: 'screener', + roleId: 'role-screener', + created: new Date().toISOString(), + createdBy: 'system', + }; + + resourcesService.getResourceById.mockResolvedValue({ + id: resourceId, + roleName: 'Checkpoint Screener', + memberId: 'user-2', + memberHandle: 'screener', + challengeId, + roleId: 'role-screener', + } as unknown as any); + + challengeApiService.getChallengeById.mockResolvedValue({ + id: challengeId, + projectId: 321, + status: 'Active', + type: 'Design', + phases: [ + { + id: 'phase-screening', + phaseId: 'phase-template-screening', + name: 'Checkpoint Screening', + isOpen: false, + scheduledStartDate: new Date(Date.now() - 10_000).toISOString(), + }, + ], + reviewers: [ + { + phaseId: 'phase-template-screening', + isMemberReview: false, + memberReviewerCount: 1, + }, + ], + } as unknown as any); + + resourcesService.getReviewerResources.mockResolvedValue([ + { id: resourceId }, + ] as unknown as any); + + await handler.handleResourceCreated(payload); + + expect(reviewAssignmentService.ensureAssignmentsOrSchedule).not.toHaveBeenCalled(); + expect(schedulerService.advancePhase).toHaveBeenCalledWith({ + projectId: 321, + challengeId, + phaseId: 'phase-screening', + phaseTypeName: 'Checkpoint Screening', + state: 'START', + operator: 'system', + projectStatus: 'Active', + }); + }); + + it('defers opening checkpoint screening when no screener is assigned', async () => { + const payload: ResourceEventPayload = { + id: resourceId, + challengeId, + memberId: '333', + memberHandle: 'screener', + roleId: 'role-screener', + created: new Date().toISOString(), + createdBy: 'system', + }; + + resourcesService.getResourceById.mockResolvedValue({ + id: resourceId, + roleName: 'Checkpoint Screener', + memberId: 'user-2', + memberHandle: 'screener', + challengeId, + roleId: 'role-screener', + } as unknown as any); + + challengeApiService.getChallengeById.mockResolvedValue({ + id: challengeId, + projectId: 321, + status: 'Active', + type: 'Design', + phases: [ + { + id: 'phase-screening', + phaseId: 'phase-template-screening', + name: 'Checkpoint Screening', + isOpen: false, + scheduledStartDate: new Date(Date.now() - 10_000).toISOString(), + }, + ], + reviewers: [ + { + phaseId: 'phase-template-screening', + isMemberReview: false, + memberReviewerCount: 1, + }, + ], + } as unknown as any); + + resourcesService.getReviewerResources.mockResolvedValue([]); + + await handler.handleResourceCreated(payload); + + expect(schedulerService.advancePhase).not.toHaveBeenCalled(); + expect(reviewAssignmentService.ensureAssignmentsOrSchedule).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/autopilot/services/resource-event-handler.service.ts b/src/autopilot/services/resource-event-handler.service.ts index e36dafa..94e87a3 100644 --- a/src/autopilot/services/resource-event-handler.service.ts +++ b/src/autopilot/services/resource-event-handler.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ChallengeApiService } from '../../challenge/challenge-api.service'; +import type { IPhase } from '../../challenge/interfaces/challenge.interface'; import { PhaseReviewService } from './phase-review.service'; import { ReviewAssignmentService } from './review-assignment.service'; import { ReviewService } from '../../review/review.service'; @@ -16,6 +17,7 @@ import { REVIEW_PHASE_NAMES, SCREENING_PHASE_NAMES, APPROVAL_PHASE_NAMES, + getRoleNamesForPhase, } from '../constants/review.constants'; import { First2FinishService } from './first2finish.service'; import { SchedulerService } from './scheduler.service'; @@ -23,6 +25,7 @@ import { getNormalizedStringArray, isActiveStatus, } from '../utils/config.utils'; +import { getReviewerConfigsForPhase } from '../utils/reviewer.utils'; @Injectable() export class ResourceEventHandler { @@ -256,19 +259,22 @@ export class ResourceEventHandler { private async maybeOpenDeferredReviewPhases( challenge: Awaited>, ): Promise { - const reviewPhases = challenge.phases ?? []; - if (!reviewPhases.length) { + const phases = challenge.phases ?? []; + if (!phases.length) { return; } const now = Date.now(); - for (const phase of reviewPhases) { + for (const phase of phases) { + const isReviewPhase = REVIEW_PHASE_NAMES.has(phase.name); + const isScreeningPhase = SCREENING_PHASE_NAMES.has(phase.name); + if ( phase.isOpen || !!phase.actualEndDate || - phase.name === ITERATIVE_REVIEW_PHASE_NAME || - !REVIEW_PHASE_NAMES.has(phase.name) + (isReviewPhase && phase.name === ITERATIVE_REVIEW_PHASE_NAME) || + (!isReviewPhase && !isScreeningPhase) ) { continue; } @@ -281,7 +287,7 @@ export class ResourceEventHandler { } if (phase.predecessor) { - const predecessor = reviewPhases.find( + const predecessor = phases.find( (candidate) => candidate.phaseId === phase.predecessor || candidate.id === phase.predecessor, @@ -306,19 +312,40 @@ export class ResourceEventHandler { }; 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 (isReviewPhase) { + 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; + } + } else { + try { + ready = await this.isScreeningPhaseReady(challenge, phase); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to verify screening assignments for phase ${phase.id} on challenge ${challenge.id}: ${err.message}`, + err.stack, + ); + continue; + } + + if (!ready) { + this.logger.warn( + `Deferring opening of screening phase ${phase.id} for challenge ${challenge.id} until required screeners are assigned.`, + ); + continue; + } } if (!phase.isOpen && ready) { @@ -335,6 +362,41 @@ export class ResourceEventHandler { } } + private async isScreeningPhaseReady( + challenge: Awaited>, + phase: IPhase, + ): Promise { + if (!phase.phaseId) { + this.logger.warn( + `Screening phase ${phase.id} on challenge ${challenge.id} is missing a phase template ID; opening without reviewer validation.`, + ); + return true; + } + + const reviewerConfigs = getReviewerConfigsForPhase( + challenge.reviewers, + phase.phaseId, + ); + + const required = reviewerConfigs.reduce((total, config) => { + const normalized = Number(config.memberReviewerCount ?? 1); + const increment = Number.isFinite(normalized) ? Math.max(normalized, 0) : 0; + return total + increment; + }, 0); + + if (required <= 0) { + return true; + } + + const roleNames = getRoleNamesForPhase(phase.name); + const assignedReviewers = await this.resourcesService.getReviewerResources( + challenge.id, + roleNames, + ); + + return assignedReviewers.length >= required; + } + private computeReviewRoleNames(postMortemRoles: string[]): Set { const roles = new Set(); for (const names of Object.values(PHASE_ROLE_MAP)) { diff --git a/src/autopilot/services/scheduler.service.ts b/src/autopilot/services/scheduler.service.ts index 0fd48ae..2828c69 100644 --- a/src/autopilot/services/scheduler.service.ts +++ b/src/autopilot/services/scheduler.service.ts @@ -1003,12 +1003,18 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { if ( !skipFinalization && + !data.preventFinalization && !hasOpenPhases && !hasNextPhases && !hasIncompletePhases ) { await this.attemptChallengeFinalization(data.challengeId); } else { + if (!skipFinalization && data.preventFinalization) { + this.logger.debug?.( + `Challenge ${data.challengeId} finalization deferred after closing phase ${data.phaseId}; preventFinalization flag set.`, + ); + } const pendingCount = phases?.reduce((pending, phase) => { return pending + (phase.isOpen || !phase.actualEndDate ? 1 : 0); }, 0);