Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ADJUST1-294 Add validation for remand edit journey to check if remand periods overlap #124

Merged
merged 1 commit into from
Dec 22, 2023
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
48 changes: 29 additions & 19 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@
"redis": "^4.6.10",
"superagent": "^8.1.2",
"url-value-parser": "^2.2.0",
"uuid": "^9.0.1"
"uuid": "^9.0.1",
"date-fns": "^3.0.6"
},
"devDependencies": {
"@types/bunyan": "^1.8.9",
Expand Down
14 changes: 10 additions & 4 deletions server/model/abstractForm.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import { ValidationMessage } from '../@types/adjustments/adjustmentsTypes'
import { Adjustment, ValidationMessage } from '../@types/adjustments/adjustmentsTypes'
import { fieldHasErrors } from '../utils/utils'
import ValidationError from './validationError'
import { PrisonApiOffenderSentenceAndOffences } from '../@types/prisonApi/prisonClientTypes'
Expand All @@ -14,11 +14,17 @@ export default abstract class AbstractForm<T> {

errors: ValidationError[] = []

async validate(getSentences?: () => Promise<PrisonApiOffenderSentenceAndOffences[]>): Promise<void> {
this.errors = await this.validation(getSentences)
async validate(
getSentences?: () => Promise<PrisonApiOffenderSentenceAndOffences[]>,
getAdjustments?: () => Promise<Adjustment[]>,
): Promise<void> {
this.errors = await this.validation(getSentences, getAdjustments)
}

abstract validation(getSentences?: () => Promise<PrisonApiOffenderSentenceAndOffences[]>): Promise<ValidationError[]>
abstract validation(
getSentences?: () => Promise<PrisonApiOffenderSentenceAndOffences[]>,
getAdjustments?: () => Promise<Adjustment[]>,
): Promise<ValidationError[]>

protected validateDate(day: string, month: string, year: string, fieldPrefix: string): ValidationError {
if (!day && !month && !year) {
Expand Down
45 changes: 42 additions & 3 deletions server/model/remandDatesForm.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import dayjs from 'dayjs'
import { areIntervalsOverlapping } from 'date-fns'
import { Adjustment } from '../@types/adjustments/adjustmentsTypes'
import AbstractForm from './abstractForm'
import ValidationError from './validationError'
import { dateItems, fieldsToDate, isDateInFuture, isFromAfterTo } from '../utils/utils'
import { dateItems, dateToString, fieldsToDate, isDateInFuture, isFromAfterTo } from '../utils/utils'
import { PrisonApiOffenderSentenceAndOffences } from '../@types/prisonApi/prisonClientTypes'

export default class RemandDatesForm extends AbstractForm<RemandDatesForm> {
Expand All @@ -18,6 +19,10 @@ export default class RemandDatesForm extends AbstractForm<RemandDatesForm> {

'to-year': string

isEdit: boolean

adjustmentId?: string

toAdjustment(adjustment: Adjustment): Adjustment {
const fromDate = dayjs(`${this['from-year']}-${this['from-month']}-${this['from-day']}`).format('YYYY-MM-DD')
const toDate = dayjs(`${this['to-year']}-${this['to-month']}-${this['to-day']}`).format('YYYY-MM-DD')
Expand All @@ -32,7 +37,10 @@ export default class RemandDatesForm extends AbstractForm<RemandDatesForm> {
return dateItems(this['to-year'], this['to-month'], this['to-day'], 'to', this.errors)
}

async validation(getSentences?: () => Promise<PrisonApiOffenderSentenceAndOffences[]>): Promise<ValidationError[]> {
async validation(
getSentences?: () => Promise<PrisonApiOffenderSentenceAndOffences[]>,
getAdjustments?: () => Promise<Adjustment[]>,
): Promise<ValidationError[]> {
const errors = []
const fromDateError = this.validateDate(this['from-day'], this['from-month'], this['from-year'], 'from')
if (fromDateError) {
Expand Down Expand Up @@ -75,8 +83,8 @@ export default class RemandDatesForm extends AbstractForm<RemandDatesForm> {
const sentencesWithOffenceDates = activeSentences.filter(it => it.offences.filter(o => o.offenceStartDate).length)

if (sentencesWithOffenceDates.length) {
const fromDate = fieldsToDate(this['from-day'], this['from-month'], this['from-year'])
const minOffenceDate = this.getMinOffenceDate(sentencesWithOffenceDates)
const fromDate = fieldsToDate(this['from-year'], this['from-month'], this['from-day'])
if (fromDate < minOffenceDate) {
errors.push({
text: `The remand period cannot start before the earliest offence date, on ${dayjs(minOffenceDate).format(
Expand All @@ -87,6 +95,37 @@ export default class RemandDatesForm extends AbstractForm<RemandDatesForm> {
}
}

// Edit specific validation
if (this.isEdit) {
errors.push(...(await this.validationForEdit(getAdjustments)))
}

return errors
}

private async validationForEdit(getAdjustments?: () => Promise<Adjustment[]>): Promise<ValidationError[]> {
const errors = []
const fromDate = fieldsToDate(this['from-day'], this['from-month'], this['from-year'])
const toDate = fieldsToDate(this['to-day'], this['to-month'], this['to-year'])
const adjustments = (await getAdjustments()).filter(
it => it.adjustmentType === 'REMAND' && it.id !== this.adjustmentId,
)
const overlappingAdjustment = adjustments.find(a =>
areIntervalsOverlapping(
{ start: new Date(a.fromDate), end: new Date(a.toDate) },
{ start: fromDate, end: toDate },
{ inclusive: true },
),
)

if (overlappingAdjustment) {
errors.push({
text: `The remand dates overlap with another remand period ${dateToString(
new Date(overlappingAdjustment.fromDate),
)} to ${dateToString(new Date(overlappingAdjustment.toDate))}`,
fields: ['from-day', 'from-month', 'from-year', 'to-day', 'to-month', 'to-year'],
})
}
return errors
}

Expand Down
76 changes: 76 additions & 0 deletions server/routes/remandRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ describe('Adjustment routes tests', () => {
adjustmentsStoreService.getById.mockReturnValue(blankAdjustment)
prisonerService.getSentencesAndOffencesFilteredForRemand.mockResolvedValue(stubbedSentencesAndOffences)
adjustmentsService.validate.mockResolvedValue([])
adjustmentsService.findByPerson.mockResolvedValue([])
return request(app)
.post(`/${NOMS_ID}/remand/dates/${addOrEdit}/${SESSION_ID}`)
.send({
Expand All @@ -211,6 +212,7 @@ describe('Adjustment routes tests', () => {
prisonerService.getPrisonerDetail.mockResolvedValue(stubbedPrisonerData)
adjustmentsStoreService.getAll.mockReturnValue(adjustments)
adjustmentsStoreService.getById.mockReturnValue(blankAdjustment)
adjustmentsService.findByPerson.mockResolvedValue([])
prisonerService.getSentencesAndOffencesFilteredForRemand.mockResolvedValue(stubbedSentencesAndOffences)
return request(app)
.post(`/${NOMS_ID}/remand/dates/${addOrEdit}/${SESSION_ID}`)
Expand All @@ -230,6 +232,7 @@ describe('Adjustment routes tests', () => {
prisonerService.getPrisonerDetail.mockResolvedValue(stubbedPrisonerData)
adjustmentsStoreService.getAll.mockReturnValue(adjustments)
adjustmentsStoreService.getById.mockReturnValue(blankAdjustment)
adjustmentsService.findByPerson.mockResolvedValue([])
prisonerService.getSentencesAndOffencesFilteredForRemand.mockResolvedValue(stubbedSentencesAndOffences)
return request(app)
.post(`/${NOMS_ID}/remand/dates/${addOrEdit}/${SESSION_ID}`)
Expand Down Expand Up @@ -258,6 +261,7 @@ describe('Adjustment routes tests', () => {
prisonerService.getPrisonerDetail.mockResolvedValue(stubbedPrisonerData)
adjustmentsStoreService.getAll.mockReturnValue(adjustments)
adjustmentsStoreService.getById.mockReturnValue(blankAdjustment)
adjustmentsService.findByPerson.mockResolvedValue([])
prisonerService.getSentencesAndOffencesFilteredForRemand.mockResolvedValue(stubbedSentencesAndOffences)
return request(app)
.post(`/${NOMS_ID}/remand/dates/${addOrEdit}/${SESSION_ID}`)
Expand Down Expand Up @@ -287,6 +291,7 @@ describe('Adjustment routes tests', () => {
prisonerService.getPrisonerDetail.mockResolvedValue(stubbedPrisonerData)
adjustmentsStoreService.getAll.mockReturnValue(adjustments)
adjustmentsStoreService.getById.mockReturnValue(blankAdjustment)
adjustmentsService.findByPerson.mockResolvedValue([])
prisonerService.getSentencesAndOffencesFilteredForRemand.mockResolvedValue(stubbedSentencesAndOffences)
return request(app)
.post(`/${NOMS_ID}/remand/dates/${addOrEdit}/${SESSION_ID}`)
Expand Down Expand Up @@ -317,6 +322,7 @@ describe('Adjustment routes tests', () => {
prisonerService.getPrisonerDetail.mockResolvedValue(stubbedPrisonerData)
adjustmentsStoreService.getAll.mockReturnValue(adjustments)
adjustmentsStoreService.getById.mockReturnValue(blankAdjustment)
adjustmentsService.findByPerson.mockResolvedValue([])
prisonerService.getSentencesAndOffencesFilteredForRemand.mockResolvedValue([
{ ...sentenceAndOffenceBaseRecord, offences: offencesWithAndWithoutStartDates },
])
Expand Down Expand Up @@ -352,6 +358,7 @@ describe('Adjustment routes tests', () => {
prisonerService.getPrisonerDetail.mockResolvedValue(stubbedPrisonerData)
adjustmentsStoreService.getAll.mockReturnValue(adjustments)
adjustmentsStoreService.getById.mockReturnValue(blankAdjustment)
adjustmentsService.findByPerson.mockResolvedValue([])
prisonerService.getSentencesAndOffencesFilteredForRemand.mockResolvedValue([
{ ...sentenceAndOffenceBaseRecord, offences: offencesWithoutStartDates },
])
Expand All @@ -370,6 +377,75 @@ describe('Adjustment routes tests', () => {
.expect('Location', redirectLocation)
},
)

test.each([
{
adjustment: {
...blankAdjustment,
fromDate: '2023-01-06',
toDate: '2023-01-10',
id: '123-abb',
from: '06 Jan 2023',
to: '10 Jan 2023',
},
from: { year: '2023', month: '01', day: '01' },
to: { year: '2023', month: '03', day: '20' },
id: '9991',
},
{
adjustment: {
...blankAdjustment,
fromDate: '2023-01-07',
toDate: '2023-01-10',
id: '123-abb',
from: '07 Jan 2023',
to: '10 Jan 2023',
},
from: { year: '2023', month: '01', day: '01' },
to: { year: '2023', month: '01', day: '07' },
id: '9992',
},
{
adjustment: {
...blankAdjustment,
fromDate: '2023-01-06',
toDate: '2023-01-10',
id: '123-abb',
from: '06 Jan 2023',
to: '10 Jan 2023',
},
from: { year: '2023', month: '01', day: '10' },
to: { year: '2023', month: '01', day: '1' },
id: '9993',
},
])('POST /{nomsId}/remand/dates/addOrEdit overlapping remand periods', ({ adjustment, from, to, id }) => {
const adjustments = {}
adjustments[SESSION_ID] = blankAdjustment
prisonerService.getPrisonerDetail.mockResolvedValue(stubbedPrisonerData)
adjustmentsStoreService.getAll.mockReturnValue(adjustments)
adjustmentsStoreService.getById.mockReturnValue(blankAdjustment)
adjustmentsService.findByPerson.mockResolvedValue([adjustment, { ...adjustment, id }])
prisonerService.getSentencesAndOffencesFilteredForRemand.mockResolvedValue([
{ ...sentenceAndOffenceBaseRecord, offences: offencesWithAndWithoutStartDates },
])
return request(app)
.post(`/${NOMS_ID}/remand/dates/edit/${id}`)
.send({
'from-day': from.day,
'from-month': from.month,
'from-year': from.year,
'to-day': to.day,
'to-month': to.month,
'to-year': to.year,
})
.type('form')
.expect('Content-Type', /html/)
.expect(res => {
expect(res.text).toContain(
`The remand dates overlap with another remand period ${adjustment.from} to ${adjustment.to}`,
)
})
})
})

describe('GET and POST tests for /{nomsId}/remand/dates/:addOrEdit', () => {
Expand Down
13 changes: 4 additions & 9 deletions server/routes/remandRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,11 @@ export default class RemandRoutes {
const { nomsId, addOrEdit, id } = req.params

const prisonerDetail = await this.prisonerService.getPrisonerDetail(nomsId, caseloads, token)
const adjustmentForm = new RemandDatesForm(req.body)
const adjustmentForm = new RemandDatesForm({ ...req.body, isEdit: addOrEdit === 'edit', adjustmentId: id })

await adjustmentForm.validate(() =>
this.prisonerService.getSentencesAndOffencesFilteredForRemand(prisonerDetail.bookingId, token),
await adjustmentForm.validate(
() => this.prisonerService.getSentencesAndOffencesFilteredForRemand(prisonerDetail.bookingId, token),
() => this.adjustmentsService.findByPerson(nomsId, token),
)

if (adjustmentForm.errors.length) {
Expand Down Expand Up @@ -323,12 +324,6 @@ export default class RemandRoutes {
)
this.adjustmentsStoreService.clear(req, nomsId)

// TODO copied this code in from the generic View route - need to double check what it's used for WIP
// Can be removed from the generic route once done
// const remandDecision =
// adjustmentType.value === 'REMAND' && roles.includes('REMAND_IDENTIFIER')
// ? await this.identifyRemandPeriodsService.getRemandDecision(nomsId, token)
// : null
return res.render('pages/adjustments/remand/view', {
model: new RemandViewModel(prisonerDetail, adjustments, sentencesAndOffences),
})
Expand Down
2 changes: 2 additions & 0 deletions server/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,5 @@ export function calculateReleaseDatesCheckInformationUrl(prisonerDetail: PrisonA

export const fieldsToDate = (day: string, month: string, year: string): Date =>
new Date(dayjs(`${year}-${month}-${day}`).format('YYYY-MM-DD'))

export const dateToString = (date: Date): string => dayjs(date).format('DD MMM YYYY')