Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 59 additions & 4 deletions src/common/phase-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ function isDesignTrack(track) {
return normalizeTrackToken(track) === DESIGN_TRACK;
}

/**
* Resolve the requested scheduled start date from a phase update payload.
*
* @param {Object} phase existing challenge phase
* @param {Object|null|undefined} newPhase phase update payload
* @returns {Date|String|undefined} requested scheduled start date, falling back to current start
*/
function resolveRequestedScheduledStartDate(phase, newPhase) {
if (_.isNil(newPhase) || _.isNil(_.get(newPhase, "scheduledStartDate"))) {
return _.get(phase, "scheduledStartDate");
}

return _.get(newPhase, "scheduledStartDate");
}

/**
* Resolve the requested scheduled end date from a phase update payload.
*
Expand All @@ -63,18 +78,47 @@ function resolveRequestedScheduledEndDate(phase, newPhase) {
}

const requestedDuration = _.get(newPhase, "duration");
if (_.isNil(requestedDuration) || _.isNil(phase.scheduledStartDate)) {
const requestedScheduledStartDate = resolveRequestedScheduledStartDate(phase, newPhase);
if (_.isNil(requestedDuration) || _.isNil(requestedScheduledStartDate)) {
return undefined;
}

const scheduledStart = moment(phase.scheduledStartDate);
const scheduledStart = moment(requestedScheduledStartDate);
if (!scheduledStart.isValid()) {
return undefined;
}

return scheduledStart.add(requestedDuration, "seconds").toDate().toISOString();
}

/**
* Check whether a requested schedule reduces the phase duration.
*
* @param {Object} phase existing challenge phase
* @param {Date|String|null|undefined} requestedScheduledStartDate requested phase start date
* @param {Date|String} requestedScheduledEndDate requested phase end date
* @returns {Boolean} true when requested duration is shorter than persisted duration
*/
function isPhaseDurationShortened(phase, requestedScheduledStartDate, requestedScheduledEndDate) {
const currentStart = moment(phase.scheduledStartDate);
const currentEnd = moment(phase.scheduledEndDate);
const requestedStart = moment(
_.defaultTo(requestedScheduledStartDate, phase.scheduledStartDate)
);
const requestedEnd = moment(requestedScheduledEndDate);

if (
!currentStart.isValid() ||
!currentEnd.isValid() ||
!requestedStart.isValid() ||
!requestedEnd.isValid()
) {
return requestedEnd.isBefore(currentEnd);
}

return requestedEnd.diff(requestedStart, "seconds") < currentEnd.diff(currentStart, "seconds");
}

