Skip to content

Commit a1145bf

Browse files
committed
Merge branch 'develop' into pm-2456
2 parents d15d6aa + 36385c9 commit a1145bf

File tree

10 files changed

+775
-4
lines changed

10 files changed

+775
-4
lines changed

config/default.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ module.exports = {
4444
SUBMISSIONS_API_URL:
4545
process.env.SUBMISSIONS_API_URL || "https://api.topcoder-dev.com/v5/submissions",
4646
MEMBERS_API_URL: process.env.MEMBERS_API_URL || "https://api.topcoder-dev.com/v6/members",
47+
REVIEW_SUMMATIONS_API_URL: process.env.REVIEW_SUMMATIONS_API_URL || "https://api.topcoder-dev.com/v6/reviewSummations",
4748
RESOURCES_API_URL: process.env.RESOURCES_API_URL || "http://localhost:4000/v5/resources",
4849
// TODO: change this to localhost
4950
RESOURCE_ROLES_API_URL:

docs/swagger.yaml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,51 @@ paths:
675675
description: Internal Server Error
676676
schema:
677677
$ref: "#/definitions/ErrorModel"
678+
/challenges/{challengeId}/close-marathon-match:
679+
post:
680+
tags:
681+
- Challenges
682+
description: >
683+
Close a Marathon Match challenge by selecting winners from final review
684+
summations, closing all phases, and setting the challenge status to
685+
Completed.
686+
security:
687+
- bearer: []
688+
produces:
689+
- application/json
690+
parameters:
691+
- $ref: "#/parameters/app-version"
692+
- name: challengeId
693+
in: path
694+
required: true
695+
type: string
696+
format: UUID
697+
description: The id of the Marathon Match challenge to close
698+
responses:
699+
"200":
700+
description: OK
701+
schema:
702+
$ref: "#/definitions/Challenge"
703+
"400":
704+
description: Bad request. The challenge is not a Marathon Match or required data is missing.
705+
schema:
706+
$ref: "#/definitions/ErrorModel"
707+
"401":
708+
description: Unauthorized. Fail to authenticate the requester.
709+
schema:
710+
$ref: "#/definitions/ErrorModel"
711+
"403":
712+
description: Forbidden. Only admins or M2M tokens with update scope can close the challenge.
713+
schema:
714+
$ref: "#/definitions/ErrorModel"
715+
"404":
716+
description: Challenge not found
717+
schema:
718+
$ref: "#/definitions/ErrorModel"
719+
"500":
720+
description: Internal Server Error
721+
schema:
722+
$ref: "#/definitions/ErrorModel"
678723
/challenge-types:
679724
get:
680725
tags:

mock-api/app.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const config = require('config')
88
const winston = require('winston')
99
const _ = require('lodash')
1010
const prisma = require('../src/common/prisma').getClient()
11+
const uuid = require('uuid/v4')
1112

1213
const app = express()
1314
app.set('port', config.PORT)
@@ -82,6 +83,19 @@ app.get('/v5/resources', (req, res) => {
8283
memberId: '12345678',
8384
memberHandle: 'thomaskranitsas',
8485
roleId: '732339e7-8e30-49d7-9198-cccf9451e221'
86+
}, {
87+
// additional submitter resource
88+
id: '22ba038e-48da-487b-96e8-8d3b99b6d184',
89+
challengeId,
90+
memberId: '9876543',
91+
memberHandle: 'tonyj',
92+
roleId: '732339e7-8e30-49d7-9198-cccf9451e221'
93+
}, {
94+
id: '22ba038e-48da-487b-96e8-8d3b99b6d185',
95+
challengeId,
96+
memberId: '3456789',
97+
memberHandle: 'nathanael',
98+
roleId: '732339e7-8e30-49d7-9198-cccf9451e221'
8599
}]
86100

87101
let ret
@@ -277,6 +291,77 @@ app.post('/v5/bus/events', (req, res) => {
277291
res.status(200).json({})
278292
})
279293

294+
app.get('/v6/reviewSummations', (req, res) => {
295+
const { challengeId } = req.query
296+
const page = Number(req.query.page || 1)
297+
const perPage = Number(req.query.perPage || 100)
298+
299+
if (!challengeId) {
300+
res.status(400).json({ message: 'challengeId query parameter is required' })
301+
return
302+
}
303+
304+
if (page > 1) {
305+
res.json({
306+
data: [],
307+
meta: {
308+
page,
309+
perPage,
310+
totalPages: 1
311+
}
312+
})
313+
return
314+
}
315+
316+
const data = [
317+
{
318+
id: uuid(),
319+
challengeId,
320+
submitterId: '12345678',
321+
submitterHandle: 'thomaskranitsas',
322+
aggregateScore: 95.5,
323+
isFinal: true,
324+
createdAt: '2024-02-01T10:00:00.000Z'
325+
},
326+
{
327+
id: uuid(),
328+
challengeId,
329+
submitterId: '9876543',
330+
submitterHandle: 'tonyj',
331+
aggregateScore: 92.1,
332+
isFinal: true,
333+
createdAt: '2024-02-01T11:00:00.000Z'
334+
},
335+
{
336+
id: uuid(),
337+
challengeId,
338+
submitterId: '3456789',
339+
submitterHandle: 'nathanael',
340+
aggregateScore: 87.3,
341+
isFinal: true,
342+
createdAt: '2024-02-01T12:00:00.000Z'
343+
},
344+
{
345+
id: uuid(),
346+
challengeId,
347+
submitterId: '5555555',
348+
submitterHandle: 'spectator',
349+
aggregateScore: 90.0,
350+
isFinal: false,
351+
createdAt: '2024-02-01T13:00:00.000Z'
352+
}
353+
]
354+
355+
res.json({
356+
data,
357+
meta: {
358+
page,
359+
perPage,
360+
totalPages: 1
361+
}
362+
})
363+
})
364+
280365
app.post('/v5/auth0', (req, res) => {
281366
winston.info('Received Auth0 request')
282367
// return config/test.js#M2M_FULL_ACCESS_TOKEN

src/common/helper.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1305,6 +1305,36 @@ async function getChallengeSubmissions(challengeId) {
13051305
return allSubmissions;
13061306
}
13071307

1308+
/**
1309+
* Get review summations for a challenge
1310+
* @param {String} challengeId the challenge ID
1311+
* @returns {Promise<Array>}
1312+
*/
1313+
async function getReviewSummations(challengeId) {
1314+
const token = await m2mHelper.getM2MToken();
1315+
let allReviewSummations = [];
1316+
let page = 1;
1317+
while (true) {
1318+
const result = await axios.get(`${config.REVIEW_SUMMATIONS_API_URL}?challengeId=${challengeId}`, {
1319+
headers: { Authorization: `Bearer ${token}` },
1320+
params: {
1321+
page,
1322+
perPage: 500,
1323+
},
1324+
});
1325+
const reviewSummations = result.data.data || [];
1326+
if (reviewSummations.length === 0) {
1327+
break;
1328+
}
1329+
allReviewSummations = allReviewSummations.concat(reviewSummations);
1330+
page += 1;
1331+
if (result.data.meta.totalPages && page > result.data.meta.totalPages) {
1332+
break;
1333+
}
1334+
}
1335+
return allReviewSummations;
1336+
}
1337+
13081338
/**
13091339
* Get member by ID
13101340
* @param {String} userId the user ID
@@ -1530,6 +1560,7 @@ module.exports = {
15301560
getProjectIdByRoundId,
15311561
getGroupById,
15321562
getChallengeSubmissions,
1563+
getReviewSummations,
15331564
getMemberById,
15341565
createSelfServiceProject,
15351566
activateProject,

src/controllers/ChallengeController.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,19 @@ async function advancePhase(req, res) {
125125
res.send(await service.advancePhase(req.authUser, req.params.challengeId, req.body));
126126
}
127127

128+
/**
129+
* Close marathon match challenge and determine winners
130+
* @param {Object} req the request
131+
* @param {Object} res the response
132+
*/
133+
async function closeMarathonMatch(req, res) {
134+
logger.debug(
135+
`closeMarathonMatch User: ${JSON.stringify(req.authUser)} - ChallengeID: ${req.params.challengeId}`
136+
);
137+
const result = await service.closeMarathonMatch(req.authUser, req.params.challengeId);
138+
res.send(result);
139+
}
140+
128141
/**
129142
* Get default reviewers for a typeId + trackId
130143
* @param {Object} req the request
@@ -154,6 +167,7 @@ module.exports = {
154167
getChallengeStatistics,
155168
sendNotifications,
156169
advancePhase,
170+
closeMarathonMatch,
157171
getDefaultReviewers,
158172
setDefaultReviewers,
159173
};

src/routes.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,15 @@ module.exports = {
120120
scopes: [UPDATE, ALL],
121121
},
122122
},
123+
"/challenges/:challengeId/close-marathon-match": {
124+
post: {
125+
controller: "ChallengeController",
126+
method: "closeMarathonMatch",
127+
auth: "jwt",
128+
access: [constants.UserRoles.Admin],
129+
scopes: [UPDATE, ALL],
130+
},
131+
},
123132
"/challenges/:challengeId/statistics": {
124133
get: {
125134
controller: "ChallengeController",

src/services/ChallengeService.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2089,6 +2089,17 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) {
20892089
}
20902090
}
20912091

2092+
// Treat incoming `reviews` payloads as an alias for `reviewers`
2093+
if (!_.isNil(data.reviews)) {
2094+
if (!Array.isArray(data.reviews)) {
2095+
throw new BadRequestError("reviews must be an array");
2096+
}
2097+
if (_.isNil(data.reviewers)) {
2098+
data.reviewers = _.cloneDeep(data.reviews);
2099+
}
2100+
delete data.reviews;
2101+
}
2102+
20922103
// Remove fields from data that are not allowed to be updated and that match the existing challenge
20932104
data = sanitizeData(sanitizeChallenge(data), challenge);
20942105
logger.debug(`Sanitized Data: ${JSON.stringify(data)}`);
@@ -3556,6 +3567,96 @@ async function advancePhase(currentUser, challengeId, data) {
35563567
};
35573568
}
35583569

3570+
async function closeMarathonMatch(currentUser, challengeId) {
3571+
logger.info(`Close Marathon Match Request - ${challengeId}`);
3572+
const machineOrAdmin = currentUser && (currentUser.isMachine || hasAdminRole(currentUser));
3573+
if (!machineOrAdmin) {
3574+
throw new errors.ForbiddenError(
3575+
`Admin role or an M2M token is required to close the marathon match.`
3576+
);
3577+
}
3578+
3579+
const challenge = await prisma.challenge.findUnique({
3580+
where: { id: challengeId },
3581+
include: includeReturnFields,
3582+
});
3583+
3584+
if (_.isNil(challenge) || _.isNil(challenge.id)) {
3585+
throw new errors.NotFoundError(`Challenge with id: ${challengeId} doesn't exist.`);
3586+
}
3587+
3588+
if (!challenge.type || challenge.type.name !== "Marathon Match") {
3589+
throw new errors.BadRequestError(
3590+
`Challenge with id: ${challengeId} is not a Marathon Match challenge.`
3591+
);
3592+
}
3593+
3594+
const reviewSummations = await helper.getReviewSummations(challengeId);
3595+
const finalSummations = (reviewSummations || []).filter((summation) => summation.isFinal === true);
3596+
3597+
const orderedSummations = _.orderBy(
3598+
finalSummations,
3599+
["aggregateScore", "createdAt"],
3600+
["desc", "asc"]
3601+
);
3602+
3603+
const winners = orderedSummations.map((summation, index) => {
3604+
const parsedUserId = Number(summation.submitterId);
3605+
if (!Number.isFinite(parsedUserId) || !Number.isInteger(parsedUserId)) {
3606+
throw new errors.BadRequestError(
3607+
`Invalid submitterId ${summation.submitterId} for review summation winner`
3608+
);
3609+
}
3610+
3611+
return {
3612+
userId: parsedUserId,
3613+
handle: summation.submitterHandle,
3614+
placement: index + 1,
3615+
type: PrizeSetTypeEnum.PLACEMENT,
3616+
};
3617+
});
3618+
3619+
if (winners.length > 0) {
3620+
const challengeResources = await helper.getChallengeResources(challengeId);
3621+
const submitterResources = challengeResources.filter(
3622+
(resource) => resource.roleId === config.SUBMITTER_ROLE_ID
3623+
);
3624+
const missingResources = winners.filter(
3625+
(winner) =>
3626+
!submitterResources.some(
3627+
(resource) => _.toString(resource.memberId) === _.toString(winner.userId)
3628+
)
3629+
);
3630+
if (missingResources.length > 0) {
3631+
throw new errors.BadRequestError(
3632+
`Submitter resources are required to close Marathon Match challenge ${challengeId}. Missing submitter resources for userIds: ${missingResources
3633+
.map((winner) => winner.userId)
3634+
.join(", ")}`
3635+
);
3636+
}
3637+
}
3638+
3639+
const closedAt = new Date().toISOString();
3640+
const updatedPhases = (challenge.phases || []).map((phase) => ({
3641+
...phase,
3642+
isOpen: false,
3643+
actualEndDate: closedAt,
3644+
}));
3645+
3646+
const updatedChallenge = await updateChallenge(
3647+
currentUser,
3648+
challengeId,
3649+
{
3650+
winners,
3651+
phases: updatedPhases,
3652+
status: ChallengeStatusEnum.COMPLETED,
3653+
},
3654+
{ emitEvent: true }
3655+
);
3656+
3657+
return updatedChallenge;
3658+
}
3659+
35593660
advancePhase.schema = {
35603661
currentUser: Joi.any(),
35613662
challengeId: Joi.id(),
@@ -3567,6 +3668,11 @@ advancePhase.schema = {
35673668
.required(),
35683669
};
35693670

3671+
closeMarathonMatch.schema = {
3672+
currentUser: Joi.any(),
3673+
challengeId: Joi.id(),
3674+
};
3675+
35703676
async function indexChallengeAndPostToKafka(updatedChallenge, track, type) {
35713677
const prizeType = challengeHelper.validatePrizeSetsAndGetPrizeType(updatedChallenge.prizeSets);
35723678

@@ -3611,6 +3717,7 @@ module.exports = {
36113717
getChallengeStatistics,
36123718
sendNotifications,
36133719
advancePhase,
3720+
closeMarathonMatch,
36143721
getDefaultReviewers,
36153722
setDefaultReviewers,
36163723
indexChallengeAndPostToKafka,

0 commit comments

Comments
 (0)