Skip to content

Commit 20261d4

Browse files
committed
Block reopen submissions and changing review scorecard ID if there are already reviews being worked on
1 parent f31c7d4 commit 20261d4

File tree

4 files changed

+298
-39
lines changed

4 files changed

+298
-39
lines changed

src/services/ChallengePhaseService.js

Lines changed: 58 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ const { indexChallengeAndPostToKafka } = require("./ChallengeService");
1616
const { getClient } = require("../common/prisma");
1717
const prisma = getClient();
1818
const PENDING_REVIEW_STATUSES = Object.freeze(["PENDING", "IN_PROGRESS", "DRAFT", "SUBMITTED"]);
19+
const REVIEW_PHASE_NAMES = Object.freeze([
20+
"checkpoint review",
21+
"checkpoint screening",
22+
"screening",
23+
"review",
24+
"approval",
25+
]);
26+
const REVIEW_PHASE_NAME_SET = new Set(REVIEW_PHASE_NAMES.map((name) => name.toLowerCase()));
1927

2028
async function hasPendingScorecardsForPhase(challengePhaseId) {
2129
if (!config.REVIEW_DB_URL) {
@@ -370,6 +378,31 @@ async function partiallyUpdateChallengePhase(currentUser, challengeId, id, data)
370378
}
371379
if (isReopeningPhase) {
372380
const phaseName = challengePhase.name;
381+
const openPhases = await prisma.challengePhase.findMany({
382+
where: {
383+
challengeId: challengePhase.challengeId,
384+
isOpen: true,
385+
},
386+
select: {
387+
id: true,
388+
phaseId: true,
389+
predecessor: true,
390+
name: true,
391+
},
392+
});
393+
const activeReviewPhase = openPhases.find((phase) =>
394+
REVIEW_PHASE_NAME_SET.has(String(phase?.name || "").toLowerCase())
395+
);
396+
if (activeReviewPhase) {
397+
const hasActiveScorecards = await hasCompletedReviewsForPhase(
398+
activeReviewPhase.id
399+
);
400+
if (hasActiveScorecards) {
401+
throw new errors.BadRequestError(
402+
`Cannot reopen ${phaseName} because the currently open phase '${activeReviewPhase.name}' has reviews in progress or completed`
403+
);
404+
}
405+
}
373406
if (phaseName === "Submission" || phaseName === "Registration") {
374407
const hasCompletedReviews = await hasCompletedReviewsForPhase(challengePhase.id);
375408
if (hasCompletedReviews) {
@@ -401,50 +434,36 @@ async function partiallyUpdateChallengePhase(currentUser, challengeId, id, data)
401434
const hasActualStartDate = !_.isNil(challengePhase.actualStartDate);
402435
const hasActualEndDate = !_.isNil(challengePhase.actualEndDate);
403436

404-
if (hasActualStartDate && hasActualEndDate) {
405-
const openPhases = await prisma.challengePhase.findMany({
406-
where: {
407-
challengeId: challengePhase.challengeId,
408-
isOpen: true,
409-
},
410-
select: {
411-
id: true,
412-
phaseId: true,
413-
predecessor: true,
414-
name: true,
415-
},
416-
});
437+
if (hasActualStartDate && hasActualEndDate && openPhases.length > 0) {
438+
const reopenedPhaseIdentifiers = new Set([String(challengePhase.id)]);
439+
if (!_.isNil(challengePhase.phaseId)) {
440+
reopenedPhaseIdentifiers.add(String(challengePhase.phaseId));
441+
}
417442

418-
if (openPhases.length > 0) {
419-
const reopenedPhaseIdentifiers = new Set([String(challengePhase.id)]);
420-
if (!_.isNil(challengePhase.phaseId)) {
421-
reopenedPhaseIdentifiers.add(String(challengePhase.phaseId));
422-
}
423-
const dependentOpenPhases = openPhases.filter((phase) => {
424-
if (!phase || _.isNil(phase.predecessor)) {
425-
return false;
426-
}
427-
return reopenedPhaseIdentifiers.has(String(phase.predecessor));
428-
});
429-
430-
if (dependentOpenPhases.length === 0) {
431-
throw new errors.ForbiddenError(
432-
`Cannot reopen ${phaseName} because no currently open phase depends on it`,
433-
);
443+
const dependentOpenPhases = openPhases.filter((phase) => {
444+
if (!phase || _.isNil(phase.predecessor)) {
445+
return false;
434446
}
447+
return reopenedPhaseIdentifiers.has(String(phase.predecessor));
448+
});
435449

436-
const appealsDependentPhaseExists = dependentOpenPhases.some(
437-
(phase) => String(phase.name || "").toLowerCase() === "appeals"
450+
if (dependentOpenPhases.length === 0) {
451+
throw new errors.ForbiddenError(
452+
`Cannot reopen ${phaseName} because no currently open phase depends on it`
453+
);
454+
}
455+
456+
const appealsDependentPhaseExists = dependentOpenPhases.some(
457+
(phase) => String(phase.name || "").toLowerCase() === "appeals"
458+
);
459+
if (appealsDependentPhaseExists) {
460+
const hasSubmittedAppeals = await hasSubmittedAppealsForChallenge(
461+
challengePhase.challengeId
438462
);
439-
if (appealsDependentPhaseExists) {
440-
const hasSubmittedAppeals = await hasSubmittedAppealsForChallenge(
441-
challengePhase.challengeId
463+
if (hasSubmittedAppeals) {
464+
throw new errors.ForbiddenError(
465+
`Cannot reopen ${phaseName} because submitted appeals already exist in the Appeals phase`
442466
);
443-
if (hasSubmittedAppeals) {
444-
throw new errors.ForbiddenError(
445-
`Cannot reopen ${phaseName} because submitted appeals already exist in the Appeals phase`
446-
);
447-
}
448467
}
449468
}
450469
}

src/services/ChallengeService.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ const challengeDomain = {
6565
const phaseAdvancer = new PhaseAdvancer(challengeDomain);
6666

6767
const REVIEW_STATUS_BLOCKING = Object.freeze(["IN_PROGRESS", "COMPLETED"]);
68+
const REVIEW_PHASE_NAMES = Object.freeze([
69+
"checkpoint review",
70+
"checkpoint screening",
71+
"screening",
72+
"review",
73+
"approval",
74+
]);
75+
const REVIEW_PHASE_NAME_SET = new Set(REVIEW_PHASE_NAMES);
6876

6977
/**
7078
* Enrich skills data with full details from standardized skills API.
@@ -3080,6 +3088,84 @@ async function ensureScorecardChangeDoesNotConflict({
30803088
);
30813089
}
30823090

3091+
const openReviewPhaseNameByKey = new Map();
3092+
for (const phase of originalChallengePhases || []) {
3093+
if (!phase || !phase.isOpen) {
3094+
continue;
3095+
}
3096+
const phaseName = String(phase.name || "").trim();
3097+
if (!phaseName) {
3098+
continue;
3099+
}
3100+
const normalizedName = phaseName.toLowerCase();
3101+
if (!REVIEW_PHASE_NAME_SET.has(normalizedName)) {
3102+
continue;
3103+
}
3104+
const phaseKey = phase.phaseId ? String(phase.phaseId) : null;
3105+
if (!phaseKey) {
3106+
continue;
3107+
}
3108+
openReviewPhaseNameByKey.set(phaseKey, phaseName);
3109+
}
3110+
3111+
const reviewPhaseKeysToCheck = Array.from(removedScorecardsByPhase.keys()).filter((phaseKey) =>
3112+
openReviewPhaseNameByKey.has(phaseKey)
3113+
);
3114+
3115+
if (reviewPhaseKeysToCheck.length > 0) {
3116+
for (const phaseKey of reviewPhaseKeysToCheck) {
3117+
const challengePhaseIds = Array.from(phaseIdToChallengePhaseIds.get(phaseKey) || []);
3118+
if (challengePhaseIds.length === 0) {
3119+
logger.debug(
3120+
`Skipping active phase scorecard guard for challenge ${challengeId} phase ${phaseKey} because no matching challenge phases were found`
3121+
);
3122+
continue;
3123+
}
3124+
3125+
let activePhaseReviewCount = 0;
3126+
3127+
if (hasReviewModel) {
3128+
activePhaseReviewCount = await reviewClient.review.count({
3129+
where: {
3130+
phaseId: { in: challengePhaseIds },
3131+
status: { in: REVIEW_STATUS_BLOCKING },
3132+
},
3133+
});
3134+
} else if (typeof reviewClient?.$queryRaw === "function") {
3135+
try {
3136+
const queryResult = await reviewClient.$queryRaw`
3137+
SELECT COUNT(*)::int AS count
3138+
FROM "review"
3139+
WHERE "phaseId" IN (${Prisma.join(challengePhaseIds)})
3140+
AND "status" IN (${Prisma.join(REVIEW_STATUS_BLOCKING)})
3141+
`;
3142+
const rawCount =
3143+
Array.isArray(queryResult) && queryResult.length > 0
3144+
? Number(queryResult[0]?.count || 0)
3145+
: 0;
3146+
activePhaseReviewCount = Number.isFinite(rawCount) ? rawCount : 0;
3147+
} catch (error) {
3148+
logger.warn(
3149+
`Skipping active phase scorecard guard for challenge ${challengeId} phase ${phaseKey} due to review DB query failure: ${error.message}`
3150+
);
3151+
continue;
3152+
}
3153+
} else {
3154+
logger.warn(
3155+
`Skipping active phase scorecard guard for challenge ${challengeId} phase ${phaseKey} because review Prisma client does not support raw queries`
3156+
);
3157+
continue;
3158+
}
3159+
3160+
if (activePhaseReviewCount > 0) {
3161+
const phaseName = openReviewPhaseNameByKey.get(phaseKey) || "phase";
3162+
throw new BadRequestError(
3163+
`Cannot change the scorecard for phase '${phaseName}' because reviews are already in progress or completed`
3164+
);
3165+
}
3166+
}
3167+
}
3168+
30833169
for (const [phaseKey, scorecardIds] of removedScorecardsByPhase.entries()) {
30843170
const challengePhaseIds = Array.from(phaseIdToChallengePhaseIds.get(phaseKey) || []);
30853171

test/unit/ChallengePhaseService.test.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,82 @@ describe('challenge phase service unit tests', () => {
366366
throw new Error('should not reach here')
367367
})
368368

369+
it('partially update challenge phase - cannot reopen when review phase has active scorecards', async () => {
370+
const reviewPhase = await prisma.phase.create({
371+
data: {
372+
id: uuid(),
373+
name: 'Review',
374+
description: 'desc',
375+
isOpen: true,
376+
duration: 86400,
377+
createdBy: 'admin',
378+
updatedBy: 'admin'
379+
}
380+
})
381+
382+
const reviewChallengePhaseId = uuid()
383+
const registrationStart = new Date('2025-08-01T00:00:00.000Z')
384+
const registrationEnd = new Date('2025-08-02T00:00:00.000Z')
385+
386+
await prisma.challengePhase.update({
387+
where: { id: data.challengePhase1Id },
388+
data: {
389+
isOpen: false,
390+
actualStartDate: registrationStart,
391+
actualEndDate: registrationEnd
392+
}
393+
})
394+
395+
await prisma.challengePhase.create({
396+
data: {
397+
id: reviewChallengePhaseId,
398+
challengeId: data.challenge.id,
399+
phaseId: reviewPhase.id,
400+
name: 'Review',
401+
isOpen: true,
402+
predecessor: data.challengePhase1Id,
403+
actualStartDate: new Date('2025-08-02T06:00:00.000Z'),
404+
createdBy: 'admin',
405+
updatedBy: 'admin'
406+
}
407+
})
408+
409+
const reviewId = uuid()
410+
await reviewClient.$executeRaw(
411+
Prisma.sql`
412+
INSERT INTO ${reviewTable} ("id", "phaseId", "status")
413+
VALUES (${reviewId}, ${reviewChallengePhaseId}, ${'IN_PROGRESS'})
414+
`
415+
)
416+
417+
try {
418+
await service.partiallyUpdateChallengePhase(authUser, data.challenge.id, data.challengePhase1Id, {
419+
isOpen: true
420+
})
421+
} catch (e) {
422+
should.equal(e.httpStatus || e.statusCode, 400)
423+
should.equal(
424+
e.message,
425+
"Cannot reopen Registration because the currently open phase 'Review' has reviews in progress or completed"
426+
)
427+
return
428+
} finally {
429+
await reviewClient.$executeRaw(Prisma.sql`DELETE FROM ${reviewTable} WHERE "id" = ${reviewId}`)
430+
await prisma.challengePhase.delete({ where: { id: reviewChallengePhaseId } })
431+
await prisma.phase.delete({ where: { id: reviewPhase.id } })
432+
await prisma.challengePhase.update({
433+
where: { id: data.challengePhase1Id },
434+
data: {
435+
isOpen: false,
436+
actualStartDate: null,
437+
actualEndDate: null
438+
}
439+
})
440+
}
441+
442+
throw new Error('should not reach here')
443+
})
444+
369445
it('partially update challenge phase - cannot reopen predecessor when appeals have submitted appeals', async () => {
370446
const reviewPhase = await prisma.phase.create({
371447
data: {

test/unit/ChallengeService.test.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -896,6 +896,84 @@ describe('challenge service unit tests', () => {
896896

897897
throw new Error('should not reach here')
898898
})
899+
900+
it('blocks scorecard change when an active review phase has started reviews', async () => {
901+
if (!reviewClient) {
902+
return
903+
}
904+
905+
let reviewPhase
906+
let reviewChallengePhaseId
907+
908+
const insertedReviewId = uuid()
909+
910+
try {
911+
reviewPhase = await prisma.phase.create({
912+
data: {
913+
id: uuid(),
914+
name: 'Review',
915+
description: 'desc',
916+
isOpen: true,
917+
duration: 86400,
918+
createdBy: 'admin',
919+
updatedBy: 'admin'
920+
}
921+
})
922+
923+
reviewChallengePhaseId = uuid()
924+
const now = new Date()
925+
926+
await prisma.challengePhase.create({
927+
data: {
928+
id: reviewChallengePhaseId,
929+
challengeId: data.challenge.id,
930+
phaseId: reviewPhase.id,
931+
name: 'Review',
932+
isOpen: true,
933+
actualStartDate: now,
934+
createdBy: 'admin',
935+
updatedBy: 'admin'
936+
}
937+
})
938+
939+
await prisma.challengeReviewer.updateMany({
940+
where: { challengeId: data.challenge.id },
941+
data: { phaseId: reviewPhase.id }
942+
})
943+
944+
await reviewClient.$executeRawUnsafe(
945+
`INSERT INTO ${reviewTableName} ("id", "phaseId", "scorecardId", "status") VALUES ('${insertedReviewId}', '${reviewChallengePhaseId}', '${originalScorecardId}', 'COMPLETED')`
946+
)
947+
948+
await service.updateChallenge({ isMachine: true, sub: 'sub3', userId: 22838965 }, data.challenge.id, {
949+
reviewers: [
950+
{
951+
phaseId: reviewPhase.id,
952+
scorecardId: newScorecardId,
953+
isMemberReview: false
954+
}
955+
]
956+
})
957+
} catch (e) {
958+
should.equal(
959+
e.message,
960+
"Cannot change the scorecard for phase 'Review' because reviews are already in progress or completed"
961+
)
962+
return
963+
} finally {
964+
await reviewClient.$executeRawUnsafe(
965+
`DELETE FROM ${reviewTableName} WHERE "id" = '${insertedReviewId}'`
966+
)
967+
if (reviewChallengePhaseId) {
968+
await prisma.challengePhase.delete({ where: { id: reviewChallengePhaseId } })
969+
}
970+
if (reviewPhase) {
971+
await prisma.phase.delete({ where: { id: reviewPhase.id } })
972+
}
973+
}
974+
975+
throw new Error('should not reach here')
976+
})
899977
})
900978

901979
it('update challenge - creator memberId can modify without matching handle', async () => {

0 commit comments

Comments
 (0)