Skip to content

Commit 874ad77

Browse files
committed
Merge branch 'develop' of github.com:topcoder-platform/challenge-api-v6 into PM-4684_challenge-approval-flow
2 parents 172b99b + 0cc6305 commit 874ad77

6 files changed

Lines changed: 277 additions & 11 deletions

File tree

src/phase-management/PhaseAdvancer.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ class PhaseAdvancer {
395395
OR (r."legacySubmissionId" IS NOT NULL AND s."legacySubmissionId" = r."legacySubmissionId")
396396
)
397397
WHERE s."challengeId" = ${challengeId}
398-
AND r."status" = ${"COMPLETED"}
398+
AND r."status"::text = ${"COMPLETED"}
399399
`
400400
);
401401

src/services/ChallengePhaseService.js

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -479,11 +479,14 @@ async function hasPendingEscalationRequestsForChallenge(challengeId) {
479479
/**
480480
* Load a challenge for challenge-scoped phase operations.
481481
* @param {String} challengeId the challenge id
482-
* @returns {Object} the challenge with the given id
482+
* @returns {Object} the challenge with the given id and type metadata
483483
* @throws {NotFoundError} when the challenge does not exist
484484
*/
485485
async function getChallengeForPhaseAccess(challengeId) {
486-
const challenge = await prisma.challenge.findUnique({ where: { id: challengeId } });
486+
const challenge = await prisma.challenge.findUnique({
487+
where: { id: challengeId },
488+
include: { type: true },
489+
});
487490
if (!challenge) {
488491
throw new errors.NotFoundError(`Challenge with id: ${challengeId} doesn't exist`);
489492
}
@@ -511,13 +514,34 @@ async function postChallengeUpdatedNotification(challengeId) {
511514
}
512515
}
513516

