Skip to content

Commit 80170e9

Browse files
committed
Review guard when changing scorecards, and make sure we don't wipe terms in a PUT / PATCH
1 parent a215324 commit 80170e9

File tree

2 files changed

+154
-83
lines changed

2 files changed

+154
-83
lines changed

src/services/ChallengeService.js

Lines changed: 104 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -2064,6 +2064,9 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) {
20642064
const originalChallengePhases = _.cloneDeep(challenge.phases || []);
20652065
const auditUserId = _.toString(currentUser.userId);
20662066
const existingPrizeType = challengeHelper.validatePrizeSetsAndGetPrizeType(challenge.prizeSets);
2067+
const payloadIncludesTerms =
2068+
!_.isNil(data) && Object.prototype.hasOwnProperty.call(data, "terms");
2069+
const originalTermsValue = payloadIncludesTerms ? data.terms : undefined;
20672070

20682071
// No conversion needed - values are already in dollars in the database
20692072

@@ -2111,6 +2114,9 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) {
21112114

21122115
// Remove fields from data that are not allowed to be updated and that match the existing challenge
21132116
data = sanitizeData(sanitizeChallenge(data), challenge);
2117+
const sanitizedIncludesTerms = Object.prototype.hasOwnProperty.call(data, "terms");
2118+
const shouldReplaceTerms =
2119+
sanitizedIncludesTerms || (payloadIncludesTerms && originalTermsValue === null);
21142120
logger.debug(`Sanitized Data: ${JSON.stringify(data)}`);
21152121

21162122
logger.debug(`updateChallenge(${challengeId}): fetching challenge resources`);
@@ -2639,7 +2645,7 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) {
26392645
if (_.isNil(updateData.attachment)) {
26402646
await tx.attachment.deleteMany({ where: { challengeId } });
26412647
}
2642-
if (_.isNil(updateData.terms)) {
2648+
if (shouldReplaceTerms) {
26432649
await tx.challengeTerm.deleteMany({ where: { challengeId } });
26442650
}
26452651
// if (_.isNil(updateData.skills)) {
@@ -2987,13 +2993,6 @@ async function ensureScorecardChangeDoesNotConflict({
29872993
updatedReviewers = [],
29882994
originalChallengePhases = [],
29892995
}) {
2990-
if (!config.REVIEW_DB_URL) {
2991-
logger.debug(
2992-
`Skipping scorecard change guard for challenge ${challengeId} because REVIEW_DB_URL is not configured`
2993-
);
2994-
return;
2995-
}
2996-
29972996
if (!Array.isArray(originalReviewers) || originalReviewers.length === 0) {
29982997
return;
29992998
}
@@ -3077,15 +3076,31 @@ async function ensureScorecardChangeDoesNotConflict({
30773076
phaseIdToChallengePhaseIds.get(phaseKey).add(String(phase.id));
30783077
}
30793078

3080-
const reviewClient = getReviewClient();
3081-
const hasReviewModel =
3082-
reviewClient &&
3083-
typeof reviewClient.review === "object" &&
3084-
typeof reviewClient.review?.count === "function";
3085-
3086-
if (!hasReviewModel) {
3079+
if (!config.REVIEW_DB_URL) {
30873080
logger.debug(
3088-
`Prisma review model is unavailable; falling back to raw query guard for challenge ${challengeId}`
3081+
`Skipping scorecard change guard for challenge ${challengeId} because REVIEW_DB_URL is not configured`
3082+
);
3083+
return;
3084+
}
3085+
3086+
let reviewClient;
3087+
try {
3088+
reviewClient = getReviewClient();
3089+
} catch (error) {
3090+
logger.warn(
3091+
`Unable to initialize review Prisma client for challenge ${challengeId}: ${error.message}`
3092+
);
3093+
throw new errors.ServiceUnavailableError(
3094+
"Cannot change the scorecard because review status could not be verified. Please try again later."
3095+
);
3096+
}
3097+
3098+
if (!reviewClient || typeof reviewClient.$queryRaw !== "function") {
3099+
logger.warn(
3100+
`Prisma review client does not support raw queries for challenge ${challengeId}`
3101+
);
3102+
throw new errors.ServiceUnavailableError(
3103+
"Cannot change the scorecard because review status could not be verified. Please try again later."
30893104
);
30903105
}
30913106

@@ -3113,6 +3128,73 @@ async function ensureScorecardChangeDoesNotConflict({
31133128
openReviewPhaseNameByKey.has(phaseKey)
31143129
);
31153130

3131+
const reviewPhaseKeysSet = new Set(removedScorecardsByPhase.keys());
3132+
const challengePhaseIdsToInspect = new Set();
3133+
for (const phaseKey of reviewPhaseKeysSet) {
3134+
const challengePhaseIds = phaseIdToChallengePhaseIds.get(phaseKey);
3135+
if (!challengePhaseIds) {
3136+
continue;
3137+
}
3138+
for (const challengePhaseId of challengePhaseIds) {
3139+
if (!_.isNil(challengePhaseId)) {
3140+
challengePhaseIdsToInspect.add(String(challengePhaseId));
3141+
}
3142+
}
3143+
}
3144+
3145+
const blockingCountByPhase = new Map();
3146+
const blockingCountByPhaseAndScorecard = new Map();
3147+
3148+
const challengePhaseIdList = Array.from(challengePhaseIdsToInspect);
3149+
if (challengePhaseIdList.length > 0) {
3150+
const reviewSchema = String(config.REVIEW_DB_SCHEMA || "").trim();
3151+
const reviewTableIdentifier = Prisma.raw(
3152+
reviewSchema
3153+
? `"${reviewSchema.replace(/"/g, '""')}"."review"`
3154+
: '"review"'
3155+
);
3156+
3157+
let blockingReviewRows = [];
3158+
try {
3159+
blockingReviewRows = await reviewClient.$queryRaw`
3160+
SELECT "phaseId", "scorecardId", COUNT(*)::int AS "count"
3161+
FROM ${reviewTableIdentifier}
3162+
WHERE "phaseId" IN (${Prisma.join(challengePhaseIdList)})
3163+
AND "status" IN (${Prisma.join(REVIEW_STATUS_BLOCKING)})
3164+
GROUP BY "phaseId", "scorecardId"
3165+
`;
3166+
} catch (error) {
3167+
logger.warn(
3168+
`Failed to query the review database for challenge ${challengeId}: ${error.message}`
3169+
);
3170+
throw new errors.ServiceUnavailableError(
3171+
"Cannot change the scorecard because review status could not be verified. Please try again later."
3172+
);
3173+
}
3174+
3175+
for (const row of blockingReviewRows || []) {
3176+
const phaseId = _.isNil(row?.phaseId) ? null : String(row.phaseId);
3177+
if (!phaseId) {
3178+
continue;
3179+
}
3180+
const countValue = Number(_.get(row, "count", 0));
3181+
if (!Number.isFinite(countValue) || countValue <= 0) {
3182+
continue;
3183+
}
3184+
3185+
blockingCountByPhase.set(phaseId, (blockingCountByPhase.get(phaseId) || 0) + countValue);
3186+
3187+
const scorecardId = _.isNil(row?.scorecardId) ? null : String(row.scorecardId);
3188+
if (scorecardId) {
3189+
const scorecardKey = `${phaseId}|${scorecardId}`;
3190+
blockingCountByPhaseAndScorecard.set(
3191+
scorecardKey,
3192+
(blockingCountByPhaseAndScorecard.get(scorecardKey) || 0) + countValue
3193+
);
3194+
}
3195+
}
3196+
}
3197+
31163198
if (reviewPhaseKeysToCheck.length > 0) {
31173199
for (const phaseKey of reviewPhaseKeysToCheck) {
31183200
const challengePhaseIds = Array.from(phaseIdToChallengePhaseIds.get(phaseKey) || []);
@@ -3124,38 +3206,8 @@ async function ensureScorecardChangeDoesNotConflict({
31243206
}
31253207

31263208
let activePhaseReviewCount = 0;
3127-
3128-
if (hasReviewModel) {
3129-
activePhaseReviewCount = await reviewClient.review.count({
3130-
where: {
3131-
phaseId: { in: challengePhaseIds },
3132-
status: { in: REVIEW_STATUS_BLOCKING },
3133-
},
3134-
});
3135-
} else if (typeof reviewClient?.$queryRaw === "function") {
3136-
try {
3137-
const queryResult = await reviewClient.$queryRaw`
3138-
SELECT COUNT(*)::int AS count
3139-
FROM "review"
3140-
WHERE "phaseId" IN (${Prisma.join(challengePhaseIds)})
3141-
AND "status" IN (${Prisma.join(REVIEW_STATUS_BLOCKING)})
3142-
`;
3143-
const rawCount =
3144-
Array.isArray(queryResult) && queryResult.length > 0
3145-
? Number(queryResult[0]?.count || 0)
3146-
: 0;
3147-
activePhaseReviewCount = Number.isFinite(rawCount) ? rawCount : 0;
3148-
} catch (error) {
3149-
logger.warn(
3150-
`Skipping active phase scorecard guard for challenge ${challengeId} phase ${phaseKey} due to review DB query failure: ${error.message}`
3151-
);
3152-
continue;
3153-
}
3154-
} else {
3155-
logger.warn(
3156-
`Skipping active phase scorecard guard for challenge ${challengeId} phase ${phaseKey} because review Prisma client does not support raw queries`
3157-
);
3158-
continue;
3209+
for (const challengePhaseId of challengePhaseIds) {
3210+
activePhaseReviewCount += blockingCountByPhase.get(challengePhaseId) || 0;
31593211
}
31603212

31613213
if (activePhaseReviewCount > 0) {
@@ -3182,42 +3234,11 @@ async function ensureScorecardChangeDoesNotConflict({
31823234
continue;
31833235
}
31843236

3237+
const normalizedScorecardId = String(scorecardId);
31853238
let conflictingReviews = 0;
3186-
3187-
if (hasReviewModel) {
3188-
conflictingReviews = await reviewClient.review.count({
3189-
where: {
3190-
phaseId: { in: challengePhaseIds },
3191-
scorecardId,
3192-
status: { in: REVIEW_STATUS_BLOCKING },
3193-
},
3194-
});
3195-
} else if (typeof reviewClient?.$queryRaw === "function") {
3196-
try {
3197-
const queryResult = await reviewClient.$queryRaw`
3198-
SELECT COUNT(*)::int AS count
3199-
FROM "review"
3200-
WHERE "phaseId" IN (${Prisma.join(challengePhaseIds)})
3201-
AND "scorecardId" = ${scorecardId}
3202-
AND "status" IN (${Prisma.join(REVIEW_STATUS_BLOCKING)})
3203-
`;
3204-
3205-
const rawCount =
3206-
Array.isArray(queryResult) && queryResult.length > 0
3207-
? Number(queryResult[0]?.count || 0)
3208-
: 0;
3209-
conflictingReviews = Number.isFinite(rawCount) ? rawCount : 0;
3210-
} catch (error) {
3211-
logger.warn(
3212-
`Skipping scorecard change guard for challenge ${challengeId} phase ${phaseKey} due to review DB query failure: ${error.message}`
3213-
);
3214-
continue;
3215-
}
3216-
} else {
3217-
logger.warn(
3218-
`Skipping scorecard change guard for challenge ${challengeId} phase ${phaseKey} because review Prisma client does not support raw queries`
3219-
);
3220-
continue;
3239+
for (const challengePhaseId of challengePhaseIds) {
3240+
const scorecardKey = `${challengePhaseId}|${normalizedScorecardId}`;
3241+
conflictingReviews += blockingCountByPhaseAndScorecard.get(scorecardKey) || 0;
32213242
}
32223243

32233244
if (conflictingReviews > 0) {

test/unit/ChallengeService.test.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,56 @@ describe('challenge service unit tests', () => {
737737
should.equal(testHelper.getDatesDiff(result.startDate, testChallengeData.startDate), 0)
738738
})
739739

740+
it('preserves existing terms when update payload omits the terms field', async () => {
741+
const challengeData = _.cloneDeep(testChallengeData)
742+
challengeData.name = `${challengeData.name} Terms ${Date.now()}`
743+
challengeData.legacyId = Math.floor(Math.random() * 1000000)
744+
const challengeWithTerms = await service.createChallenge(
745+
{ isMachine: true, sub: 'sub-terms', userId: 22838965 },
746+
challengeData,
747+
config.M2M_FULL_ACCESS_TOKEN
748+
)
749+
750+
const termRecords = [
751+
{
752+
challengeId: challengeWithTerms.id,
753+
termId: uuid(),
754+
roleId: uuid(),
755+
createdBy: 'unit-test',
756+
updatedBy: 'unit-test'
757+
},
758+
{
759+
challengeId: challengeWithTerms.id,
760+
termId: uuid(),
761+
roleId: uuid(),
762+
createdBy: 'unit-test',
763+
updatedBy: 'unit-test'
764+
}
765+
]
766+
await prisma.challengeTerm.createMany({ data: termRecords })
767+
768+
try {
769+
const updated = await service.updateChallenge(
770+
{ isMachine: true, sub: 'sub-terms', userId: 22838965 },
771+
challengeWithTerms.id,
772+
{
773+
description: 'Updated description to ensure persistence of terms'
774+
}
775+
)
776+
777+
should.exist(updated.terms)
778+
should.equal(updated.terms.length, termRecords.length)
779+
const sortedTerms = _.sortBy(updated.terms, ['id', 'roleId'])
780+
const expectedTerms = _.sortBy(
781+
termRecords.map((t) => ({ id: t.termId, roleId: t.roleId })),
782+
['id', 'roleId']
783+
)
784+
sortedTerms.should.deep.equal(expectedTerms)
785+
} finally {
786+
await prisma.challenge.delete({ where: { id: challengeWithTerms.id } })
787+
}
788+
}).timeout(5000)
789+
740790
it('update challenge successfully with winners', async () => {
741791
const result = await service.updateChallenge({ isMachine: true, sub: 'sub3', userId: 22838965 }, data.challenge.id, {
742792
winners: [{

0 commit comments

Comments
 (0)