Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/autopilot/services/challenge-completion.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,4 +411,18 @@ export class ChallengeCompletionService {
);
return true;
}

async completeChallengeWithWinners(
challengeId: string,
winners: IChallengeWinner[],
context?: { reason?: string },
): Promise<void> {
await this.challengeApiService.completeChallenge(challengeId, winners);
// Trigger finance payments generation after marking the challenge as completed
void this.financeApiService.generateChallengePayments(challengeId);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The use of void to ignore the promise returned by generateChallengePayments could lead to unhandled errors if the promise is rejected. Consider handling the promise explicitly to ensure any errors are logged or managed appropriately.

const suffix = context?.reason ? ` (${context.reason})` : '';
this.logger.log(
`Marked challenge ${challengeId} as COMPLETED with ${winners.length} winner(s)${suffix}.`,
);
}
}
77 changes: 77 additions & 0 deletions src/autopilot/services/first2finish.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
IPhase,
IChallengeReviewer,
} from '../../challenge/interfaces/challenge.interface';
import type { ChallengeCompletionService } from './challenge-completion.service';
import {
ITERATIVE_REVIEW_PHASE_NAME,
REGISTRATION_PHASE_NAME,
Expand Down Expand Up @@ -105,6 +106,7 @@ describe('First2FinishService', () => {
let reviewService: jest.Mocked<ReviewService>;
let resourcesService: jest.Mocked<ResourcesService>;
let configService: jest.Mocked<ConfigService>;
let challengeCompletionService: jest.Mocked<ChallengeCompletionService>;
let service: First2FinishService;

beforeEach(() => {
Expand Down Expand Up @@ -132,6 +134,7 @@ describe('First2FinishService', () => {

resourcesService = {
getReviewerResources: jest.fn(),
getMemberHandleMap: jest.fn(),
} as unknown as jest.Mocked<ResourcesService>;

configService = {
Expand All @@ -146,16 +149,22 @@ describe('First2FinishService', () => {
}),
} as unknown as jest.Mocked<ConfigService>;

challengeCompletionService = {
completeChallengeWithWinners: jest.fn(),
} as unknown as jest.Mocked<ChallengeCompletionService>;

service = new First2FinishService(
challengeApiService,
schedulerService,
reviewService,
resourcesService,
configService,
challengeCompletionService,
);

reviewService.getReviewerSubmissionPairs.mockResolvedValue(new Set());
reviewService.getPendingReviewCount.mockResolvedValue(0);
resourcesService.getMemberHandleMap.mockResolvedValue(new Map());
});

afterEach(() => {
Expand Down Expand Up @@ -491,5 +500,73 @@ describe('First2FinishService', () => {
state: 'END',
}),
);

expect(challengeCompletionService.completeChallengeWithWinners).toHaveBeenCalledWith(
challenge.id,
[
{
handle: 'submitter',
placement: 1,
userId: 4001,
},
],
{ reason: 'iterative-review-pass' },
);
});

it('uses member handle map when completing after a passing iterative review', async () => {
const iterativePhase = buildIterativePhase({
id: 'iter-phase',
isOpen: true,
actualEndDate: null,
});

const challenge = buildChallenge({
phases: [iterativePhase],
reviewers: [buildReviewer()],
});

resourcesService.getMemberHandleMap.mockResolvedValue(
new Map([['4001', 'resolvedHandle']]),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
Consider checking if the getMemberHandleMap method could potentially return null or undefined instead of a Map. If so, ensure that the code handles such cases to prevent runtime errors.

);
reviewService.getScorecardPassingScore.mockResolvedValue(80);

await service.handleIterativeReviewCompletion(
challenge,
iterativePhase,
{
score: 90,
scorecardId: 'iterative-scorecard',
resourceId: 'resource-1',
submissionId: 'submission-1',
phaseId: iterativePhase.id,
},
{
reviewId: 'review-1',
challengeId: challenge.id,
submissionId: 'submission-1',
phaseId: iterativePhase.id,
scorecardId: 'iterative-scorecard',
reviewerResourceId: 'resource-1',
reviewerHandle: 'iterativeReviewer',
reviewerMemberId: '2001',
submitterHandle: 'submitter',
submitterMemberId: '4001',
completedAt: iso(),
initialScore: 90,
},
);

expect(challengeCompletionService.completeChallengeWithWinners).toHaveBeenCalledWith(
challenge.id,
[
{
handle: 'resolvedHandle',
placement: 1,
userId: 4001,
},
],
{ reason: 'iterative-review-pass' },
);
});
});
55 changes: 55 additions & 0 deletions src/autopilot/services/first2finish.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from '../constants/challenge.constants';
import { isActiveStatus } from '../utils/config.utils';
import { selectScorecardId } from '../utils/reviewer.utils';
import { ChallengeCompletionService } from './challenge-completion.service';

@Injectable()
export class First2FinishService {
Expand All @@ -46,6 +47,7 @@ export class First2FinishService {
private readonly reviewService: ReviewService,
private readonly resourcesService: ResourcesService,
private readonly configService: ConfigService,
private readonly challengeCompletionService: ChallengeCompletionService,
) {
this.iterativeRoles = PHASE_ROLE_MAP[ITERATIVE_REVIEW_PHASE_NAME] ?? [
'Iterative Reviewer',
Expand Down Expand Up @@ -189,6 +191,8 @@ export class First2FinishService {
projectStatus: challenge.status,
});
}

await this.completeFirst2FinishChallenge(challenge, payload);
} else {
this.logger.log(
`Iterative review failed for submission ${payload.submissionId} on challenge ${challenge.id} (score ${finalScore}, passing ${passingScore}).`,
Expand All @@ -199,6 +203,57 @@ export class First2FinishService {
}
}

private async completeFirst2FinishChallenge(
challenge: IChallenge,
payload: ReviewCompletedPayload,
): Promise<void> {
if (!isActiveStatus(challenge.status)) {
this.logger.debug(
`Skipping completion for challenge ${challenge.id}; status ${challenge.status ?? 'UNKNOWN'} is not ACTIVE.`,
);
return;
}

const memberIdRaw = payload.submitterMemberId ?? '';
const numericMemberId = Number(memberIdRaw);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The conversion of memberIdRaw to numericMemberId using Number() could result in NaN if memberIdRaw is not a valid number. Consider using parseInt() or parseFloat() with appropriate checks to ensure the conversion is intentional and correct.


if (!Number.isFinite(numericMemberId)) {
this.logger.warn(
`Unable to complete challenge ${challenge.id} after passing iterative review; submitterMemberId is invalid (${payload.submitterMemberId}).`,
);
return;
}

let handle = payload.submitterHandle?.trim();
try {
const handleMap = await this.resourcesService.getMemberHandleMap(
challenge.id,
[String(memberIdRaw)],
);
handle = handleMap.get(String(memberIdRaw)) ?? handle;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 readability]
The use of String(memberIdRaw) in the getMemberHandleMap call is redundant since memberIdRaw is already a string. Consider removing the String() conversion for clarity.

} catch (error) {
const err = error as Error;
this.logger.warn(
`Failed to resolve handle for member ${memberIdRaw} on challenge ${challenge.id}; using provided payload handle.`,
err.stack,
);
}

await this.challengeCompletionService.completeChallengeWithWinners(
challenge.id,
[
{
userId: numericMemberId,
handle: handle && handle.length ? handle : String(memberIdRaw),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 style]
The conditional check handle && handle.length is unnecessary. handle.length alone suffices to check if the string is non-empty.

placement: 1,
},
],
{
reason: 'iterative-review-pass',
},
);
}

async handleIterativePhaseClosed(challengeId: string): Promise<void> {
try {
await this.prepareNextIterativeReview(challengeId);
Expand Down
61 changes: 59 additions & 2 deletions src/autopilot/services/scheduler.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,48 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy {

let skipPhaseChain = false;
let skipFinalization = false;
let appealsOpenedImmediately = false;

if (operation === 'close' && isReviewPhase && result.next?.phases?.length) {
const appealsSuccessors = result.next.phases.filter(
(phase) =>
this.isAppealsPhaseName(phase.name) ||
this.isAppealsResponsePhaseName(phase.name),
);

if (appealsSuccessors.length > 0) {
try {
if (this.phaseChainCallback) {
this.logger.log(
`[APPEALS FAST-TRACK] Closing Review for challenge ${data.challengeId}; opening ${appealsSuccessors.length} appeals-related phase(s) immediately.`,
);

const callbackResult = this.phaseChainCallback(
data.challengeId,
data.projectId,
data.projectStatus ?? ChallengeStatusEnum.ACTIVE,
appealsSuccessors,
);

if (callbackResult instanceof Promise) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 maintainability]
The check for callbackResult instanceof Promise is redundant since await can handle both promises and non-promises. Consider removing this check to simplify the code.

await callbackResult;
}

appealsOpenedImmediately = true;
} else {
this.logger.warn(
`[APPEALS FAST-TRACK] Phase chain callback not set; unable to auto-open appeals for challenge ${data.challengeId}.`,
);
}
} catch (error) {
const err = error as Error;
this.logger.error(
`[APPEALS FAST-TRACK] Failed to fast-open appeals for challenge ${data.challengeId}: ${err.message}`,
err.stack,
);
}
}
}

if (operation === 'close' && isReviewPhase) {
this.reviewCloseRetryAttempts.delete(
Expand Down Expand Up @@ -1058,16 +1100,31 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy {
result.next.phases &&
result.next.phases.length > 0
) {
const phasesToOpen = appealsOpenedImmediately
? result.next.phases.filter(
(phase) =>
!this.isAppealsPhaseName(phase.name) &&
!this.isAppealsResponsePhaseName(phase.name),
)
: result.next.phases;

if (!phasesToOpen.length) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 performance]
The check if (!phasesToOpen.length) is performed after filtering result.next.phases. If result.next.phases is empty, the filter operation is unnecessary. Consider checking the length of result.next.phases before filtering to avoid unnecessary operations.

this.logger.log(
`[PHASE CHAIN] All successor appeals phases already opened for challenge ${data.challengeId}; skipping additional phase chaining.`,
);
return;
}

try {
if (this.phaseChainCallback) {
this.logger.log(
`[PHASE CHAIN] Triggering phase chain callback for challenge ${data.challengeId} with ${result.next.phases.length} next phases`,
`[PHASE CHAIN] Triggering phase chain callback for challenge ${data.challengeId} with ${phasesToOpen.length} next phases`,
);
const callbackResult = this.phaseChainCallback(
data.challengeId,
data.projectId,
data.projectStatus,
result.next.phases,
phasesToOpen,
);

// Handle both sync and async callbacks
Expand Down
Loading