Skip to content

Commit

Permalink
Populate calendar with visit sessions
Browse files Browse the repository at this point in the history
  • Loading branch information
tpmcgowan committed May 16, 2024
1 parent a33789a commit 50755f9
Show file tree
Hide file tree
Showing 6 changed files with 192 additions and 111 deletions.
12 changes: 12 additions & 0 deletions server/@types/bapv.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,15 @@ export type FlashData = {
errors?: FieldValidationError[]
formValues?: Record<string, string | string[] | number[]>
}

// available visit sessions formatted in months/days for calendar component
export type VisitSessionsCalendar = {
selectedDate: string // TODO consider removing this from here and defaulting/validating in route?
months: Record<
string,
{
startDayColumn: number // the day column first date should start on (1 = Monday)
dates: Record<string, { reference: string; time: string; duration: string }[]>
}
>
}
2 changes: 2 additions & 0 deletions server/routes/bookingJourney/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,7 @@ export default function routes(services: Services): Router {
postWithValidation('/select-visitors', selectVisitorsController.validate(), selectVisitorsController.submit())

get('/select-date-and-time', selectVisitDateTimeController.view())
post('/select-date-and-time', selectVisitDateTimeController.submit())

return router
}
102 changes: 17 additions & 85 deletions server/routes/bookingJourney/selectVisitDateTimeController.ts
Original file line number Diff line number Diff line change
@@ -1,108 +1,40 @@
import type { RequestHandler } from 'express'
import { VisitSessionsService } from '../../services'

type CalendarData = {
selectedDate: string
months: Record<
string,
{
startDayColumn: number
dates: Record<string, { reference: string; time: string; duration: string }[]>
}
>
}

const calendarData: CalendarData = {
selectedDate: '2024-02-24',
months: {
'February 2024': {
startDayColumn: 4, // 1 = first date is a Monday
dates: {
'2024-02-22': [],
'2024-02-23': [],
'2024-02-24': [
{ reference: 'a', time: '10am to 11:30am', duration: '1 hour and 30 minutes' },
{ reference: 'b', time: '4:30pm to 6:30am', duration: '2 hours' },
],
'2024-02-25': [],
'2024-02-26': [{ reference: 'c', time: '11am to 12:30am', duration: '1 hour and 30 minutes' }],
'2024-02-27': [
{ reference: 'd', time: '9:30am to 10:30am', duration: '1 hour' },
{ reference: 'e', time: '1pm to 3pm', duration: '2 hours' },
],
'2024-02-28': [],
'2024-02-29': [
{ reference: 'f', time: '10am to 11:30am', duration: '1 hour and 30 minutes' },
{ reference: 'g', time: '4:30pm to 6:30am', duration: '2 hours' },
],
},
},

'March 2024': {
startDayColumn: 5,
dates: {
'2024-03-01': [],
'2024-03-02': [{ reference: 'h', time: '11am to 12:30am', duration: '1 hour and 30 minutes' }],
'2024-03-03': [
{ reference: 'i', time: '9:30am to 10:30am', duration: '1 hour' },
{ reference: 'j', time: '1pm to 3pm', duration: '2 hours' },
],
'2024-03-04': [],
'2024-03-05': [],
'2024-03-06': [
{ reference: 'k', time: '10am to 11:30am', duration: '1 hour and 30 minutes' },
{ reference: 'l', time: '4:30pm to 6:30am', duration: '2 hours' },
],
'2024-03-07': [],
'2024-03-08': [],
'2024-03-09': [{ reference: 'm', time: '11am to 12:30am', duration: '1 hour and 30 minutes' }],
'2024-03-10': [
{ reference: 'n', time: '9:30am to 10:30am', duration: '1 hour' },
{ reference: 'o', time: '1pm to 3pm', duration: '2 hours' },
],
'2024-03-11': [],
'2024-03-12': [],
'2024-03-13': [
{ reference: 'p', time: '10am to 11:30am', duration: '1 hour and 30 minutes' },
{ reference: 'q', time: '4:30pm to 6:30am', duration: '2 hours' },
],
'2024-03-14': [{ reference: 'r', time: '11am to 12:30am', duration: '1 hour and 30 minutes' }],
'2024-03-15': [],
'2024-03-16': [
{ reference: 'd', time: '9:30am to 10:30am', duration: '1 hour' },
{ reference: 't', time: '1pm to 3pm', duration: '2 hours' },
],
'2024-03-17': [
{ reference: 'u', time: '10am to 11:30am', duration: '1 hour and 30 minutes' },
{ reference: 'v', time: '4:30pm to 6:30am', duration: '2 hours' },
],
'2024-03-18': [{ reference: 'w', time: '11am to 12:30am', duration: '1 hour and 30 minutes' }],
},
},
},
}

