Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
0159780
Add models for clinic bookings and appointments
malross Feb 25, 2026
bacde11
Generate fake data for clinic bookings and their appointments
malross Feb 26, 2026
fb055be
Data generation functions for clinic bookings and appointments
malross Feb 26, 2026
32f100e
Improvements to generation of faked clinic bookings
malross Feb 27, 2026
c8149c2
Create fake parents with the Fosterer relationship
malross Feb 27, 2026
5f5da58
Add initial routes and controller for clinic bookings.
malross Feb 27, 2026
c311088
Add dump-only views for show and list on clinic bookings
malross Feb 27, 2026
2791244
refactor: De-duplicate the clinic booking reference generation
malross Mar 2, 2026
3f14263
Allow inspect() to remove context from arrays as well as single records
malross Mar 2, 2026
e70d48c
fix: Generate expected number of fake clinic bookings
malross Mar 2, 2026
53537d1
fix: redirect to correct route format after updating a clinic booking
malross Mar 2, 2026
a2dbf5e
Add clinic appointment faked data, routes and controller
malross Mar 2, 2026
d735cb9
Move parent details for clinic bookings into clinic appointments
malross Mar 5, 2026
961b5aa
Present details of a single clinic appointment in a new view
malross Mar 5, 2026
5e0e864
Show the details for a single clinic booking
malross Mar 5, 2026
80e0b55
Add the primary programme to a clinic booking and its routes
malross Mar 9, 2026
a5368d1
Create views for early pages in the clinic booking journey
malross Mar 11, 2026
42e355c
Fix content in the parent's clinic booking journey
malross Mar 12, 2026
e0a507a
Add more views to the parents' clinic booking journey
malross Mar 16, 2026
3526385
Minor: typo fixes and comments around clinc booking
malross Mar 16, 2026
52f6d7e
Hard-code health question sequence for clinic booking
malross Mar 16, 2026
13389da
Add hard-coded check answers page for clinic booking
malross Mar 16, 2026
bae88d1
Add confirmation page for clinic booking journey
malross Mar 16, 2026
2acd8f9
Use correct heading levels on clinic booking check answers page
malross Mar 16, 2026
0f29679
Move clinics' health questions to after booking confirmation
malross Mar 16, 2026
c7a3bad
Fix the forking for health questions in clinic booking
malross Mar 16, 2026
ef0afe7
Caption all appointment pages of the clinic booking journey
malross Mar 16, 2026
b2b92af
Use HPV, not MMR, immune system health question.
malross Mar 16, 2026
21534d7
Fix misplaced HTML in clinic booking's check answers page
malross Mar 17, 2026
acb7312
Fix all linter issues in clinic booking
malross Mar 17, 2026
4662b06
Add homepage links for booking into clinic journeys
malross Mar 17, 2026
67e7620
Create all appointment models in advance for clinic bookings
malross Mar 17, 2026
3652fa2
Reorganise the parent details for clinic bookings
malross Mar 17, 2026
df7f0c3
Add a parental responsibility block in the clinic booking journey
malross Mar 18, 2026
36d815e
Fix hard-coded date order in clinic booking
malross Mar 18, 2026
07c3438
Hide SAIS team navigation in parent's clinic booking journey
malross Mar 18, 2026
0fe0f82
Fix branching around parental responsibility in clinic booking
malross Mar 18, 2026
85c2227
Fix the branching for contact preferences in clinic booking
malross Mar 18, 2026
7aad1a5
Iterate over multiple appointments in clinic booking journey
malross Mar 18, 2026
f9553b6
Increase prominence of vaccine choice heading in clinic booking
malross Mar 19, 2026
8c33a39
Sanitise any _unchecked values coming from the clinic booking process
malross Mar 19, 2026
4f97d49
Fix linter issue with unused class import
malross Mar 19, 2026
6cde551
Show health questions for chosen vaccines for all appointments in cli…
malross Mar 20, 2026
ab39740
Clarify the need for 3 doses of the HPV vaccine in contexts that hand…
malross Mar 20, 2026
03f66a0
Clarify the points at which clinic booking subjourneys move onto the …
malross Mar 20, 2026
ddcff1f
Typography improvements
malross Mar 20, 2026
44e25e6
Consistent heaading sizes in the clinic booking journey
malross Mar 20, 2026
ec18003
Show correct header on child name page in clinic booking
malross Mar 23, 2026
c85c47e
Format clinic booking references as codes.
malross Mar 23, 2026
c2b43fb
Avoid race condition with session storage in clinic booking
malross Mar 23, 2026
574d038
Make clinic booking data generation more robust
malross Mar 23, 2026
ab51ae7
Fix generateClinicAppointment (#230)
malross Mar 23, 2026
17c24fb
Clinic booking fixes (#232)
malross Mar 25, 2026
576aeff
Fix linter warnings and remove/tidy up TODOs.
malross Mar 26, 2026
8084671
Prefer forEach over index-based for loop
malross Mar 26, 2026
eb08002
Prefer forEach over index-based for loop
malross Mar 26, 2026
1310b63
Don't use abbreviations for variable names
malross Mar 26, 2026
7e54966
Use English, not class names, in parameter descriptions.
malross Mar 26, 2026
1eba6c6
Favour the appHeading macro over hard-coded HTML
malross Mar 26, 2026
83c5ad0
Rename createInContext to match repo conventions
malross Mar 26, 2026
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
365 changes: 365 additions & 0 deletions app/controllers/book-into-a-clinic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,365 @@
import { fakerEN_GB as faker } from '@faker-js/faker'
import wizard from '@x-govuk/govuk-prototype-wizard'
import _ from 'lodash'

import { ParentalRelationship, SessionPresets } from '../enums.js'
import { ClinicAppointment, ClinicBooking } from '../models.js'
import {
getAllAppointmentPaths,
getHealthQuestionPaths
} from '../utils/clinic-appointment.js'
import { kebabToCamelCase } from '../utils/string.js'

/**
* @typedef {import('express').Request} Request
* @typedef {import('express').Response} Response
* @typedef {import('express').NextFunction} Next
*/

export const bookIntoClinicController = {
/**
* Record the session preset
*
* @param {Request} request
* @param {Response} response
* @param {Next} next
* @param {string} session_preset_slug
*/
read(request, response, next, session_preset_slug) {
Comment thread
paulrobertlloyd marked this conversation as resolved.
const serviceName = 'Book into a clinic'

response.locals.assetsName = 'public'
response.locals.serviceName = serviceName
response.locals.headerOptions = { service: { text: serviceName } }

// Record the session preset (aka "primary programme" to the parent)
const sessionPreset =
SessionPresets.find((preset) => preset.slug === session_preset_slug) ??
SessionPresets[0]
response.locals.sessionPreset = sessionPreset

// Allow us to offer a phone booking if not wanting online (start.njk)
response.locals.bookingPhoneNumber =
request.session.data.teams[0]?.tel ??
faker.helpers.replaceSymbols('01### ######')

next()
},

/**
* Send to the start page
*
* @param {Request} request
* @param {Response} response
*/
redirect(request, response) {
const { sessionPreset } = response.locals

response.redirect(`${request.baseUrl}/${sessionPreset.slug}/start`)
},

/**
* Start a new clinic booking for clinics with the primary programme we've been given
*
* @param {Request} request
* @param {Response} response
*/
new(request, response) {
const { data } = request.session
const { sessionPreset } = response.locals

// Create a new clinic booking in the wizard context
const booking = ClinicBooking.create(
{
sessionPreset
},
data.wizard
)

// Redirect to the first page in the booking journey (after the start page, that is)
const redirectUrl = `${request.baseUrl}/${booking.bookingUri}/new/child-count`
response.redirect(redirectUrl)
},

/**
* Prepare a form-based page of the clinic booking journey.
*
* This includes code to set up radio button groups for various pages (we set them up
* regardless of which specific route we're handling).
*
* @param {Request} request
* @param {Response} response
* @param {Next} next
*/
readForm(request, response, next) {
const { session_preset_slug, booking_uuid } = request.params
const appointment_uuid = request.params.appointment_uuid
const { data, referrer } = request.session

/**
* NOTE:
*
* The nature of the journey here is complex, as there are two separate sections in which we need to
* iterate over children. Or over appointments, if you want to think of it that way (each child has
* their own appointment). And the second iteration - the health questions - has pages that are
* dependent on the answers given during the appointment booking (specifically, the choice of vaccines
* per child).
*
* So, it goes:
* - Start page
* - How many children?
* - Child name <-- first page of the per-child appointment journey
* - Child DOB
* - ...
* - Appointment time <-- final page of the per-child appointment journey; iterate to next child if required
* - Parent info
* - Check answers
* - Health questions?
* - Health question 1 <-- first page of the per-child health question journey
* - ...
* - Health question n <-- final page of the per-child health question journey; iterate to next child if required
* - Confirmation
*
*/

// Create objects on the global context to allow us to check branching conditions, etc.
// And make them available to the view.
let booking, appointment
if (booking_uuid) {
booking = new ClinicBooking(
ClinicBooking.findOne(booking_uuid, data?.wizard),
data
)
response.locals.booking = booking

if (appointment_uuid) {
appointment = new ClinicAppointment(
ClinicAppointment.findOne(appointment_uuid, data?.wizard),
data
)
response.locals.appointment = appointment
response.locals.childNumber =
booking.appointments_ids.indexOf(appointment.uuid) + 1
response.locals.childCount = booking.appointments_ids.length
response.locals.firstName = appointment.firstName || 'your child'
response.locals.fullName = appointment.fullName || 'your child'
}
}

// Make sure the views have access to information about flow control e.g. for narrowing down a clinic search
let transaction
if (data.wizard?.transaction) {
transaction = data.wizard?.transaction
response.locals.transaction = transaction
}

const journey = {
[`/${session_preset_slug}`]: {},
[`/${session_preset_slug}/${booking_uuid}/new/child-count`]: {},

// Appointment journey; once per child
...getAllAppointmentPaths(request.session.data, booking),

// Parent journey
[`/${session_preset_slug}/${booking_uuid}/new/parent`]: {
[`/${session_preset_slug}/${booking_uuid}/new/offer-health-questions`]:
() => !request.session.data.booking?.parent?.tel
},
[`/${session_preset_slug}/${booking_uuid}/new/contact-preference`]: {},

// Check answers
[`/${session_preset_slug}/${booking_uuid}/new/check-answers`]: {},

// Health questions (optional)
[`/${session_preset_slug}/${booking_uuid}/new/offer-health-questions`]: {
[`/${session_preset_slug}/${booking_uuid}/new/confirmation`]: {
data: 'transaction.optedIntoHealthQuestions',
value: 'false'
}
},

// For each child being booked in, and their selected vaccinations, ask the
// relevant health questions and impairments/adjustments questions
...getHealthQuestionPaths(
`/${session_preset_slug}/${booking_uuid}/new/`,
booking_uuid,
data.wizard,
data
),

// Confirmation! \o/
[`/${session_preset_slug}/${booking_uuid}/new/confirmation`]: {}
}

const paths = wizard(journey, request)
paths.back = referrer || paths.back
response.locals.paths = paths // used later to redirect in updateForm

// Prepare the radio options for the parental relationship page
response.locals.parentalRelationshipItems = Object.values(
ParentalRelationship
)
.filter((relationship) => relationship !== ParentalRelationship.Unknown)
.map((relationship) => ({
text: relationship,
value: relationship
}))

next()
},

/**
* Render the requested form page
*
* @param {Request} request
* @param {Response} response
*/
showForm(request, response) {
const { appointment } = response.locals
let { booking_uuid, view } = request.params

// All health questions use the same view
let key
if (view.startsWith('health-question-')) {
key = kebabToCamelCase(view.replace('health-question-', ''))
view = 'health-question'
}

// Only ask for details if question does not have sub-questions
const hasSubQuestions =
appointment?.getHealthQuestionsForSelectedProgrammes(
request.session.data
)[key]?.conditional

// Build the options for the selection of a home address address from those already entered
if (view === 'address-selection') {
const booking = ClinicBooking.findOne(
booking_uuid,
request.session.data.wizard
)
const previousAddressItems = booking.appointments
.map((appointment) => {
if (appointment.child?.address) {
const oneLineAddress = Object.values(appointment.child.address)
.filter((string) => string)
.join(', ')
return {
text: oneLineAddress,
value: appointment.uuid
}
}

return null
})
.filter(Boolean)

response.locals.previousAddressItems = [
...previousAddressItems,
{
divider: 'or'
},
{
text: 'Enter a different address',
value: 'new'
}
]
}

response.render(`book-into-a-clinic/form/${view}`, { key, hasSubQuestions })
},

/**
* Store the latest values entered into a form in the booking journey
*
* @param {Request} request
* @param {Response} response
*/
updateForm(request, response) {
const { booking_uuid, appointment_uuid, view } = request.params
const { data } = request.session
const { paths } = response.locals

// Store values from the posted form
if (request.body.booking) {
ClinicBooking.update(booking_uuid, request.body.booking, data.wizard)
}
if (request.body.appointment) {
ClinicAppointment.update(
appointment_uuid,
request.body.appointment,
data.wizard
)
}
if (request.body.transaction) {
data.wizard.transaction = data.wizard.transaction ?? {}
_.merge(data.wizard.transaction, request.body.transaction)
}

let nextUrl = paths.next

if (view === 'child-count') {
// We've just set the child count, so create the appointments we'll need
const booking = ClinicBooking.findOne(booking_uuid, data.wizard)

let desiredCount = Number(data.wizard.transaction.childCount)
desiredCount = isNaN(desiredCount) || desiredCount < 1 ? 1 : desiredCount
const existingCount = booking.appointments_ids.length

const childrenToAdd = Math.max(0, desiredCount - existingCount)
const childrenToRemove = Math.max(0, existingCount - desiredCount)
Array.from({ length: childrenToAdd }).forEach(() => {
const appointment = ClinicAppointment.create(
{ primary_programme_ids: booking.primaryProgrammeIDs },
data.wizard
)

booking.addAppointment(appointment)
})
Array.from({ length: childrenToRemove }).forEach(() => {
const appointment_uuid = booking.removeLastAppointment()
ClinicAppointment.delete(appointment_uuid, data.wizard)
})

// Start the appointment journey for the first child
const firstAppointment = booking.appointments[0]
const firstAppointmentUrl = `${request.baseUrl}/${booking.bookingUri}/new/${firstAppointment.appointmentUri}/child`
nextUrl = firstAppointmentUrl
} else if (
view === 'address-selection' &&
request.body.transaction.previousAddress !== 'new'
) {
// We've just selected a previous child's address for the current appointment, so copy
// that detail to the child record
const previous_appointment_uuid = request.body.transaction.previousAddress
const previousAppointment = ClinicAppointment.findOne(
previous_appointment_uuid,
data.wizard
)
const currentAppointment = ClinicAppointment.findOne(
appointment_uuid,
data.wizard
)

if (previousAppointment && currentAppointment) {
currentAppointment.child.address = previousAppointment.child.address
}
}

// NB: request.session.save was needed to avoid race condition issues on heroku
request.session.save((error) => {
if (!error) response.redirect(nextUrl)
})
},

/**
* Catch-all for pages not needing to reference a given clinic booking
*
* @param {Request} request
* @param {Response} response
*/
show(request, response) {
const view = request.params.view || 'start'

response.render(`book-into-a-clinic/${view}`)
}
}
Loading