/**
* Validate a phase scheduled end date change against PM-5378 rules.
*
Expand All @@ -83,6 +127,7 @@ function resolveRequestedScheduledEndDate(phase, newPhase) {
* @param {Object} options validation options
* @param {Boolean} options.allowActivePhaseShortening whether Design track phase shortening is allowed
* @param {Boolean} options.preventPhaseShortening whether shortening is guarded for all incomplete phases
* @param {Date|String|null|undefined} options.requestedScheduledStartDate requested scheduled start date
* @returns {undefined} validates only
* @throws {BadRequestError} when phase shortening is disallowed or would end in the past
*/
Expand Down Expand Up @@ -116,7 +161,14 @@ function validateActivePhaseScheduledEndDateChange(
phase.isOpen === true ||
options.allowActivePhaseShortening === true ||
options.preventPhaseShortening === true;
const isShortened = hasCurrentEnd && requestedEnd.isBefore(currentEnd);
const isShortened =
hasCurrentEnd &&
requestedEnd.isBefore(currentEnd) &&
isPhaseDurationShortened(
phase,
options.requestedScheduledStartDate,
requestedScheduledEndDate
);

if (shouldValidatePhaseEnd && requestedEnd.isBefore(moment())) {
throw new errors.BadRequestError(
Expand Down Expand Up @@ -335,7 +387,10 @@ class ChallengePhaseHelper {
validateActivePhaseScheduledEndDateChange(
phase,
resolveRequestedScheduledEndDate(phase, newPhase),
options
{
...options,
requestedScheduledStartDate: resolveRequestedScheduledStartDate(phase, newPhase),
}
);
const templatePredecessor = _.get(phaseFromTemplate, "predecessor");
// Prefer template predecessor only when that phase exists on the challenge, otherwise keep the stored link.
Expand Down
4 changes: 4 additions & 0 deletions src/services/ChallengePhaseService.js
Original file line number Diff line number Diff line change
Expand Up @@ -934,9 +934,13 @@ async function partiallyUpdateChallengePhase(currentUser, challengeId, id, data)
const allowActivePhaseShortening = phaseHelper.isDesignTrack(challenge.track);
const preventPhaseShortening =
challenge.status === ChallengeStatusEnum.ACTIVE && !allowActivePhaseShortening;
const requestedScheduledStartDate = !_.isNil(data.scheduledStartDate)
? data.scheduledStartDate
: challengePhase.scheduledStartDate;
phaseHelper.validateActivePhaseScheduledEndDateChange(challengePhase, data.scheduledEndDate, {
allowActivePhaseShortening,
preventPhaseShortening,
requestedScheduledStartDate,
});
const dataToUpdate = _.omit(data, "constraints");
const shouldRefreshPhaseNames =
Expand Down
94 changes: 94 additions & 0 deletions test/unit/phase-helper.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,100 @@ describe('phase helper unit tests', () => {
updatedPhases[0].duration.should.equal(24 * 60 * 60)
})

it('allows active non-Design phase start to move earlier when duration is unchanged', async () => {
const registrationPhaseId = 'development-registration-phase'
const currentRegistrationStartDate = '2099-05-26T05:14:00.000Z'
const currentRegistrationEndDate = '2099-05-31T05:14:00.000Z'
const requestedRegistrationStartDate = '2099-05-25T05:14:00.000Z'
const requestedRegistrationEndDate = '2099-05-30T05:14:00.000Z'
const duration = 5 * 24 * 60 * 60

stubPhaseLookups(
[{ id: registrationPhaseId, name: 'Registration', description: 'Registration phase' }],
[{ phaseId: registrationPhaseId, defaultDuration: duration }]
)

const updatedPhases = await phaseHelper.populatePhasesForChallengeUpdate(
[
{
duration,
isOpen: true,
name: 'Registration',
phaseId: registrationPhaseId,
scheduledStartDate: currentRegistrationStartDate,
scheduledEndDate: currentRegistrationEndDate
}
],
[
{
duration,
phaseId: registrationPhaseId,
scheduledStartDate: requestedRegistrationStartDate,
scheduledEndDate: requestedRegistrationEndDate
}
],
'timeline-template-id',
false,
{
allowActivePhaseShortening: false,
preventPhaseShortening: true
}
)

updatedPhases[0].scheduledStartDate.should.equal(requestedRegistrationStartDate)
updatedPhases[0].scheduledEndDate.should.equal(requestedRegistrationEndDate)
updatedPhases[0].duration.should.equal(duration)
})

it('rejects active non-Design phase updates that shorten duration after moving start earlier', async () => {
const registrationPhaseId = 'development-registration-phase'
const currentRegistrationStartDate = '2099-05-26T05:14:00.000Z'
const currentRegistrationEndDate = '2099-05-31T05:14:00.000Z'
const requestedRegistrationStartDate = '2099-05-25T05:14:00.000Z'
const requestedRegistrationEndDate = '2099-05-29T05:14:00.000Z'
const duration = 5 * 24 * 60 * 60

stubPhaseLookups(
[{ id: registrationPhaseId, name: 'Registration', description: 'Registration phase' }],
[{ phaseId: registrationPhaseId, defaultDuration: duration }]
)

try {
await phaseHelper.populatePhasesForChallengeUpdate(
[
{
duration,
isOpen: true,
name: 'Registration',
phaseId: registrationPhaseId,
scheduledStartDate: currentRegistrationStartDate,
scheduledEndDate: currentRegistrationEndDate
}
],
[
{
phaseId: registrationPhaseId,
scheduledStartDate: requestedRegistrationStartDate,
scheduledEndDate: requestedRegistrationEndDate
}
],
'timeline-template-id',
false,
{
allowActivePhaseShortening: false,
preventPhaseShortening: true
}
)
} catch (e) {
e.message.should.equal(
'Challenge phase schedules can only be shortened for Design track challenges.'
)
return
}

throw new Error('should not reach here')
})

it('rejects active phase shortening for non-Design tracks', async () => {
const registrationPhaseId = 'development-registration-phase'
const staleDuration = 5 * 24 * 60 * 60
Expand Down
Loading