export default class SelectVisitDateTimeController {
public constructor(private readonly visitSessionsService: VisitSessionsService) {}

public view(): RequestHandler {
return async (req, res) => {
const { prisoner, selectedVisitors } = req.session.bookingJourney
const { prison, prisoner, selectedVisitors } = req.session.bookingJourney

const selectedVisitorIds = selectedVisitors.map(visitor => visitor.personId)

const visitSessions = await this.visitSessionsService.getVisitSessions(
const visitSessionsCalendar = await this.visitSessionsService.getVisitSessionsCalendar(
prisoner.prisonCode,
prisoner.prisonerNumber,
selectedVisitorIds,
prison.policyNoticeDaysMax,
)

res.render('pages/bookingJourney/selectVisitDateTime', {
booker: req.session.booker,
bookingJourney: req.session.bookingJourney,
visitSessions,
calendarData,
visitSessionsCalendar,
})
}
}

public submit(): RequestHandler {
return async (req, res) => {
const { visitSlot } = req.body

const sessionDate = visitSlot.split('_')[0]
const sessionTemplateReference = visitSlot.split('_')[1]

return res.send(
`<pre>Visit slot selected:\n\nsessionDate: ${sessionDate}\n\nsessionTemplateReference: ${sessionTemplateReference}</pre>`,

Check failure

Code scanning / CodeQL

Reflected cross-site scripting High

Cross-site scripting vulnerability due to a
user-provided value
.
)
}
}
}
104 changes: 99 additions & 5 deletions server/services/visitSessionsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import TestData from '../routes/testutils/testData'
import { createMockHmppsAuthClient, createMockOrchestrationApiClient } from '../data/testutils/mocks'
import VisitSessionsService from './visitSessionsService'
import { AvailableVisitSessionDto } from '../data/orchestrationApiTypes'
import { VisitSessionsCalendar } from '../@types/bapv'

const token = 'some token'

Expand All @@ -24,25 +25,118 @@ describe('Visit sessions service', () => {
jest.resetAllMocks()
})

describe('getVisitSessions', () => {
it('should return available visit sessions for prison / prisoner / visitors', async () => {
describe('getVisitSessionsCalendar', () => {
const fakeDate = new Date('2024-05-25')

beforeEach(() => {
jest.useFakeTimers({ advanceTimers: true, now: fakeDate })
})

afterEach(() => {
jest.useRealTimers()
})

it('should return a VisitSessionsCalendar with visit sessions for prison / prisoner / visitors', async () => {
const prisoner = TestData.prisonerInfoDto()
const visitorIds = [1, 2]
const daysAhead = 10 // the booking window 'policyNoticeDaysMax' for the prison
const visitSessions: AvailableVisitSessionDto[] = [
// first session after the fake start date to check we get empty dates at start
TestData.availableVisitSessionDto({
sessionDate: '2024-05-30',
sessionTemplateReference: 'a',
sessionTimeSlot: { startTime: '10:00', endTime: '11:30' },
}),
TestData.availableVisitSessionDto({
sessionDate: '2024-05-31',
sessionTemplateReference: 'b',
sessionTimeSlot: { startTime: '09:00', endTime: '09:45' },
}),
TestData.availableVisitSessionDto({
sessionDate: '2024-05-31', // second session on same day
sessionTemplateReference: 'c',
sessionTimeSlot: { startTime: '14:00', endTime: '15:00' },
}),
// next month - testing multiple months
TestData.availableVisitSessionDto({
sessionDate: '2024-06-03',
sessionTemplateReference: 'd',
sessionTimeSlot: { startTime: '09:00', endTime: '11:00' },
}),
]
orchestrationApiClient.getVisitSessions.mockResolvedValue(visitSessions)

const expectedVisitSessionsCalendar: VisitSessionsCalendar = {
selectedDate: '2024-05-30',
months: {
'May 2024': {
startDayColumn: 6, // first day is a Saturday; sixth calendar day column
dates: {
'2024-05-25': [],
'2024-05-26': [],
'2024-05-27': [],
'2024-05-28': [],
'2024-05-29': [],
'2024-05-30': [{ reference: 'a', time: '10am to 11:30am', duration: '1 hour and 30 minutes' }],
'2024-05-31': [
{ reference: 'b', time: '9am to 9:45am', duration: '45 minutes' },
{ reference: 'c', time: '2pm to 3pm', duration: '1 hour' },
],
},
},
'June 2024': {
startDayColumn: 6,
dates: {
'2024-06-01': [],
'2024-06-02': [],
'2024-06-03': [{ reference: 'd', time: '9am to 11am', duration: '2 hours' }],
'2024-06-04': [],
},
},
},
}

const results = await visitSessionsService.getVisitSessionsCalendar(
prisoner.prisonCode,
prisoner.prisonerNumber,
visitorIds,
daysAhead,
)

expect(orchestrationApiClient.getVisitSessions).toHaveBeenCalledWith(
prisoner.prisonCode,
prisoner.prisonerNumber,
visitorIds,
)
expect(results).toStrictEqual(expectedVisitSessionsCalendar)
})

// TODO add tests to cover different startDayColumn values (mon / sun) and year boundary

it('should return an empty VisitSessionsCalendar if no available visit sessions', async () => {
const prisoner = TestData.prisonerInfoDto()
const visitorIds = [1, 2]
const visitSessions: AvailableVisitSessionDto[] = [TestData.availableVisitSessionDto()]
const visitSessions: AvailableVisitSessionDto[] = []
orchestrationApiClient.getVisitSessions.mockResolvedValue(visitSessions)

const results = await visitSessionsService.getVisitSessions(
const expectedVisitSessionsCalendar: VisitSessionsCalendar = {
selectedDate: '',
months: {},
}

const results = await visitSessionsService.getVisitSessionsCalendar(
prisoner.prisonCode,
prisoner.prisonerNumber,
visitorIds,
28,
)

expect(orchestrationApiClient.getVisitSessions).toHaveBeenCalledWith(
prisoner.prisonCode,
prisoner.prisonerNumber,
visitorIds,
)
expect(results).toStrictEqual(visitSessions)
expect(results).toStrictEqual(expectedVisitSessionsCalendar)
})
})
})
58 changes: 57 additions & 1 deletion server/services/visitSessionsService.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { addDays, eachDayOfInterval, format, formatDuration, getISODay, intervalToDuration, parse } from 'date-fns'
import { VisitSessionsCalendar } from '../@types/bapv'
import { HmppsAuthClient, OrchestrationApiClient, RestClientBuilder } from '../data'
import { AvailableVisitSessionDto } from '../data/orchestrationApiTypes'

Expand All @@ -7,7 +9,61 @@ export default class VisitSessionsService {
private readonly hmppsAuthClient: HmppsAuthClient,
) {}

async getVisitSessions(
private readonly dateFormat = 'yyyy-MM-dd'

async getVisitSessionsCalendar(
prisonId: string,
prisonerId: string,
visitorIds: number[],
daysAhead: number,
): Promise<VisitSessionsCalendar> {
const visitSessions = await this.getVisitSessions(prisonId, prisonerId, visitorIds)

if (visitSessions.length === 0) {
return { selectedDate: '', months: {} }
}

const calendar: VisitSessionsCalendar = {
selectedDate: visitSessions[0].sessionDate, // TODO consider if this should be worked out in route?
months: {},
}

// generate all calendar dates: first is today; last is max booking window
const today = new Date()
const allCalendarDates = eachDayOfInterval({
start: format(today, this.dateFormat),
end: format(addDays(today, daysAhead), this.dateFormat),
})

allCalendarDates.forEach(date => {
const currentMonth = format(date, 'MMMM yyyy')
if (!calendar.months[currentMonth]) {
calendar.months[currentMonth] = { startDayColumn: getISODay(date), dates: {} }
}

const { dates } = calendar.months[currentMonth]
const thisDate = format(date, this.dateFormat)
const thisDateVisitSessions = visitSessions.filter(session => session.sessionDate === thisDate)

dates[thisDate] = thisDateVisitSessions.map(session => {
const startTime = parse(session.sessionTimeSlot.startTime, 'HH:mm', date)
const endTime = parse(session.sessionTimeSlot.endTime, 'HH:mm', date)

const startTimeFormatted = format(startTime, 'h:mmaaa').replace(':00', '')
const endTimeFormatted = format(endTime, 'h:mmaaa').replace(':00', '')

const duration = formatDuration(intervalToDuration({ start: startTime, end: endTime }), { delimiter: ' and ' })
return {
reference: session.sessionTemplateReference,
time: `${startTimeFormatted} to ${endTimeFormatted}`,
duration,
}
})
})
return calendar
}

private async getVisitSessions(
prisonId: string,
prisonerId: string,
visitorIds: number[],
Expand Down
Loading

0 comments on commit 50755f9

Please sign in to comment.