Skip to content

Commit 4823b03

Browse files
authoredJan 31, 2023
Merge pull request #556 from topcoder-platform/feature/PLAT-2032
feat: add support for phase constraints (plat-2032)
·
1.4.181.4.16
2 parents 8333a17 + 57e3a03 commit 4823b03

File tree

13 files changed

+4603
-141
lines changed

13 files changed

+4603
-141
lines changed
 

‎.circleci/config.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ install_deploysuite: &install_deploysuite
1818
cp ./../buildscript/buildenv.sh .
1919
cp ./../buildscript/awsconfiguration.sh .
2020
restore_cache_settings_for_build: &restore_cache_settings_for_build
21-
key: docker-node-modules-{{ checksum "package-lock.json" }}
21+
key: docker-node-modules-{{ checksum "yarn.lock" }}
2222

2323
save_cache_settings: &save_cache_settings
24-
key: docker-node-modules-{{ checksum "package-lock.json" }}
24+
key: docker-node-modules-{{ checksum "yarn.lock" }}
2525
paths:
2626
- node_modules
2727

@@ -72,7 +72,7 @@ workflows:
7272
branches:
7373
only:
7474
- develop
75-
- fix/task-memberId-reset
75+
- feature/PLAT-2032
7676

7777
# Production builds are exectuted only on tagged commits to the
7878
# master branch.

‎.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,4 @@ typings/
6060

6161
# next.js build output
6262
.next
63+
.npmrc

‎.vscode/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"editor.defaultFormatter": "standard.vscode-standard",
3+
"standard.autoFixOnSave": true
4+
}

‎README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ You can find sample `.env` files inside the `/docs` directory.
102102
1. 📦 Install npm dependencies
103103

104104
```bash
105-
npm install
105+
yarn install
106106
```
107107

108108
2. ⚙ Local config

‎build.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ docker create --name app $APP_NAME:latest
77

88
if [ -d node_modules ]
99
then
10-
mv package-lock.json old-package-lock.json
11-
docker cp app:/$APP_NAME/package-lock.json package-lock.json
10+
mv yarn.lock old-yarn.lock
11+
docker cp app:/$APP_NAME/yarn.lock yarn.lock
1212
set +eo pipefail
13-
UPDATE_CACHE=$(cmp package-lock.json old-package-lock.json)
13+
UPDATE_CACHE=$(cmp yarn.lock old-yarn.lock)
1414
set -eo pipefail
1515
else
1616
UPDATE_CACHE=1

‎docker/Dockerfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Use the base image with Node.js
2-
FROM node:12.22.12-buster
2+
FROM node:14.21.2-bullseye
33

44
# Copy the current directory into the Docker image
55
COPY . /challenge-api
@@ -8,6 +8,6 @@ COPY . /challenge-api
88
WORKDIR /challenge-api
99

1010
# Install the dependencies from package.json
11-
RUN npm install
11+
RUN yarn install
1212

13-
CMD npm start
13+
CMD yarn start

‎mock-api/Dockerfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ FROM node:10.20-jessie
22

33
COPY . /challenge-api
44

5-
RUN (cd /challenge-api && npm install)
5+
RUN (cd /challenge-api && yarn install)
66

77
WORKDIR /challenge-api/mock-api
88

9-
RUN npm install
9+
RUN yarn install
1010

11-
CMD npm start
11+
CMD yarn start

‎package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,9 @@
7676
]
7777
},
7878
"engines": {
79-
"node": "10.x"
79+
"node": "14.x"
8080
},
8181
"volta": {
82-
"node": "12.22.12"
82+
"node": "14.21.2"
8383
}
8484
}

‎src/common/helper.js

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -336,25 +336,6 @@ function partialMatch (filter, value) {
336336
}
337337
}
338338

