Skip to content

Commit 19eb10d

Browse files
authored
Merge pull request #55 from topcoder-platform/reviewer_checks
Additional checks for reviewer setup before allowing activation (PM-3…
2 parents e8fe599 + f0a1e54 commit 19eb10d

File tree

3 files changed

+302
-3
lines changed

3 files changed

+302
-3
lines changed

config/default.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ module.exports = {
4949
// TODO: change this to localhost
5050
RESOURCE_ROLES_API_URL:
5151
process.env.RESOURCE_ROLES_API_URL || "http://api.topcoder-dev.com/v5/resource-roles",
52-
GROUPS_API_URL: process.env.GROUPS_API_URL || "http://localhost:4000/v5/groups",
52+
GROUPS_API_URL: process.env.GROUPS_API_URL || "http://localhost:4000/v6/groups",
5353
PROJECTS_API_URL: process.env.PROJECTS_API_URL || "http://localhost:4000/v5/projects",
5454
TERMS_API_URL: process.env.TERMS_API_URL || "http://localhost:4000/v5/terms",
5555
CUSTOMER_PAYMENTS_URL:

src/services/ChallengeService.js

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ const REVIEW_PHASE_NAMES = Object.freeze([
7373
"approval",
7474
]);
7575
const REVIEW_PHASE_NAME_SET = new Set(REVIEW_PHASE_NAMES);
76+
const REQUIRED_REVIEW_PHASE_NAME_SET = new Set([...REVIEW_PHASE_NAMES, "iterative review"]);
7677

7778
/**
7879
* Enrich skills data with full details from standardized skills API.
@@ -2356,6 +2357,8 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) {
23562357
validateTask(currentUser, challenge, data, challengeResources);
23572358
const taskCompletionInfo = prepareTaskCompletionData(challenge, challengeResources, data);
23582359

2360+
const isStatusChangingToActive =
2361+
data.status === ChallengeStatusEnum.ACTIVE && challenge.status !== ChallengeStatusEnum.ACTIVE;
23592362
let sendActivationEmail = false;
23602363
let sendSubmittedEmail = false;
23612364
let sendCompletedEmail = false;
@@ -2459,8 +2462,6 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) {
24592462

24602463
/* END self-service stuffs */
24612464

2462-
const isStatusChangingToActive =
2463-
data.status === ChallengeStatusEnum.ACTIVE && challenge.status !== ChallengeStatusEnum.ACTIVE;
24642465
let isChallengeBeingActivated = isStatusChangingToActive;
24652466
let isChallengeBeingCancelled = false;
24662467
if (data.status) {
@@ -2806,6 +2807,57 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) {
28062807
});
28072808
}
28082809