514-
async function ensureRequiredResourcesBeforeOpeningPhase(challengeId, phaseName) {
517+
/**
518+
* Check whether a challenge is the Marathon Match challenge type.
519+
* @param {Object} challenge challenge data loaded with its type relation
520+
* @returns {Boolean} true when the challenge type name is Marathon Match
521+
*/
522+
function isMarathonMatchChallengeType(challenge) {
523+
const typeName = _.get(challenge, "type.name");
524+
return _.toLower(_.trim(typeName || "")) === "marathon match";
525+
}
526+
527+
/**
528+
* Ensure a challenge has the configured resource role before a phase opens.
529+
* @param {Object} challenge challenge data loaded with its type relation
530+
* @param {String} phaseName phase name being opened
531+
* @throws {BadRequestError} when the challenge is missing the required resource role
532+
*/
533+
async function ensureRequiredResourcesBeforeOpeningPhase(challenge, phaseName) {
515534
const normalizedPhaseName = _.toLower(_.trim(phaseName || ""));
516535
const requiredRoleName = PHASE_RESOURCE_ROLE_REQUIREMENTS[normalizedPhaseName];
517536
if (!requiredRoleName) {
518537
return;
519538
}
520539

540+
if (normalizedPhaseName === "review" && isMarathonMatchChallengeType(challenge)) {
541+
return;
542+
}
543+
544+
const challengeId = challenge.id;
521545
const challengeResources = await helper.getChallengeResources(challengeId);
522546
const requiredRoleNameLower = _.toLower(requiredRoleName);
523547
const hasRequiredRoleByName = (challengeResources || []).some((resource) => {
@@ -693,7 +717,7 @@ async function partiallyUpdateChallengePhase(currentUser, challengeId, id, data)
693717

694718
if (isOpeningPhase) {
695719
const phaseName = data.name || challengePhase.name;
696-
await ensureRequiredResourcesBeforeOpeningPhase(challengeId, phaseName);
720+
await ensureRequiredResourcesBeforeOpeningPhase(challenge, phaseName);
697721

698722
// Check if this is the Appeals phase
699723
const normalizedPhaseName = normalizePhaseName(phaseName);

src/services/ChallengeService.js

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const CHALLENGE_BILLING_LOCK_STATUSES = new Set([
5151
ChallengeStatusEnum.APPROVED,
5252
ChallengeStatusEnum.ACTIVE,
5353
]);
54+
5455
const CHALLENGE_APPROVAL_STATUS = {
5556
PENDING_APPROVAL: "PENDING_APPROVAL",
5657
APPROVED: "APPROVED",
@@ -61,6 +62,8 @@ const CHALLENGE_APPROVAL_ACTION_STATUSES = new Set([
6162
CHALLENGE_APPROVAL_STATUS.REJECTED,
6263
]);
6364

65+
const DEFAULT_ESTIMATED_SUBMISSIONS_COUNT = 2;
66+
6467
// Provide aliases for friendlier sortBy query params
6568
const sortByAliases = {
6669
updated: constants.validChallengeParams.Updated,
@@ -199,17 +202,93 @@ function getChallengePrizeSetMemberPaymentAmount(challenge) {
199202
}, 0);
200203
}
201204

205+
/**
206+
* Reads the first-place placement prize used by reviewer cost estimates.
207+
*
208+
* @param {object} challenge Challenge model or response object.
209+
* @returns {number|undefined} First-place USD placement prize amount, or undefined when unavailable.
210+
*/
211+
function getFirstPlacePrizeValue(challenge) {
212+
const prizeSets = _.get(challenge, "prizeSets");
213+
214+
if (!Array.isArray(prizeSets)) {
215+
return undefined;
216+
}
217+
218+
const placementPrizeSet = _.find(
219+
prizeSets,
220+
(prizeSet) => _.toString(_.get(prizeSet, "type")).toUpperCase() === PrizeSetTypeEnum.PLACEMENT,
221+
);
222+
const firstPrize = _.get(placementPrizeSet, "prizes[0]");
223+
224+
if (_.toString(_.get(firstPrize, "type")).toUpperCase() !== constants.prizeTypes.USD) {
225+
return undefined;
226+
}
227+
228+
const prizeValue = _.toNumber(_.get(firstPrize, "value"));
229+
230+
return Number.isFinite(prizeValue) ? prizeValue : undefined;
231+
}
232+
233+
/**
234+
* Calculates the estimated member-review payment amount for billing locks.
235+
*
236+
* The work app shows review cost using a two-submission estimate, so draft
237+
* budget locks need the same fixed and coefficient-based reviewer math.
238+
*
239+
* @param {object} challenge Challenge model or response object.
240+
* @returns {number} Estimated member-review payment amount before markup.
241+
*/
242+
function getEstimatedReviewerPaymentAmount(challenge) {
243+
const reviewers = _.get(challenge, "reviewers");
244+
245+
if (!Array.isArray(reviewers)) {
246+
return 0;
247+
}
248+
249+
const firstPlacePrizeValue = getFirstPlacePrizeValue(challenge);
250+
251+
if (_.isNil(firstPlacePrizeValue)) {
252+
return 0;
253+
}
254+
255+
return reviewers.reduce((total, reviewer) => {
256+
if (_.get(reviewer, "isMemberReview") === false) {
257+
return total;
258+
}
259+
260+
const fixedAmount = _.toNumber(_.get(reviewer, "fixedAmount"));
261+
const baseCoefficient = _.toNumber(_.get(reviewer, "baseCoefficient"));
262+
const incrementalCoefficient = _.toNumber(_.get(reviewer, "incrementalCoefficient"));
263+
const memberReviewerCount = Math.max(
264+
1,
265+
Math.trunc(_.toNumber(_.get(reviewer, "memberReviewerCount")) || 1),
266+
);
267+
const reviewerPayment =
268+
(Number.isFinite(fixedAmount) ? fixedAmount : 0) +
269+
((Number.isFinite(baseCoefficient) ? baseCoefficient : 0) +
270+
(Number.isFinite(incrementalCoefficient) ? incrementalCoefficient : 0) *
271+
DEFAULT_ESTIMATED_SUBMISSIONS_COUNT) *
272+
firstPlacePrizeValue;
273+
274+
return total + reviewerPayment * memberReviewerCount;
275+
}, 0);
276+
}
277+
202278
/**
203279
* Reads the currently persisted challenge member-payment total.
204280
*
205281
* @param {object} challenge Challenge model or response object.
206-
* @returns {number|undefined} Total USD member-payment amount before markup.
282+
* @returns {number|undefined} Total USD member-payment amount before markup,
283+
* including estimated member-review cost when prize sets are loaded.
207284
*/
208285
function getChallengeMemberPaymentAmount(challenge) {
209286
const prizeSetMemberPaymentAmount = getChallengePrizeSetMemberPaymentAmount(challenge);
210287

211288
if (!_.isNil(prizeSetMemberPaymentAmount)) {
212-
return prizeSetMemberPaymentAmount;
289+
return Number(
290+
(prizeSetMemberPaymentAmount + getEstimatedReviewerPaymentAmount(challenge)).toFixed(2),
291+
);
213292
}
214293

215294
const totalPrizes = _.get(

test/unit/ChallengePhaseService.test.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1713,6 +1713,54 @@ describe('challenge phase service unit tests', () => {
17131713
throw new Error('should not reach here')
17141714
})
17151715

1716+
it('partially update challenge phase - opens marathon match review phase without reviewer resource', async () => {
1717+
const reviewPhase = await prisma.phase.create({
1718+
data: {
1719+
id: uuid(),
1720+
name: 'Review',
1721+
description: 'desc',
1722+
isOpen: false,
1723+
duration: 86400,
1724+
createdBy: 'admin',
1725+
updatedBy: 'admin'
1726+
}
1727+
})
1728+
const reviewChallengePhaseId = uuid()
1729+
await prisma.challengePhase.create({
1730+
data: {
1731+
id: reviewChallengePhaseId,
1732+
challengeId: data.marathonMatchChallenge.id,
1733+
phaseId: reviewPhase.id,
1734+
name: 'Review',
1735+
isOpen: false,
1736+
createdBy: 'admin',
1737+
updatedBy: 'admin'
1738+
}
1739+
})
1740+
1741+
const originalGetChallengeResources = helper.getChallengeResources
1742+
const originalGetResourceRoles = helper.getResourceRoles
1743+
helper.getChallengeResources = async () => [{ roleId: 'some-other-role-id' }]
1744+
helper.getResourceRoles = async () => {
1745+
throw new Error('resource role lookup should not be required for Marathon Match Review')
1746+
}
1747+
1748+
try {
1749+
const challengePhase = await service.partiallyUpdateChallengePhase(
1750+
authUser,
1751+
data.marathonMatchChallenge.id,
1752+
reviewChallengePhaseId,
1753+
{ isOpen: true }
1754+
)
1755+
should.equal(challengePhase.isOpen, true)
1756+
} finally {
1757+
helper.getChallengeResources = originalGetChallengeResources
1758+
helper.getResourceRoles = originalGetResourceRoles
1759+
await prisma.challengePhase.delete({ where: { id: reviewChallengePhaseId } })
1760+
await prisma.phase.delete({ where: { id: reviewPhase.id } })
1761+
}
1762+
})
1763+
17161764
it('partially update challenge phase - opens review phase when reviewer resource exists', async () => {
17171765
const reviewPhase = await prisma.phase.create({
17181766
data: {

test/unit/ChallengeService.test.js

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -261,9 +261,46 @@ describe("challenge service unit tests", () => {
261261
it("locks draft challenge budget when the challenge is saved", async () => {
262262
const challengeData = _.cloneDeep(testChallengeData);
263263
challengeData.status = ChallengeStatusEnum.DRAFT;
264-
challengeData.prizeSets[0].type = PrizeSetTypeEnum.PLACEMENT;
265-
challengeData.prizeSets[0].prizes[0].type = constants.prizeTypes.USD;
266-
challengeData.prizeSets[0].prizes[0].value = 1000;
264+
challengeData.prizeSets = [
265+
{
266+
type: PrizeSetTypeEnum.PLACEMENT,
267+
description: "placement prizes",
268+
prizes: [
269+
{
270+
description: "placement 1",
271+
type: constants.prizeTypes.USD,
272+
value: 35,
273+
},
274+
{
275+
description: "placement 2",
276+
type: constants.prizeTypes.USD,
277+
value: 12,
278+
},
279+
],
280+
},
281+
{
282+
type: PrizeSetTypeEnum.COPILOT,
283+
description: "copilot payment",
284+
prizes: [
285+
{
286+
description: "copilot",
287+
type: constants.prizeTypes.USD,
288+
value: 10,
289+
},
290+
],
291+
},
292+
];
293+
challengeData.reviewers = [
294+
{
295+
scorecardId: "scorecard-id",
296+
isMemberReview: true,
297+
memberReviewerCount: 1,
298+
phaseId: data.phase.id,
299+
fixedAmount: 16.1,
300+
baseCoefficient: 0,
301+
incrementalCoefficient: 0,
302+
},
303+
];
267304
const originalGetProjectBillingInformation = projectHelper.getProjectBillingInformation;
268305

269306
projectHelper.getProjectBillingInformation = async () => ({
@@ -284,7 +321,7 @@ describe("challenge service unit tests", () => {
284321
billingAccountId: "80001012",
285322
challengeId: result.id,
286323
markup: 0.1,
287-
memberPaymentAmount: 1000,
324+
memberPaymentAmount: 73.1,
288325
});
289326
} finally {
290327
projectHelper.getProjectBillingInformation = originalGetProjectBillingInformation;

test/unit/phase-management/PhaseAdvancer.test.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const { expect } = require("chai");
22

33
const PhaseAdvancer = require("../../../src/phase-management/PhaseAdvancer");
44
const { getClient } = require("../../../src/common/prisma");
5+
const reviewPrisma = require("../../../src/common/review-prisma");
56

67
const buildIterativeReviewPhase = () => ({
78
id: "phase-iterative-review",
@@ -91,3 +92,80 @@ describe("PhaseAdvancer Iterative Review gating", () => {
9192
expect(phases[0].isOpen).to.be.true;
9293
});
9394
});
95+
96+
describe("PhaseAdvancer review completion queries", () => {
97+
const prisma = getClient();
98+
const originalFindUnique = prisma.challenge.findUnique;
99+
const originalGetReviewClient = reviewPrisma.getReviewClient;
100+
const phaseAdvancerPath = require.resolve("../../../src/phase-management/PhaseAdvancer");
101+
102+
afterEach(() => {
103+
prisma.challenge.findUnique = originalFindUnique;
104+
reviewPrisma.getReviewClient = originalGetReviewClient;
105+
delete require.cache[phaseAdvancerPath];
106+
});
107+
108+
it("casts review status to text before comparing completed reviews", async () => {
109+
const capturedQueries = [];
110+
111+
reviewPrisma.getReviewClient = () => ({
112+
$queryRaw: async (query) => {
113+
capturedQueries.push(query);
114+
return [{ count: 2 }];
115+
},
116+
});
117+
118+
delete require.cache[phaseAdvancerPath];
119+
const PhaseAdvancerWithMockedReviewClient = require("../../../src/phase-management/PhaseAdvancer");
120+
const phaseAdvancer = new PhaseAdvancerWithMockedReviewClient({
121+
async getPhaseFacts() {
122+
return {};
123+
},
124+
});
125+
126+
prisma.challenge.findUnique = async () => ({
127+
numOfSubmissions: 1,
128+
reviewers: [{ isMemberReview: true, memberReviewerCount: 2 }],
129+
});
130+
131+
const phases = [
132+
{
133+
id: "phase-review",
134+
phaseId: "phase-review",
135+
name: "Review",
136+
description: "Review phase",
137+
duration: 86400,
138+
isOpen: true,
139+
predecessor: null,
140+
scheduledStartDate: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
141+
scheduledEndDate: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
142+
actualStartDate: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
143+
actualEndDate: null,
144+
constraints: [],
145+
},
146+
];
147+
148+
const result = await phaseAdvancer.advancePhase(
149+
"challenge-123",
150+
null,
151+
phases,
152+
"close",
153+
"Review"
154+
);
155+
const [reviewCompletionQuery] = capturedQueries;
156+
const queryText = [
157+
reviewCompletionQuery.sql,
158+
reviewCompletionQuery.text,
159+
reviewCompletionQuery.statement,
160+
Array.isArray(reviewCompletionQuery.strings)
161+
? reviewCompletionQuery.strings.join("?")
162+
: "",
163+
]
164+
.filter(Boolean)
165+
.join("\n");
166+
167+
expect(result.success).to.be.true;
168+
expect(queryText).to.contain('r."status"::text =');
169+
expect(reviewCompletionQuery.values).to.include("COMPLETED");
170+
});
171+
});

0 commit comments

Comments
 (0)