339-
/**
340-
* Perform validation on phases.
341-
* @param {Array} phases the phases data.
342-
*/
343-
async function validatePhases (phases) {
344-
if (!phases || phases.length === 0) {
345-
return
346-
}
347-
const records = await scan('Phase')
348-
const map = new Map()
349-
_.each(records, r => {
350-
map.set(r.id, r)
351-
})
352-
const invalidPhases = _.filter(phases, p => !map.has(p.phaseId))
353-
if (invalidPhases.length > 0) {
354-
throw new errors.BadRequestError(`The following phases are invalid: ${toString(invalidPhases)}`)
355-
}
356-
}
357-
358339
/**
359340
* Download file from S3
360341
* @param {String} bucket the bucket name
@@ -1294,7 +1275,6 @@ module.exports = {
12941275
scanAll,
12951276
validateDuplicate,
12961277
partialMatch,
1297-
validatePhases,
12981278
downloadFromFileStack,
12991279
downloadFromS3,
13001280
deleteFromS3,

‎src/common/phase-helper.js

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
const _ = require('lodash')
2+
const uuid = require('uuid/v4')
3+
const moment = require('moment')
4+
5+
const errors = require('./errors')
6+
const helper = require('./helper')
7+
8+
class ChallengePhaseHelper {
9+
/**
10+
* Populate challenge phases.
11+
* @param {Array} phases the phases to populate
12+
* @param {Date} startDate the challenge start date
13+
* @param {String} timelineTemplateId the timeline template id
14+
*/
15+
async populatePhases (phases, startDate, timelineTemplateId) {
16+
if (_.isUndefined(timelineTemplateId)) {
17+
throw new errors.BadRequestError(`Invalid timeline template ID: ${timelineTemplateId}`)
18+
}
19+
20+
const { timelineTempate, timelineTemplateMap } = await this.getTemplateAndTemplateMap(timelineTemplateId)
21+
const { phaseDefinitionMap } = await this.getPhaseDefinitionsAndMap()
22+
23+
if (!phases || phases.length === 0) {
24+
// auto populate phases
25+
for (const p of timelineTempate) {
26+
phases.push({ ...p })
27+
}
28+
}
29+
30+
for (const p of phases) {
31+
const phaseDefinition = phaseDefinitionMap.get(p.phaseId)
32+
33+
p.id = uuid()
34+
p.name = phaseDefinition.name
35+
p.description = phaseDefinition.description
36+
37+
// set p.open based on current phase
38+
const phaseTemplate = timelineTemplateMap.get(p.phaseId)
39+
if (phaseTemplate) {
40+
if (!p.duration) {
41+
p.duration = phaseTemplate.defaultDuration
42+
}
43+
44+
if (phaseTemplate.predecessor) {
45+
const predecessor = _.find(phases, { phaseId: phaseTemplate.predecessor })
46+
if (!predecessor) {
47+
throw new errors.BadRequestError(`Predecessor ${phaseTemplate.predecessor} not found in given phases.`)
48+
}
49+
p.predecessor = phaseTemplate.predecessor
50+
}
51+
}
52+
}
53+
54+
// calculate dates
55+
if (!startDate) {
56+
return
57+
}
58+
59+
// sort phases by predecessor
60+
phases.sort((a, b) => {
61+
if (a.predecessor === b.phaseId) {
62+
return 1
63+
}
64+
if (b.predecessor === a.phaseId) {
65+
return -1
66+
}
67+
return 0
68+
})
69+
70+
let isSubmissionPhaseOpen = false
71+
72+
for (let p of phases) {
73+
const predecessor = timelineTemplateMap.get(p.predecessor)
74+
75+
if (predecessor == null) {
76+
if (p.name === 'Registration') {
77+
p.scheduledStartDate = moment(startDate).toDate()
78+
}
79+
if (p.name === 'Submission') {
80+
if (p.scheduledStartDate != null) {
81+
p.scheduledStartDate = moment(p.scheduledStartDate).toDate()
82+
} else {
83+
p.scheduledStartDate = moment(startDate).add(5, 'minutes').toDate()
84+
}
85+
}
86+
87+
if (moment(p.scheduledStartDate).isSameOrBefore(moment())) {
88+
p.actualStartDate = p.scheduledStartDate
89+
} else {
90+
delete p.actualStartDate
91+
}
92+
93+
p.scheduledEndDate = moment(p.scheduledStartDate).add(p.duration, 'seconds').toDate()
94+
if (moment(p.scheduledEndDate).isBefore(moment())) {
95+
delete p.actualEndDate
96+
} else {
97+
p.actualEndDate = p.scheduledEndDate
98+
}
99+
} else {
100+
const precedecessorPhase = _.find(phases, { phaseId: predecessor.phaseId })
101+
if (precedecessorPhase == null) {
102+
throw new errors.BadRequestError(`Predecessor ${predecessor.phaseId} not found in given phases.`)
103+
}
104+
let phaseEndDate = moment(precedecessorPhase.scheduledEndDate)
105+
if (precedecessorPhase.actualEndDate != null && moment(precedecessorPhase.actualEndDate).isAfter(phaseEndDate)) {
106+
phaseEndDate = moment(precedecessorPhase.actualEndDate)
107+
} else {
108+
phaseEndDate = moment(precedecessorPhase.scheduledEndDate)
109+
}
110+
111+
p.scheduledStartDate = phaseEndDate.toDate()
112+
p.scheduledEndDate = moment(p.scheduledStartDate).add(p.duration, 'seconds').toDate()
113+
}
114+
115+
p.isOpen = moment().isBetween(p.scheduledStartDate, p.scheduledEndDate)
116+
117+
if (p.isOpen) {
118+
if (p.name === 'Submission') {
119+
isSubmissionPhaseOpen = true
120+
}
121+
delete p.actualEndDate
122+
}
123+
124+
if (moment(p.scheduledStartDate).isAfter(moment())) {
125+
delete p.actualStartDate
126+
delete p.actualEndDate
127+
}
128+
129+
if (p.name === 'Post-Mortem' && isSubmissionPhaseOpen) {
130+
delete p.actualStartDate
131+
delete p.actualEndDate
132+
p.isOpen = false
133+
}
134+
}
135+
136+
// phases.sort((a, b) => moment(a.scheduledStartDate).isAfter(b.scheduledStartDate))
137+
}
138+
139+
async validatePhases (phases) {
140+
if (!phases || phases.length === 0) {
141+
return
142+
}
143+
const records = await helper.scan('Phase')
144+
const map = new Map()
145+
_.each(records, (r) => {
146+
map.set(r.id, r)
147+
})
148+
const invalidPhases = _.filter(phases, (p) => !map.has(p.phaseId))
149+
if (invalidPhases.length > 0) {
150+
throw new errors.BadRequestError(
151+
`The following phases are invalid: ${toString(invalidPhases)}`
152+
)
153+
}
154+
}
155+
156+
async getPhaseDefinitionsAndMap () {
157+
const records = await helper.scan('Phase')
158+
const map = new Map()
159+
_.each(records, (r) => {
160+
map.set(r.id, r)
161+
})
162+
return { phaseDefinitions: records, phaseDefinitionMap: map }
163+
}
164+
165+
async getTemplateAndTemplateMap (timelineTemplateId) {
166+
const records = await helper.getById('TimelineTemplate', timelineTemplateId)
167+
const map = new Map()
168+
_.each(records.phases, (r) => {
169+
map.set(r.phaseId, r)
170+
})
171+
172+
return {
173+
timelineTempate: records.phases,
174+
timelineTemplateMap: map
175+
}
176+
}
177+
}
178+
179+
module.exports = new ChallengePhaseHelper()