2810+
if (
2811+
isStatusChangingToActive &&
2812+
(challenge.status === ChallengeStatusEnum.NEW || challenge.status === ChallengeStatusEnum.DRAFT)
2813+
) {
2814+
const effectiveReviewers = Array.isArray(data.reviewers)
2815+
? data.reviewers
2816+
: Array.isArray(challenge.reviewers)
2817+
? challenge.reviewers
2818+
: [];
2819+
const reviewerPhaseIds = new Set(
2820+
effectiveReviewers
2821+
.filter((reviewer) => reviewer && reviewer.phaseId)
2822+
.map((reviewer) => String(reviewer.phaseId))
2823+
);
2824+
2825+
if (reviewerPhaseIds.size === 0) {
2826+
throw new errors.BadRequestError(
2827+
"Cannot activate a challenge without at least one reviewer configured"
2828+
);
2829+
}
2830+
2831+
const normalizePhaseName = (name) => String(name || "").trim().toLowerCase();
2832+
const effectivePhases =
2833+
(Array.isArray(phasesForUpdate) && phasesForUpdate.length > 0
2834+
? phasesForUpdate
2835+
: challenge.phases) || [];
2836+
2837+
const missingPhaseNames = new Set();
2838+
for (const phase of effectivePhases) {
2839+
if (!phase) {
2840+
continue;
2841+
}
2842+
const normalizedName = normalizePhaseName(phase.name);
2843+
if (!REQUIRED_REVIEW_PHASE_NAME_SET.has(normalizedName)) {
2844+
continue;
2845+
}
2846+
const phaseId = _.get(phase, "phaseId");
2847+
if (!phaseId || !reviewerPhaseIds.has(String(phaseId))) {
2848+
missingPhaseNames.add(phase.name || "Unknown phase");
2849+
}
2850+
}
2851+
2852+
if (missingPhaseNames.size > 0) {
2853+
throw new errors.BadRequestError(
2854+
`Cannot activate challenge; missing reviewers for phase(s): ${Array.from(
2855+
missingPhaseNames
2856+
).join(", ")}`
2857+
);
2858+
}
2859+
}
2860+
28092861
// convert data to prisma models
28102862
const updateData = prismaHelper.convertChallengeSchemaToPrisma(
28112863
currentUser,

test/unit/ChallengeService.test.js

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,152 @@ describe('challenge service unit tests', () => {
672672
})
673673

674674
describe('update challenge tests', () => {
675+
const ensureSkipTimelineTemplate = async () => {
676+
const templateId = config.SKIP_PROJECT_ID_BY_TIMLINE_TEMPLATE_ID
677+
let template = await prisma.timelineTemplate.findUnique({ where: { id: templateId } })
678+
if (!template) {
679+
template = await prisma.timelineTemplate.create({
680+
data: {
681+
id: templateId,
682+
name: `skip-template-${templateId}`,
683+
description: 'Template used to bypass project requirements in activation tests',
684+
isActive: true,
685+
createdBy: 'activation-test',
686+
updatedBy: 'activation-test'
687+
}
688+
})
689+
}
690+
691+
const existingPhases = await prisma.timelineTemplatePhase.findMany({
692+
where: { timelineTemplateId: templateId }
693+
})
694+
const existingPhaseIds = new Set(existingPhases.map(p => p.phaseId))
695+
const phaseRows = []
696+
if (!existingPhaseIds.has(data.phase.id)) {
697+
phaseRows.push({
698+
timelineTemplateId: templateId,
699+
phaseId: data.phase.id,
700+
defaultDuration: 1000,
701+
createdBy: 'activation-test',
702+
updatedBy: 'activation-test'
703+
})
704+
}
705+
if (!existingPhaseIds.has(data.phase2.id)) {
706+
phaseRows.push({
707+
timelineTemplateId: templateId,
708+
phaseId: data.phase2.id,
709+
predecessor: data.phase.id,
710+
defaultDuration: 1000,
711+
createdBy: 'activation-test',
712+
updatedBy: 'activation-test'
713+
})
714+
}
715+
if (phaseRows.length > 0) {
716+
await prisma.timelineTemplatePhase.createMany({ data: phaseRows })
717+
}
718+
return templateId
719+
}
720+
721+
const createChallengeWithRequiredReviewPhases = async () => {
722+
await ensureSkipTimelineTemplate()
723+
724+
const phaseNames = ['Screening', 'Review']
725+
const createdPhaseIds = []
726+
const phaseRecords = []
727+
728+
for (const phaseName of phaseNames) {
729+
let phaseRecord = await prisma.phase.findFirst({ where: { name: phaseName } })
730+
if (!phaseRecord) {
731+
phaseRecord = await prisma.phase.create({
732+
data: {
733+
id: uuid(),
734+
name: phaseName,
735+
description: `${phaseName} phase`,
736+
isOpen: false,
737+
duration: 86400,
738+
createdBy: 'activation-test',
739+
updatedBy: 'activation-test'
740+
}
741+
})
742+
createdPhaseIds.push(phaseRecord.id)
743+
}
744+
phaseRecords.push(phaseRecord)
745+
}
746+
747+
const challenge = await prisma.challenge.create({
748+
data: {
749+
id: uuid(),
750+
name: `Activation coverage ${Date.now()}`,
751+
description: 'Activation coverage test',
752+
typeId: data.challenge.typeId,
753+
trackId: data.challenge.trackId,
754+
timelineTemplateId: config.SKIP_PROJECT_ID_BY_TIMLINE_TEMPLATE_ID,
755+
startDate: new Date(),
756+
status: ChallengeStatusEnum.DRAFT,
757+
tags: [],
758+
groups: [],
759+
createdBy: 'activation-test',
760+
updatedBy: 'activation-test'
761+
}
762+
})
763+
764+
const challengePhaseIds = []
765+
await prisma.challengePhase.createMany({
766+
data: phaseRecords.map((phase) => {
767+
const cpId = uuid()
768+
challengePhaseIds.push(cpId)
769+
return {
770+
id: cpId,
771+
challengeId: challenge.id,
772+
phaseId: phase.id,
773+
name: phase.name,
774+
duration: phase.duration || 86400,
775+
isOpen: false,
776+
createdBy: 'activation-test',
777+
updatedBy: 'activation-test'
778+
}
779+
})
780+
})
781+
782+
return { challenge, phaseRecords, createdPhaseIds, challengePhaseIds }
783+
}
784+
785+
const cleanupChallengeWithRequiredReviewPhases = async ({
786+
challenge,
787+
createdPhaseIds = [],
788+
challengePhaseIds = []
789+
}) => {
790+
if (challengePhaseIds.length > 0) {
791+
await prisma.challengePhase.deleteMany({ where: { id: { in: challengePhaseIds } } })
792+
}
793+
if (challenge) {
794+
await prisma.challenge.delete({ where: { id: challenge.id } })
795+
}
796+
if (createdPhaseIds.length > 0) {
797+
await prisma.phase.deleteMany({ where: { id: { in: createdPhaseIds } } })
798+
}
799+
}
800+
801+
const createActivationChallenge = async (status = ChallengeStatusEnum.NEW) => {
802+
const timelineTemplateId = await ensureSkipTimelineTemplate()
803+
return prisma.challenge.create({
804+
data: {
805+
id: uuid(),
806+
name: `Activation reviewer check ${Date.now()}`,
807+
description: 'activation reviewer check',
808+
typeId: data.challenge.typeId,
809+
trackId: data.challenge.trackId,
810+
timelineTemplateId,
811+
startDate: new Date(),
812+
status,
813+
tags: [],
814+
groups: [],
815+
createdBy: 'activation-test',
816+
updatedBy: 'activation-test'
817+
}
818+
})
819+
}
820+
675821
it('update challenge successfully 1', async () => {
676822
const challengeData = testChallengeData
677823
const result = await service.updateChallenge({ isMachine: true, sub: 'sub3', userId: 22838965 }, id, {
@@ -1128,6 +1274,107 @@ describe('challenge service unit tests', () => {
11281274
throw new Error('should not reach here')
11291275
})
11301276

1277+
it('update challenge - prevent activating without reviewers', async () => {
1278+
const activationChallenge = await createActivationChallenge(ChallengeStatusEnum.DRAFT)
1279+
try {
1280+
await service.updateChallenge({ isMachine: true, sub: 'sub-activate' }, activationChallenge.id, {
1281+
status: ChallengeStatusEnum.ACTIVE
1282+
})
1283+
} catch (e) {
1284+
should.equal(e.message.indexOf('reviewer configured') >= 0, true)
1285+
return
1286+
} finally {
1287+
await prisma.challenge.delete({ where: { id: activationChallenge.id } })
1288+
}
1289+
throw new Error('should not reach here')
1290+
})
1291+
1292+
it('update challenge - allow activating with reviewers provided', async () => {
1293+
const activationChallenge = await createActivationChallenge()
1294+
try {
1295+
const updated = await service.updateChallenge(
1296+
{ isMachine: true, sub: 'sub-activate', userId: 22838965 },
1297+
activationChallenge.id,
1298+
{
1299+
status: ChallengeStatusEnum.ACTIVE,
1300+
reviewers: [
1301+
{
1302+
phaseId: data.phase.id,
1303+
scorecardId: 'activation-scorecard',
1304+
isMemberReview: false,
1305+
aiWorkflowId: 'workflow-123'
1306+
}
1307+
]
1308+
}
1309+
)
1310+
should.equal(updated.status, ChallengeStatusEnum.ACTIVE)
1311+
should.exist(updated.reviewers)
1312+
should.equal(updated.reviewers.length, 1)
1313+
should.equal(updated.reviewers[0].scorecardId, 'activation-scorecard')
1314+
} finally {
1315+
await prisma.challenge.delete({ where: { id: activationChallenge.id } })
1316+
}
1317+
})
1318+
1319+
it('update challenge - enforce reviewers for required phases', async () => {
1320+
const setup = await createChallengeWithRequiredReviewPhases()
1321+
try {
1322+
await service.updateChallenge(
1323+
{ isMachine: true, sub: 'sub-activate', userId: 22838965 },
1324+
setup.challenge.id,
1325+
{
1326+
status: ChallengeStatusEnum.ACTIVE,
1327+
reviewers: [
1328+
{
1329+
phaseId: setup.phaseRecords[0].id,
1330+
scorecardId: 'screening-scorecard',
1331+
isMemberReview: false,
1332+
aiWorkflowId: 'workflow-screening'
1333+
}
1334+
]
1335+
}
1336+
)
1337+
} catch (e) {
1338+
should.equal(e.message.indexOf('missing reviewers for phase(s): Review') >= 0, true)
1339+
return
1340+
} finally {
1341+
await cleanupChallengeWithRequiredReviewPhases(setup)
1342+
}
1343+
throw new Error('should not reach here')
1344+
})
1345+
1346+
it('update challenge - allow activation when required phases have reviewers', async () => {
1347+
const setup = await createChallengeWithRequiredReviewPhases()
1348+
try {
1349+
const updated = await service.updateChallenge(
1350+
{ isMachine: true, sub: 'sub-activate', userId: 22838965 },
1351+
setup.challenge.id,
1352+
{
1353+
status: ChallengeStatusEnum.ACTIVE,
1354+
reviewers: [
1355+
{
1356+
phaseId: setup.phaseRecords[0].id,
1357+
scorecardId: 'screening-scorecard',
1358+
isMemberReview: false,
1359+
aiWorkflowId: 'workflow-screening'
1360+
},
1361+
{
1362+
phaseId: setup.phaseRecords[1].id,
1363+
scorecardId: 'review-scorecard',
1364+
isMemberReview: false,
1365+
aiWorkflowId: 'workflow-review'
1366+
}
1367+
]
1368+
}
1369+
)
1370+
should.equal(updated.status, ChallengeStatusEnum.ACTIVE)
1371+
should.exist(updated.reviewers)
1372+
should.equal(updated.reviewers.length, 2)
1373+
} finally {
1374+
await cleanupChallengeWithRequiredReviewPhases(setup)
1375+
}
1376+
})
1377+
11311378
it('update challenge - set winners with non-completed Active status', async () => {
11321379
try {
11331380
await service.updateChallenge({ isMachine: true, sub: 'sub3' }, id, {

0 commit comments

Comments
 (0)