‎src/services/ChallengeService.js

Lines changed: 28 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const xss = require('xss')
1010
const helper = require('../common/helper')
1111
const logger = require('../common/logger')
1212
const errors = require('../common/errors')
13+
const phaseHelper = require('../common/phase-helper')
1314
const constants = require('../../app-constants')
1415
const models = require('../models')
1516
const HttpStatus = require('http-status-codes')
@@ -18,6 +19,7 @@ const PhaseService = require('./PhaseService')
1819
const ChallengeTypeService = require('./ChallengeTypeService')
1920
const ChallengeTrackService = require('./ChallengeTrackService')
2021
const ChallengeTimelineTemplateService = require('./ChallengeTimelineTemplateService')
22+
const { BadRequestError } = require('../common/errors')
2123

2224
const esClient = helper.getESClient()
2325

@@ -839,102 +841,6 @@ async function validateChallengeData (challenge) {
839841
return { type, track }
840842
}
841843

842-
/**
843-
* Populate challenge phases.
844-
* @param {Array} phases the phases to populate
845-
* @param {Date} startDate the challenge start date
846-
* @param {String} timelineTemplateId the timeline template id
847-
*/
848-
async function populatePhases (phases, startDate, timelineTemplateId) {
849-
if (_.isUndefined(timelineTemplateId)) {
850-
throw new errors.BadRequestError(`Invalid timeline template ID: ${timelineTemplateId}`)
851-
}
852-
const template = await helper.getById('TimelineTemplate', timelineTemplateId)
853-
if (!phases || phases.length === 0) {
854-
// auto populate phases
855-
for (const p of template.phases) {
856-
phases.push({
857-
phaseId: p.phaseId,
858-
duration: p.defaultDuration
859-
})
860-
}
861-
}
862-
const phaseDefinitions = await helper.scan('Phase')
863-
// generate phase instance ids
864-
for (let i = 0; i < phases.length; i += 1) {
865-
phases[i].id = uuid()
866-
}
867-
868-
for (let i = 0; i < phases.length; i += 1) {
869-
const phase = phases[i]
870-
const templatePhase = _.find(template.phases, (p) => p.phaseId === phase.phaseId)
871-
const phaseDefinition = _.find(phaseDefinitions, (p) => p.id === phase.phaseId)
872-
phase.name = _.get(phaseDefinition, 'name')
873-
phase.isOpen = _.get(phase, 'isOpen', false)
874-
if (templatePhase) {
875-
// use default duration if not provided
876-
if (!phase.duration) {
877-
phase.duration = templatePhase.defaultDuration
878-
}
879-
// set predecessor
880-
if (templatePhase.predecessor) {
881-
const prePhase = _.find(phases, (p) => p.phaseId === templatePhase.predecessor)
882-
if (!prePhase) {
883-
throw new errors.BadRequestError(`Predecessor ${templatePhase.predecessor} not found from given phases.`)
884-
}
885-
phase.predecessor = prePhase.phaseId
886-
}
887-
}
888-
}
889-
890-
// calculate dates
891-
if (!startDate) {
892-
return
893-
}
894-
const done = []
895-
for (let i = 0; i < phases.length; i += 1) {
896-
done.push(false)
897-
}
898-
let doing = true
899-
while (doing) {
900-
doing = false
901-
for (let i = 0; i < phases.length; i += 1) {
902-
if (!done[i]) {
903-
const phase = phases[i]
904-
if (!phase.predecessor) {
905-
phase.scheduledStartDate = startDate
906-
phase.scheduledEndDate = moment(startDate).add(phase.duration || 0, 'seconds').toDate()
907-
phase.actualStartDate = phase.scheduledStartDate
908-
done[i] = true
909-
doing = true
910-
} else {
911-
const preIndex = _.findIndex(phases, (p) => p.phaseId === phase.predecessor)
912-
let canProcess = true
913-
if (preIndex < 0) {
914-
canProcess = false
915-
delete phase.predecessor
916-
i -= 1
917-
}
918-
if (canProcess && done[preIndex]) {
919-
phase.scheduledStartDate = phases[preIndex].scheduledEndDate
920-
phase.scheduledEndDate = moment(phase.scheduledStartDate).add(phase.duration || 0, 'seconds').toDate()
921-
phase.actualStartDate = phase.scheduledStartDate
922-
done[i] = true
923-
doing = true
924-
}
925-
}
926-
}
927-
}
928-
}
929-
// validate that all dates are calculated
930-
for (let i = 0; i < phases.length; i += 1) {
931-
if (!done[i]) {
932-
throw new Error(`Invalid phase predecessor: ${phases[i].predecessor}`)
933-
}
934-
}
935-
phases.sort((a, b) => moment(a.scheduledStartDate).isAfter(b.scheduledStartDate))
936-
}
937-
938844
/**
939845
* Create challenge.
940846
* @param {Object} currentUser the user who perform operation
@@ -1004,7 +910,7 @@ async function createChallenge (currentUser, challenge, userToken) {
1004910
}
1005911
}
1006912
if (challenge.phases && challenge.phases.length > 0) {
1007-
await helper.validatePhases(challenge.phases)
913+
await phaseHelper.validatePhases(challenge.phases)
1008914
}
1009915
helper.ensureNoDuplicateOrNullElements(challenge.tags, 'tags')
1010916
helper.ensureNoDuplicateOrNullElements(challenge.groups, 'groups')
@@ -1036,7 +942,7 @@ async function createChallenge (currentUser, challenge, userToken) {
1036942
if (!challenge.phases) {
1037943
challenge.phases = []
1038944
}
1039-
await populatePhases(challenge.phases, challenge.startDate, challenge.timelineTemplateId)
945+
await phaseHelper.populatePhases(challenge.phases, challenge.startDate, challenge.timelineTemplateId)
1040946
}
1041947

1042948
// populate challenge terms
@@ -1164,7 +1070,11 @@ createChallenge.schema = {
11641070
timelineTemplateId: Joi.string(), // Joi.optionalId(),
11651071
phases: Joi.array().items(Joi.object().keys({
11661072
phaseId: Joi.id(),
1167-
duration: Joi.number().integer().min(0)
1073+
duration: Joi.number().integer().min(0),
1074+
constraints: Joi.object().keys({
1075+
name: Joi.string(),
1076+
value: Joi.number().integer().min(0)
1077+
}).optional()
11681078
})),
11691079
events: Joi.array().items(Joi.object().keys({
11701080
id: Joi.number().required(),
@@ -1620,6 +1530,10 @@ async function update (currentUser, challengeId, data, isFull) {
16201530
}
16211531

16221532
if (data.phases || data.startDate) {
1533+
if (challenge.status === constants.challengeStatuses.Completed || challenge.status.indexOf(constants.challengeStatuses.Cancelled) > -1) {
1534+
throw new BadRequestError(`Challenge phase/start date can not be modified for Completed or Cancelled challenges.`)
1535+
}
1536+
16231537
if (data.phases && data.phases.length > 0) {
16241538
for (let i = 0; i < challenge.phases.length; i += 1) {
16251539
const updatedPhaseInfo = _.find(data.phases, p => p.phaseId === challenge.phases[i].phaseId)
@@ -1635,10 +1549,10 @@ async function update (currentUser, challengeId, data, isFull) {
16351549
const newPhases = _.cloneDeep(challenge.phases) || []
16361550
const newStartDate = data.startDate || challenge.startDate
16371551

1638-
await helper.validatePhases(newPhases)
1552+
await phaseHelper.validatePhases(newPhases)
16391553
// populate phases
16401554

1641-
await populatePhases(newPhases, newStartDate, data.timelineTemplateId || challenge.timelineTemplateId)
1555+
await phaseHelper.populatePhases(newPhases, newStartDate, data.timelineTemplateId || challenge.timelineTemplateId)
16421556
data.phases = newPhases
16431557
challenge.phases = newPhases
16441558
data.startDate = newStartDate
@@ -2116,7 +2030,7 @@ function sanitizeChallenge (challenge) {
21162030
sanitized.metadata = _.map(challenge.metadata, meta => _.pick(meta, ['name', 'value']))
21172031
}
21182032
if (challenge.phases) {
2119-
sanitized.phases = _.map(challenge.phases, phase => _.pick(phase, ['phaseId', 'duration', 'isOpen', 'actualEndDate']))
2033+
sanitized.phases = _.map(challenge.phases, phase => _.pick(phase, ['phaseId', 'duration', 'isOpen', 'actualEndDate', 'scheduledStartDate', 'constraints']))
21202034
}
21212035
if (challenge.prizeSets) {
21222036
sanitized.prizeSets = _.map(challenge.prizeSets, prizeSet => ({
@@ -2199,7 +2113,12 @@ fullyUpdateChallenge.schema = {
21992113
phaseId: Joi.id(),
22002114
duration: Joi.number().integer().min(0),
22012115
isOpen: Joi.boolean(),
2202-
actualEndDate: Joi.date().allow(null)
2116+
actualEndDate: Joi.date().allow(null),
2117+
scheduledStartDate: Joi.date().allow(null),
2118+
constraints: Joi.array().items(Joi.object().keys({
2119+
name: Joi.string(),
2120+
value: Joi.number().integer().min(0)
2121+
}).optional()).optional()
22032122
}).unknown(true)),
22042123
prizeSets: Joi.array().items(Joi.object().keys({
22052124
type: Joi.string().valid(_.values(constants.prizeSetTypes)).required(),
@@ -2306,7 +2225,12 @@ partiallyUpdateChallenge.schema = {
23062225
phaseId: Joi.id(),
23072226
duration: Joi.number().integer().min(0),
23082227
isOpen: Joi.boolean(),
2309-
actualEndDate: Joi.date().allow(null)
2228+
actualEndDate: Joi.date().allow(null),
2229+
scheduledStartDate: Joi.date().allow(null),
2230+
constraints: Joi.array().items(Joi.object().keys({
2231+
name: Joi.string(),
2232+
value: Joi.number().integer().min(0)
2233+
}).optional()).optional()
23102234
}).unknown(true)).min(1),
23112235
events: Joi.array().items(Joi.object().keys({
23122236
id: Joi.number().required(),

‎src/services/TimelineTemplateService.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const _ = require('lodash')
66
const Joi = require('joi')
77
const uuid = require('uuid/v4')
88
const helper = require('../common/helper')
9+
const phaseHelper = require('../common/phase-helper')
910
// const logger = require('../common/logger')
1011
const constants = require('../../app-constants')
1112

@@ -40,7 +41,7 @@ searchTimelineTemplates.schema = {
4041
*/
4142
async function createTimelineTemplate (timelineTemplate) {
4243
await helper.validateDuplicate('TimelineTemplate', 'name', timelineTemplate.name)
43-
await helper.validatePhases(timelineTemplate.phases)
44+
await phaseHelper.validatePhases(timelineTemplate.phases)
4445

4546
const ret = await helper.create('TimelineTemplate', _.assign({ id: uuid() }, timelineTemplate))
4647
// post bus event
@@ -89,7 +90,7 @@ async function update (timelineTemplateId, data, isFull) {
8990
}
9091

9192
if (data.phases) {
92-
await helper.validatePhases(data.phases)
93+
await phaseHelper.validatePhases(data.phases)
9394
}
9495

9596
if (isFull) {

‎yarn.lock

Lines changed: 4373 additions & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.