diff --git a/app/controllers/book-into-a-clinic.js b/app/controllers/book-into-a-clinic.js new file mode 100644 index 000000000..6b707d498 --- /dev/null +++ b/app/controllers/book-into-a-clinic.js @@ -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) { + 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}`) + } +} diff --git a/app/controllers/clinic-appointment.js b/app/controllers/clinic-appointment.js new file mode 100644 index 000000000..97efb9641 --- /dev/null +++ b/app/controllers/clinic-appointment.js @@ -0,0 +1,34 @@ +import { ClinicAppointment } from '../models.js' +import { getResults, getPagination } from '../utils/pagination.js' + +export const clinicAppointmentController = { + read(request, response, next, clinic_appointment_uuid) { + response.locals.clinicAppointment = ClinicAppointment.findOne( + clinic_appointment_uuid, + request.session.data + ) + + next() + }, + + readAll(request, response, next) { + const clinicAppointments = ClinicAppointment.findAll(request.session.data) + + // // Sort - not available yet + // clinicAppointments = _.sortBy(clinicAppointments, 'createdAt') + + response.locals.clinicAppointments = clinicAppointments + response.locals.results = getResults(clinicAppointments, request.query) + response.locals.pages = getPagination(clinicAppointments, request.query) + + next() + }, + + show(request, response) { + response.render('clinic-appointment/show') + }, + + list(request, response) { + response.render('clinic-appointment/list') + } +} diff --git a/app/controllers/clinic-booking.js b/app/controllers/clinic-booking.js new file mode 100644 index 000000000..b085d0576 --- /dev/null +++ b/app/controllers/clinic-booking.js @@ -0,0 +1,34 @@ +import { ClinicBooking } from '../models.js' +import { getResults, getPagination } from '../utils/pagination.js' + +export const clinicBookingController = { + read(request, response, next, clinic_booking_uuid) { + response.locals.clinicBooking = ClinicBooking.findOne( + clinic_booking_uuid, + request.session.data + ) + + next() + }, + + readAll(request, response, next) { + const clinicBookings = ClinicBooking.findAll(request.session.data) + + // // Sort - not available yet + // clinicBookings = _.sortBy(clinicBookings, 'createdAt') + + response.locals.clinicBookings = clinicBookings + response.locals.results = getResults(clinicBookings, request.query) + response.locals.pages = getPagination(clinicBookings, request.query) + + next() + }, + + show(request, response) { + response.render('clinic-booking/show') + }, + + list(request, response) { + response.render('clinic-booking/list') + } +} diff --git a/app/data.js b/app/data.js index 267bca655..1b64ad7a7 100644 --- a/app/data.js +++ b/app/data.js @@ -1,5 +1,7 @@ import vaccines from './datasets/vaccines.js' import batches from '../.data/batches.json' with { type: 'json' } +import clinicAppointments from '../.data/clinic-appointments.json' with { type: 'json' } +import clinicBookings from '../.data/clinic-bookings.json' with { type: 'json' } import clinics from '../.data/clinics.json' with { type: 'json' } import instructions from '../.data/instructions.json' with { type: 'json' } import moves from '../.data/moves.json' with { type: 'json' } @@ -30,6 +32,8 @@ const team = teams['001'] */ const data = { batches, + clinicAppointments, + clinicBookings, clinics, counts: {}, defaultBatches: {}, diff --git a/app/datasets/health-questions.js b/app/datasets/health-questions.js index 28da20b87..7f99e1fa6 100644 --- a/app/datasets/health-questions.js +++ b/app/datasets/health-questions.js @@ -80,7 +80,7 @@ export const healthQuestions = { immuneSystemHpv: { label: 'Does the child have a disease or treatment that severely affects their immune system?', - hint: 'Children with a severely weakened immune system will need 3 doses of the vaccine, over a 12-month period.', + hint: 'Children with a severely weakened immune system will need 3 doses of the HPV vaccine, over a 12-month period.', options: false, detailsHint: false }, diff --git a/app/enums.js b/app/enums.js index f8fc4a5a2..911378259 100644 --- a/app/enums.js +++ b/app/enums.js @@ -443,6 +443,7 @@ export const SessionMMRConsent = { * @property {boolean} [adolescent] - Adolescent programme flag * @property {Array} programmeTypes - Preset programme types * @property {SchoolTerm} term - School term to schedule session + * @property {string} slug - URI part unique to this preset, used in clinic booking URIs */ /** @@ -454,27 +455,31 @@ export const SessionPresets = [ name: SessionPresetName.Flu, active: true, programmeTypes: [ProgrammeType.Flu], - term: SchoolTerm.Autumn + term: SchoolTerm.Autumn, + slug: 'flu' }, { name: SessionPresetName.HPV, active: true, adolescent: true, programmeTypes: [ProgrammeType.HPV], - term: SchoolTerm.Spring + term: SchoolTerm.Spring, + slug: 'hpv' }, { name: SessionPresetName.Doubles, active: true, adolescent: true, programmeTypes: [ProgrammeType.MenACWY, ProgrammeType.TdIPV], - term: SchoolTerm.Summer + term: SchoolTerm.Summer, + slug: 'doubles' }, { name: SessionPresetName.MMR, active: true, programmeTypes: [ProgrammeType.MMR], - term: SchoolTerm.Spring + term: SchoolTerm.Spring, + slug: 'mmr' } ] diff --git a/app/generators/clinic-appointment.js b/app/generators/clinic-appointment.js new file mode 100644 index 000000000..839222f3d --- /dev/null +++ b/app/generators/clinic-appointment.js @@ -0,0 +1,153 @@ +import { fakerEN_GB as faker } from '@faker-js/faker' +import { addMinutes } from 'date-fns' + +import { ParentalRelationship, SessionType } from '../enums.js' +import { ClinicAppointment } from '../models.js' +import { getAge } from '../utils/date.js' + +const clinicSlotLength = Number(process.env.CLINIC_SLOT_LENGTH) || 10 + +/** + * Generate fake clinic appointment + * + * @param {import('../models/clinic-booking.js').ClinicBooking} booking - The booking this appointment will belong to + * @param {object} context - The other data already defined (sessions, children, etc.) + * @returns {ClinicAppointment} A new, fake clinic appointment + */ +export function generateClinicAppointment(booking, context) { + const uuid = faker.string.uuid() + + // Find clinic sessions for this programme + const clinicSessions = Object.values(context.sessions).filter( + (session) => + session.type === SessionType.Clinic && + session.presetNames.includes(booking.sessionPreset.name) + ) + if (!clinicSessions.length) { + return null + } + + // Choose a clinic session to book this appointment into + const clinicSession = faker.helpers.arrayElement(clinicSessions) + if (!clinicSession) { + return null + } + const session_id = clinicSession.id + + // Work out the expected age range for children attending this session + const yearGroups = clinicSession.programmes.flatMap((programme) => [ + ...new Set(programme.yearGroups) + ]) + const minAge = yearGroups.length ? Math.min(...yearGroups) + 4 : 4 + const maxAge = yearGroups.length ? Math.max(...yearGroups) + 5 : 15 + + // Find/create a child of an appropriate age for the chosen clinic and its programme + let matchedPatient + if (faker.datatype.boolean(0.9)) { + const eligiblePatients = Object.values(context.patients).filter( + (patient) => { + const age = getAge(patient.dob) + return age >= minAge && age <= maxAge + } + ) + if (!eligiblePatients.length) { + return null + } + matchedPatient = faker.helpers.arrayElement(eligiblePatients) + } + const patient_uuid = matchedPatient?.uuid + + // Unmatched child details, if required + const unmatchedFirstName = matchedPatient + ? undefined + : faker.person.firstName() + const unmatchedLastName = matchedPatient ? undefined : faker.person.lastName() + const unmatchedDob = matchedPatient + ? undefined + : faker.date.birthdate({ min: minAge, max: maxAge, mode: 'age' }) + + // Set up the relationship to the child for this appointment + const parent = booking.parent + let parentalRelationship, + parentalRelationshipOther, + parentHasParentalResponsibility + if (parent) { + const mumOrDad = [ + ParentalRelationship.Mum, + ParentalRelationship.Dad + ].includes(parent.relationship) + if (mumOrDad) { + // Mum or Dad initially, and most likely to stay that way + if (faker.datatype.boolean(0.1)) { + parentalRelationship = faker.helpers.arrayElement([ + ParentalRelationship.Fosterer, + ParentalRelationship.Guardian, + ParentalRelationship.Other + ]) + parentalRelationshipOther = + parentalRelationship === ParentalRelationship.Other + ? 'Grandparent' + : undefined + parentHasParentalResponsibility = true + } + } else { + // Fosterer, Guardian or Other + parentalRelationship = parent.relationship + parentalRelationshipOther = parent.relationshipOther + parentHasParentalResponsibility = parent.hasParentalResponsibility + } + } + + // Slot details (NB: session date is expected to specify midday) + const needsExtraTime = faker.datatype.boolean(0.2) + let extraTimeReason + if (needsExtraTime) { + const phobia = faker.helpers.weightedArrayElement([ + { value: 'needles', weight: 90 }, + { value: 'nurses', weight: 8 }, + { value: 'vaccines', weight: 2 } + ]) + extraTimeReason = `Suffers from anxiety regarding ${phobia}` + } + const startAt = addMinutes( + clinicSession.date, + faker.number.int({ min: 0, max: 60, multipleOf: clinicSlotLength }) + ) + const endAt = addMinutes(startAt, clinicSlotLength * (needsExtraTime ? 2 : 1)) + + // Have the child signed up for the clinic's primary programme plus a random selection of other programmes + const primary_programme_ids = clinicSession.programme_ids + const additionalProgramme_ids = Object.values(context.programmes) + .filter((p) => p.hidden !== true) + .map((p) => p.id) + .filter( + (id) => + !clinicSession.programme_ids.includes(id) && faker.datatype.boolean(0.2) + ) + const selected_programme_ids = [ + ...primary_programme_ids, + ...additionalProgramme_ids + ] + + return new ClinicAppointment( + { + uuid, + booking_uuid: booking.uuid, + patient_uuid, + unmatchedFirstName, + unmatchedLastName, + unmatchedDob, + needsExtraTime, + extraTimeReason, + parentalRelationship, + parentalRelationshipOther, + parentHasParentalResponsibility, + session_id, + startAt, + endAt, + selected_programme_ids, + primary_programme_ids + }, + context + ) +} diff --git a/app/generators/clinic-booking.js b/app/generators/clinic-booking.js new file mode 100644 index 000000000..3e5f509ce --- /dev/null +++ b/app/generators/clinic-booking.js @@ -0,0 +1,27 @@ +import { fakerEN_GB as faker } from '@faker-js/faker' + +import { SessionPresets } from '../enums.js' +import { ClinicBooking } from '../models.js' + +/** + * Generate fake clinic booking (initially without any appointments, which can be added later) + * + * @param {object} context + * @returns {ClinicBooking} ClinicBooking + */ +export function generateEmptyClinicBooking(context) { + const uuid = faker.string.uuid() + const bookingReference = ClinicBooking.generateReference() + const sessionPreset = faker.helpers.arrayElement( + SessionPresets.filter((preset) => preset.active) + ) + + return new ClinicBooking( + { + uuid, + bookingReference, + sessionPreset + }, + context + ) +} diff --git a/app/generators/parent.js b/app/generators/parent.js index c3148564e..cd9a5d0bf 100644 --- a/app/generators/parent.js +++ b/app/generators/parent.js @@ -19,8 +19,9 @@ export function generateParent(childLastName, isMum) { const relationship = isMum ? ParentalRelationship.Mum : faker.helpers.weightedArrayElement([ - { value: ParentalRelationship.Dad, weight: 3 }, + { value: ParentalRelationship.Dad, weight: 4 }, { value: ParentalRelationship.Guardian, weight: 1 }, + { value: ParentalRelationship.Fosterer, weight: 1 }, { value: ParentalRelationship.Other, weight: 1 } ]) @@ -72,7 +73,7 @@ export function generateParent(childLastName, isMum) { fullName: `${firstName} ${lastName}`, relationship, ...(relationship === ParentalRelationship.Other && { - relationshipOther: 'Foster parent' + relationshipOther: 'Grandparent' }), ...(email && { email, diff --git a/app/globals.js b/app/globals.js index c1baa0d57..abbba92f6 100644 --- a/app/globals.js +++ b/app/globals.js @@ -350,7 +350,17 @@ export default () => { if (!includeContext) { const contextlessData = structuredClone(data) - delete contextlessData.context + + // Remove context whether data is a single record or a collection of records + if (contextlessData.context) { + delete contextlessData.context + } else { + for (const item of Object.values(contextlessData)) { + if (item.context) { + delete item.context + } + } + } data = contextlessData } diff --git a/app/locales/en.js b/app/locales/en.js index 2a537e6ac..04f55a218 100644 --- a/app/locales/en.js +++ b/app/locales/en.js @@ -222,6 +222,264 @@ export const en = { label: 'Parent' } }, + clinicAppointment: { + label: 'Appointment details', + show: { + title: 'Clinic appointment for %s' + }, + nameAndAge: { + label: 'Child' + }, + location: { + label: 'Clinic location' + }, + date: { + label: 'Date' + }, + dateAndTime: { + label: 'Date and time' + }, + timeSlot: { + label: 'Time' + }, + vaccinations: { + label: 'Vaccinations' + } + }, + clinicBooking: { + start: { + title: { + [SessionPresetName.Flu]: + 'Book an appointment for your child’s flu vaccination', + [SessionPresetName.Doubles]: + 'Book an appointment for the MenACWY and Td/IPV vaccinations', + [SessionPresetName.HPV]: 'Book an appointment for the HPV vaccination', + [SessionPresetName.MMR]: + 'Book an appointment for an MMR or MMR(V) catch-up vaccination' + }, + primaryProgrammeInSentence: { + [SessionPresetName.Flu]: 'flu', + [SessionPresetName.Doubles]: 'MenACWY and Td/IPV', + [SessionPresetName.HPV]: 'HPV', + [SessionPresetName.MMR]: 'MMR or MMR(V)' + }, + confirm: { + title: 'Book an appointment', + buttonText: 'Start now' + }, + otherMethods: { + title: 'Other ways to book an appointment', + description: + 'The quickest way to book an appointment is online, using this service. This will take less than 5 minutes.\n\nIf you cannot use the service, you can book an appointment by phoning %s.' + } + }, + childCount: { + title: 'How many children do you need to book appointments for?', + description: + 'If you have more than one child invited to a clinic, you can book appointments for all of them.', + children: { + label: 'Number of children', + hint: 'For example, if you have twins needing vaccination, enter 2' + } + }, + nextChildButtonText: 'Continue to next child', + appointment: { + caption: 'Appointment for %s' + }, + child: { + title: { + first: 'What is your child’s name?', + next: 'What is your next child’s name?' + }, + caption: 'Appointment for your %s child', + summary: 'About your child', + description: + 'Give the name on your child’s birth certificate. If it’s changed, give the name held by your child’s GP.', + firstName: { + label: 'First name', + hint: 'Or given name' + }, + lastName: { + label: 'Last name', + hint: 'Or family name' + } + }, + dob: { + title: 'What is %s’s date of birth?', + hint: 'For example, 27 3 2012' + }, + address: { + title: 'What is %s’s home address?', + hint: 'Give the child’s primary address. We use this to confirm their identity.' + }, + addressSelection: { + title: 'What is %s’s home address?', + hint: 'Select the child’s primary address. We use this to confirm their identity.' + }, + parentalRelationship: { + title: 'What is your relationship to %s?', + hasParentalResponsibility: { + label: 'Do you have parental responsibility?', + hint: 'This means you have legal rights and duties relating to the child' + }, + relationshipOther: { + label: 'Relationship to the child' + }, + relationship: { + label: 'Relationship to child' + } + }, + parentalResponsibility: { + title: 'You will be unable to give consent', + description: + 'To give or refuse consent for a child’s vaccination, you need to have parental responsibility.\n\nIf you have any questions, please contact the local health organisation by calling {{team.tel}}, or email {{team.email}}.' + }, + vaccinationChoice: { + title: 'Do you agree to %s having the following vaccinations?', + vaccinations: { + label: 'Select the vaccinations that you agree to %s having', + hint: 'Each vaccine is given separately' + } + }, + extraTime: { + title: 'Does %s need extra time for their vaccination?', + hint: 'For example, they need longer than usual because they’re anxious about injections', + reason: { + label: 'Reason for needing extra time' + } + }, + preferredLocation: { + title: 'Find a clinic near where you’d like %s’s appointment', + location: { + label: 'Preferred clinic location', + hint: 'Enter a town, city, or postcode' + } + }, + preferredLocationMatches: { + title: 'We found 3 places that match “Newcastle”', + hits: { + label: 'Choose one of the following:' + }, + tryAgain: 'None of these — try another town, city, or postcode' + }, + clinicLocation: { + title: 'Choose a clinic location for %s', + hint: 'The following clinics are ordered by distance from NE12 7ET' + }, + clinicDate: { + title: 'Choose a clinic date for %s', + location: + 'Location: Killingworth Library, White Swan Centre, Killingworth, NE12 6SS', + date: { + label: 'Clinic date' + } + }, + timeRange: { + title: 'Choose a time range for %s’s appointment', + clinicSummary: { + title: 'Clinic' + }, + ranges: { + label: 'Available time ranges' + }, + range: { + slotsAvailable: + '{count, plural, =0 {No slots remaining} one {1 slot remaining} other {{count} slots remaining}}' + } + }, + time: { + title: 'Choose an appointment time for %s', + clinicSummary: { + title: 'Clinic' + }, + times: { + label: 'Available appointment times' + } + }, + parent: { + title: 'About you', + fullName: { + label: 'Full name' + }, + notify: { + label: 'Send notifications' + }, + email: { + label: 'Email address', + hint: 'We will use this to send you confirmation messages' + }, + tel: { + label: 'Phone number', + hint: 'Someone from the vaccinations team might call you if they have questions' + }, + sms: { + label: 'Tick this box if you’d like to get updates by text message' + }, + contactPreference: { + title: 'If we need to contact you', + label: 'Do you have any communication needs?', + yes: 'Yes', + no: 'No', + description: + 'Let us know if you have any communication needs you’d like us to be aware of — for example, a hearing or visual impairment.' + }, + contactPreferenceDetails: { + label: 'Give details' + }, + relationshipOther: { + label: 'Relationship to the child' + }, + hasParentalResponsibility: { + label: 'Do you have parental responsibility?', + hint: 'This means you have legal rights and duties relating to the child' + } + }, + offerHealthQuestions: { + title: 'We’ve got your vaccination booking request', + bookingReference: 'Your booking reference number is: %s', + beforeYouGo: + 'Before you finish using the service, we’d like to ask some questions about your child’s health.\n\nThese questions help us make sure it’s safe to vaccinate. You can answer these questions at the clinic, but responding now will save time on the day.', + label: 'Answer the health questions?', + yes: 'Yes, answer the health questions now', + no: 'No, I’ll do it later' + }, + healthAnswers: { + label: 'Answers to health questions', + caption: 'Health questions for %s', + yes: 'Yes', + no: 'No', + details: 'Give details' + }, + 'check-answers': { + confirm: 'Confirm', + title: 'Check and confirm' + }, + confirmation: { + title: 'Booking complete', + subtitle: '

Your reference number:
%s

' + }, + show: { + title: 'Manage your booking', + introduction: + 'Check your appointment details and make changes where needed.', + appointment: { + title: 'Appointment %s', + change: { + label: 'Change appointment' + }, + cancel: { + label: 'Cancel appointment' + } + }, + parent: { + title: 'Your details', + change: { + label: 'Change my details' + } + }, + referenceNumber: 'Your booking reference number is: %s' + } + }, consent: { label: 'Consent response', title: 'Review consent responses', diff --git a/app/middleware/navigation.js b/app/middleware/navigation.js index 637afb6d7..2ff68022e 100644 --- a/app/middleware/navigation.js +++ b/app/middleware/navigation.js @@ -1,5 +1,6 @@ import { SessionPresetName } from '../enums.js' import { Session } from '../models.js' +import { getClinicBookingUrl } from '../utils/clinic-booking.js' import { formatDate, today } from '../utils/date.js' import { getSessionConsentUrl } from '../utils/session.js' @@ -23,6 +24,12 @@ export const navigation = (request, response, next) => { HPV: getSessionConsentUrl(sessions, SessionPresetName.HPV), Doubles: getSessionConsentUrl(sessions, SessionPresetName.Doubles), 'MMR(V)': getSessionConsentUrl(sessions, SessionPresetName.MMR) + }, + clinicBookingUrl: { + Flu: getClinicBookingUrl(SessionPresetName.Flu), + HPV: getClinicBookingUrl(SessionPresetName.HPV), + Doubles: getClinicBookingUrl(SessionPresetName.Doubles), + 'MMR(V)': getClinicBookingUrl(SessionPresetName.MMR) } } diff --git a/app/models.js b/app/models.js index 96a103c88..15661e550 100644 --- a/app/models.js +++ b/app/models.js @@ -7,6 +7,8 @@ export * from './models/reply.js' // Other classes export * from './models/audit-event.js' export * from './models/clinic.js' +export * from './models/clinic-appointment.js' +export * from './models/clinic-booking.js' export * from './models/consent.js' export * from './models/default-batch.js' export * from './models/download.js' diff --git a/app/models/clinic-appointment.js b/app/models/clinic-appointment.js new file mode 100644 index 000000000..cc0eee00f --- /dev/null +++ b/app/models/clinic-appointment.js @@ -0,0 +1,389 @@ +import { fakerEN_GB as faker } from '@faker-js/faker' +import _ from 'lodash' + +import { + Child, + ClinicBooking, + Parent, + Patient, + Programme, + Session +} from '../models.js' +import { formatDate, getDateValueDifference } from '../utils/date.js' +import { stringToArray, stringToBoolean } from '../utils/string.js' + +/** + * @class ClinicAppointment + * @param {object} options - Options + * @param {object} [context] - Context + * @property {object} [context] - Context + * @property {string} uuid - Unique ID for this clinic appointment + * @property {string} booking_uuid - Unique ID for the booking in which this appointment was made + * @property {string} [patient_uuid] - Patient UUID (if matched to a patient record) + * @property {import('./child.js').Child} [child] - child details recorded from form input values + * @property {boolean} needsExtraTime - Does the child need extra time for their vaccinations? + * @property {string} [extraTimeReason] - The reason why the child needs extra time for their appointment + * @property {import('../enums.js').ParentalRelationship} [parentalRelationship] - The relationship of the person booking the appointment to the child + * @property {string} [parentalRelationshipOther] - User-defined parental relationship to the child for this appointment + * @property {boolean} [parentHasParentalResponsibility] - Does the parent/carer have legal parental responsibility for the child? + * @property {string} [session_id] - The ID of the clinic session in which the appointment's booked + * @property {Date} [startAt] - Slot start time + * @property {Date} [endAt] - Slot end time + * @property {Array} [primary_programme_ids] - IDs of primary programmes for this clinic booking + * @property {Array} [selected_programme_ids] - IDs of programmes signed up for + * @property {object} [healthAnswers] - Answers to health questions + */ +export class ClinicAppointment { + constructor(options, context) { + this.context = context + this.uuid = options?.uuid || faker.string.uuid() + this.booking_uuid = options?.booking_uuid + + this.patient_uuid = options?.patient_uuid + this.child = (options?.child && new Child(options.child)) || new Child({}) + + this.needsExtraTime = stringToBoolean(options?.needsExtraTime) + this.extraTimeReason = options?.extraTimeReason + + this.parentalRelationship = options?.parentalRelationship + this.parentalRelationshipOther = options?.parentalRelationshipOther + this.parentHasParentalResponsibility = + options?.parentHasParentalResponsibility + + this.session_id = options?.session_id + this.startAt = options?.startAt ? new Date(options.startAt) : undefined + this.endAt = options?.endAt ? new Date(options.endAt) : undefined + + this.selected_programme_ids = + (options?.selected_programme_ids && + stringToArray(options.selected_programme_ids)) || + [] + this.primary_programme_ids = + (options?.primary_programme_ids && + stringToArray(options.primary_programme_ids)) || + [] + this.healthAnswers = options?.healthAnswers || {} + } + + /** + * Get URI of the booking journey + * + * @returns {string} Appointment URI + */ + get appointmentUri() { + return `${this.uuid}` + } + + /** + * Get the booking this appointment belongs to + * + * @returns {ClinicBooking|undefined} Clinic booking + */ + get clinicBooking() { + try { + if (this.booking_uuid) { + return ClinicBooking.findOne(this.booking_uuid, this.context) + } + } catch (error) { + console.error('ClinicAppointment.clinicBooking', error.message) + } + } + + /** + * Get patient + * + * @returns {Patient|undefined} Patient + */ + get patient() { + try { + if (this.patient_uuid) { + return Patient.findOne(this.patient_uuid, this.context) + } + } catch (error) { + console.error('ClinicAppointment.patient', error.message) + } + } + + /** + * Get a parent object combining the parent's contact details held in the + * booking with the parental relationship for this appointment's child + * + * @returns {Parent} - a Parent with the correct relationship to this appointment's child + */ + get parent() { + const booking = this.clinicBooking + if (booking) { + const parent = new Parent(booking.parent) + return _.merge(parent, { + relationship: this.parentalRelationship, + relationshipOther: this.parentalRelationshipOther, + hasParentalResponsibility: this.parentHasParentalResponsibility + }) + } + + return undefined + } + + /** + * Get first name of the child booked into this appointment + * + * @returns {string} Child's first name + */ + get firstName() { + return this.patient ? this.patient.firstName : this.child.firstName + } + + /** + * Get last name of the child booked into this appointment + * + * @returns {string} Child's last name + */ + get lastName() { + return this.patient ? this.patient.lastName : this.child.lastName + } + + /** + * Get full name of the child booked into this appointment + * + * @returns {string} Child's full name + */ + get fullName() { + return `${this.firstName} ${this.lastName}` + } + + /** + * Get the programmes selected for this appointment + * + * @param {object} programmeContext - the context in which we'll find the programmes + * @returns {Array} Programmes selected for this appointment + */ + #getSelectedProgrammes(programmeContext) { + return ClinicAppointment.#getProgrammesFromIDs( + this.selected_programme_ids, + programmeContext ?? this.context + ) + } + + /** + * Get the programmes for which this child is eligible + * + * @returns {Array} The programmes from which the parent is able to choose + */ + get eligibleProgrammes() { + const patient = this.patient + if (!patient) { + return this.clinicBooking?.primaryProgrammes + } + + // TODO: work out which vaccinations the matched child is eligible for + const catchup_programme_ids = [] + + let eligible_programme_ids = new Set(this.primary_programme_ids) + eligible_programme_ids = eligible_programme_ids.union( + new Set(catchup_programme_ids) + ) + + return ClinicAppointment.#getProgrammesFromIDs( + [...eligible_programme_ids], + this.context + ) + } + + /** + * Convert an array of programme IDs to actual programme objects + * + * @param {Array} programmeIDs + * @param {object} context + * @returns {Array} Programme objects matching the given IDs + */ + static #getProgrammesFromIDs(programmeIDs, context) { + return programmeIDs + .map((id) => { + const programme = Programme.findOne(id, context) + if (!programme) { + console.log(`Null programme for ID: ${id}`) + } + return programme + }) + .filter(Boolean) // TODO: shouldn't need this filter and it will mask issues; remove when the checkboxes binding is fixed + } + + /** + * Get health questions to show based on the selected programme(s) + * + * Note: this method requires this instance to have a full context + * + * @param {object} programmeContext - the context in which we'll find the programmes + * @returns {Array} Health questions + */ + getHealthQuestionsForSelectedProgrammes(programmeContext) { + // Logic is: programme -> vaccine (matched on programme type) -> health questions + + // NB: given we don't have information about consent for nasal vs. injection, or for + // gelatine, we can end up asking more questions here than we might need to. :/ + const vaccinesForSelectedProgrammes = [] + for (const programme of this.#getSelectedProgrammes(programmeContext)) { + vaccinesForSelectedProgrammes.push( + ...Object.values(programmeContext.vaccines).filter( + (v) => v.type === programme.type + ) + ) + } + + // Collate the questions from the vaccines, making sure we don't duplicate them + const questions = new Map() + for (const vaccine of vaccinesForSelectedProgrammes) { + for (const [key, value] of Object.entries(vaccine.healthQuestions)) { + questions.set(key, value) + } + } + + return Object.fromEntries(questions) + } + + /** + * Get various formatted values for display in the page + * + * @returns {object} Formatted values + */ + get formatted() { + const formattedStartTime = formatDate(this.startAt, { + hour: 'numeric', + minute: 'numeric', + hour12: true + }) + const formattedEndTime = formatDate(this.endAt, { + hour: 'numeric', + minute: 'numeric', + hour12: true + }) + + const session = Session.findOne(this.session_id, this.context) + + return { + nameAndAge: [ + this.fullName, + this.patient?.age ? `Age ${this.patient.age}` : null + ] + .filter(Boolean) + .join('
'), + location: Object.values(session?.clinic?.location ?? {}) + .filter(Boolean) + .join(', '), + date: session?.formatted.date ?? '', + dateAndTime: `${session?.formatted.date} at ${formattedStartTime}`, + timeSlot: `${formattedStartTime} to ${formattedEndTime}`, + vaccinations: this.#getSelectedProgrammes(this.context) + .map((programme) => programme.name) + .join(', ') + } + } + + /** + * Get the prefix used for looking up localised strings for this model + * + * @returns {string} Namespace + */ + get ns() { + return 'clinicAppointment' + } + + /** + * Get URI + * + * @returns {string} URI + */ + get uri() { + return `/clinic-appointments/${this.uuid}` + } + + /** + * Find all + * + * @param {object} context - Context + * @returns {Array|undefined} Clinic appointments + * @static + */ + static findAll(context) { + return Object.values(context?.clinicAppointments ?? {}) + .map((appt) => new ClinicAppointment(appt, context)) + .sort((a, b) => getDateValueDifference(a.startAt, b.startAt)) + } + + /** + * Find one + * + * @param {string} uuid - ClinicAppointment UUID + * @param {object} context - Context + * @returns {ClinicAppointment|undefined} Clinic appointment + * @static + */ + static findOne(uuid, context) { + if (context?.clinicAppointments?.[uuid]) { + return new ClinicAppointment(context.clinicAppointments[uuid], context) + } + } + + /** + * Create a new clinic appointment, adding it to the context + * + * @param {object} appointment - an appointment to copy or an object with any subset of its properties + * @param {object} context - the context into which we'll add the new appointment + * @returns {ClinicAppointment} A new clinic booking, added to the context, and possibly with a new UUID + */ + static create(appointment, context) { + const createdAppointment = new ClinicAppointment(appointment) + + // Update context + context.clinicAppointments = context.clinicAppointments || {} + context.clinicAppointments[createdAppointment.uuid] = createdAppointment + + return createdAppointment + } + + /** + * Update + * + * @param {string} uuid - ClinicAppointment UUID + * @param {object} updates - Updates + * @param {object} context - Context + * @returns {ClinicAppointment} Updated appointment + * @static + */ + static update(uuid, updates, context) { + // Sanitise any _unchecked checkbox values + if (updates?.selected_programme_ids) { + updates.selected_programme_ids = stringToArray( + updates.selected_programme_ids + ) + } + + // Copy updates into the relevant appointment + const updatedAppointment = _.merge( + ClinicAppointment.findOne(uuid, context), + updates + ) + + // Remove appointment context + delete updatedAppointment.context + + // Delete original appointment (with previous UUID) + delete context.clinicAppointments[uuid] + + // Update context + context.clinicAppointments[updatedAppointment.uuid] = updatedAppointment + + return updatedAppointment + } + + /** + * Delete + * + * @param {string} uuid - ClinicAppointment UUID + * @param {object} context - Context + * @static + */ + static delete(uuid, context) { + delete context.clinicAppointments[uuid] + } +} diff --git a/app/models/clinic-booking.js b/app/models/clinic-booking.js new file mode 100644 index 000000000..b334d5fc2 --- /dev/null +++ b/app/models/clinic-booking.js @@ -0,0 +1,217 @@ +import { fakerEN_GB as faker } from '@faker-js/faker' +import _ from 'lodash' + +import allProgrammesData from '../datasets/programmes.js' +import { SessionPresets } from '../enums.js' +import { ClinicAppointment, Parent, Programme } from '../models.js' +import { formatMonospace, stringToBoolean } from '../utils/string.js' + +/** + * @class ClinicBooking + * @param {object} options - Options + * @param {object} [context] - Context + * @property {object} [context] - Context + * @property {string} uuid - Clinic booking UUID + * @property {string} bookingReference - Booking reference number + * @property {import('../enums.js').SessionPreset} sessionPreset - the primary programme for which the parent was invited to book e.g. doubles + * @property {Parent} parent - contact details for the parent making the booking; see appointments for parental relationship details + * @property {Array} [appointments_ids] - Unique IDs of children's appointments (one parent may book in multiple children under one booking) + */ +export class ClinicBooking { + constructor(options, context) { + this.context = context + this.uuid = options?.uuid || faker.string.uuid() + this.bookingReference = + options?.bookingReference || ClinicBooking.generateReference() + this.sessionPreset = options?.sessionPreset ?? SessionPresets[0] + this.parent = + (options?.parent && new Parent(options.parent)) ?? new Parent({}) + + this.appointments_ids = options?.appointments_ids ?? [] + } + + /** + * + * @returns {string} Generate a new, random booking reference + */ + static generateReference() { + return faker.helpers.replaceSymbols('CLN-####-####') + } + + /** + * Get URI of the booking journey + * + * @returns {string} Booking journey URI + */ + get bookingUri() { + return `${this.sessionPreset.slug}/${this.uuid}` + } + + /** + * Get the IDs of the set of programmes that this clinic was set up to serve + * + * @returns {Array} the set of Programme objects represented by the session preset + */ + get primaryProgrammeIDs() { + return this.sessionPreset.programmeTypes.map( + (type) => allProgrammesData[type].id + ) + } + + /** + * Get the set of programmes that this clinic was set up to serve + * + * @returns {Array} the set of Programme objects represented by the session preset + */ + get primaryProgrammes() { + return this.primaryProgrammeIDs.map((id) => + Programme.findOne(id, this.context) + ) + } + + /** + * Add a child's appointment to this booking + * + * @param {ClinicAppointment} appointment - An appointment to make part of this booking + */ + addAppointment(appointment) { + this.appointments_ids.push(appointment.uuid) + } + + /** + * Remove the last appointment added to this booking + * + * @returns {string} the uuid of the removed appointment + */ + removeLastAppointment() { + return this.appointments_ids.pop() + } + + /** + * Get appointments + * + * @returns {Array} Appointments that are part of this booking + */ + get appointments() { + return this.appointments_ids.map((id) => + ClinicAppointment.findOne(id, this.context) + ) + } + + /** + * Get various formatted values for display in the page + * + * @returns {object} Formatted values + */ + get formatted() { + return { + // TODO: make this work using commas for more than 2 programmes + primaryProgramme: this.primaryProgrammes.map((p) => p.name).join(' and '), + bookingReference: formatMonospace(this.bookingReference, true) + } + } + + /** + * Get namespace + * + * @returns {string} Namespace + */ + get ns() { + return 'clinicBooking' + } + + /** + * Get URI + * + * @returns {string} URI + */ + get uri() { + return `/clinic-bookings/${this.uuid}` + } + + /** + * Find all + * + * @param {object} context - Context + * @returns {Array|undefined} Clinic bookings + * @static + */ + static findAll(context) { + return Object.values(context?.clinicBookings ?? {}).map( + (booking) => new ClinicBooking(booking, context) + ) + } + + /** + * Find one + * + * @param {string} uuid - ClinicBooking UUID + * @param {object} context - Context + * @returns {ClinicBooking|undefined} Clinic booking + * @static + */ + static findOne(uuid, context) { + if (context?.clinicBookings?.[uuid]) { + return new ClinicBooking(context.clinicBookings[uuid], context) + } + } + + /** + * Create a new clinic booking, adding it to the context + * + * @param {object} booking + * @param {object} context + * @returns {ClinicBooking} A new clinic booking, added to the context, and possibly with a new UUID + */ + static create(booking, context) { + const createdBooking = new ClinicBooking(booking) + + // Update context + context.clinicBookings = context.clinicBookings || {} + context.clinicBookings[createdBooking.uuid] = createdBooking + + return createdBooking + } + + /** + * Update + * + * @param {string} uuid - ClinicBooking UUID + * @param {object} updates - Updates + * @param {object} context - Context + * @returns {ClinicBooking} Updated booking + * @static + */ + static update(uuid, updates, context) { + // Sanitise any _unchecked checkbox values + if (updates?.parent?.sms) { + updates.parent.sms = stringToBoolean(updates.parent.sms) || false + } + + // Copy updates into the relevant booking + const existingBooking = ClinicBooking.findOne(uuid, context) + const updatedBooking = _.merge(existingBooking, updates) + + // Remove booking context + delete updatedBooking.context + + // Delete original booking (with previous UUID) + delete context.clinicBookings[uuid] + + // Update context + context.clinicBookings[updatedBooking.uuid] = updatedBooking + + return updatedBooking + } + + /** + * Delete + * + * @param {string} uuid - Clinic booking UUID + * @param {object} context - Context + * @static + */ + static delete(uuid, context) { + delete context.clinicBookings[uuid] + } +} diff --git a/app/models/programme.js b/app/models/programme.js index 6c295fdb4..d1a1d72e5 100644 --- a/app/models/programme.js +++ b/app/models/programme.js @@ -26,6 +26,7 @@ import { * @property {Array} [sequence] - Vaccine dose sequence * @property {Array} [immunocompromisedSequence] - Vaccine dose sequence for immunocompromised patients * @property {string} sequenceDefault - Default vaccine dose sequence + * @property {Array} [yearGroups] - All eligible year groups for this programme * @property {number} [targetYearGroup] - Year group for routine vaccination * @property {boolean} ttcv - Tetanus-toxoid containing vaccination programme * @property {boolean} nhseSyncable - Vaccination records can be synced diff --git a/app/routes.js b/app/routes.js index 4e5a03c1a..d2d0e5b47 100644 --- a/app/routes.js +++ b/app/routes.js @@ -14,6 +14,9 @@ import { team } from './middleware/team.js' import { accountRoutes } from './routes/account.js' import { activityRoutes } from './routes/activity.js' import { batchRoutes } from './routes/batch.js' +import { bookIntoClinicRoutes } from './routes/book-into-a-clinic.js' +import { clinicAppointmentRoutes } from './routes/clinic-appointment.js' +import { clinicBookingRoutes } from './routes/clinic-booking.js' import { clinicRoutes } from './routes/clinic.js' import { consentRoutes } from './routes/consent.js' import { defaultBatchRoutes } from './routes/default-batch.js' @@ -47,6 +50,9 @@ router.use(referrer) router.use('/', homeRoutes) router.use('/account', accountRoutes) router.use('/activity', activityRoutes) +router.use('/book-into-a-clinic', bookIntoClinicRoutes) // parent-facing clinic booking journey +router.use('/clinic-bookings', clinicBookingRoutes) // original explorations of clinic booking data +router.use('/clinic-appointments', clinicAppointmentRoutes) router.use('/consents', consentRoutes) router.use('/downloads', downloadRoutes) router.use('/give-or-refuse-consent', parentRoutes) diff --git a/app/routes/book-into-a-clinic.js b/app/routes/book-into-a-clinic.js new file mode 100644 index 000000000..ace58add0 --- /dev/null +++ b/app/routes/book-into-a-clinic.js @@ -0,0 +1,49 @@ +import express from 'express' + +import { bookIntoClinicController as bookIntoClinic } from '../controllers/book-into-a-clinic.js' + +const router = express.Router({ strict: true, mergeParams: true }) + +router.param('session_preset_slug', bookIntoClinic.read) + +router.get( + ['/:session_preset_slug', '/:session_preset_slug/'], + bookIntoClinic.redirect +) + +router.get('/:session_preset_slug/new', bookIntoClinic.new) + +// TODO +router.all( + '/:session_preset_slug/:booking_uuid/new/:appointment_uuid/:view', + bookIntoClinic.readForm +) +router.all( + '/:session_preset_slug/:booking_uuid/new/:view', + bookIntoClinic.readForm +) + +router.get( + '/:session_preset_slug/:booking_uuid/new/:appointment_uuid/:view', + bookIntoClinic.showForm +) +router.get( + '/:session_preset_slug/:booking_uuid/new/:view', + bookIntoClinic.showForm +) + +// TODO: save the completed booking to the global context +// router.post('/:session_preset_slug/:booking_uuid/new/check-answers', bookIntoClinic.update) + +router.post( + '/:session_preset_slug/:booking_uuid/new/:appointment_uuid/:view', + bookIntoClinic.updateForm +) +router.post( + '/:session_preset_slug/:booking_uuid/new/:view', + bookIntoClinic.updateForm +) + +router.get('/:session_preset_slug{/:view}', bookIntoClinic.show) + +export const bookIntoClinicRoutes = router diff --git a/app/routes/clinic-appointment.js b/app/routes/clinic-appointment.js new file mode 100644 index 000000000..ef95db298 --- /dev/null +++ b/app/routes/clinic-appointment.js @@ -0,0 +1,13 @@ +import express from 'express' + +import { clinicAppointmentController as clinicAppointment } from '../controllers/clinic-appointment.js' + +const router = express.Router({ strict: true, mergeParams: true }) + +router.get('/', clinicAppointment.readAll, clinicAppointment.list) + +router.param('clinic_appointment_uuid', clinicAppointment.read) + +router.get('/:clinic_appointment_uuid', clinicAppointment.show) + +export const clinicAppointmentRoutes = router diff --git a/app/routes/clinic-booking.js b/app/routes/clinic-booking.js new file mode 100644 index 000000000..80f48dda3 --- /dev/null +++ b/app/routes/clinic-booking.js @@ -0,0 +1,13 @@ +import express from 'express' + +import { clinicBookingController as clinicBooking } from '../controllers/clinic-booking.js' + +const router = express.Router({ strict: true, mergeParams: true }) + +router.get('/', clinicBooking.readAll, clinicBooking.list) + +router.param('clinic_booking_uuid', clinicBooking.read) + +router.get('/:clinic_booking_uuid', clinicBooking.show) + +export const clinicBookingRoutes = router diff --git a/app/utils/clinic-appointment.js b/app/utils/clinic-appointment.js new file mode 100644 index 000000000..7c89095d2 --- /dev/null +++ b/app/utils/clinic-appointment.js @@ -0,0 +1,160 @@ +import { ClinicBooking } from '../models.js' + +import { camelToKebabCase } from './string.js' + +/** + * Get wizard journey paths and forking details for all appointments in the given clinic booking + * + * @param {object} sessionData - the request.session.data object + * @param {ClinicBooking} booking - the clinic booking whose appointment journeys we're mapping + * @returns {object} An object containing all relevants page and forks + */ +export const getAllAppointmentPaths = (sessionData, booking) => { + const booking_uuid = booking.uuid + const session_preset_slug = booking.sessionPreset.slug + + const allPaths = booking.appointments_ids.map((appointment_uuid) => { + return { + // Child details + [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/child`]: + {}, + [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/dob`]: + {}, + ...(booking.appointments_ids[0] !== appointment_uuid + ? { + [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/address-selection`]: + { + [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/parental-relationship`]: + () => sessionData.transaction.previousAddress !== 'new' + } + } + : {}), + [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/address`]: + {}, + [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/parental-relationship`]: + { + [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/parental-responsibility`]: + { + data: 'appointment.parentHasParentalResponsibility', + value: 'false' + } + }, + + // Appointment-length influences + [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/vaccination-choice`]: + {}, + [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/extra-time`]: + {}, + + // Clinic and slot selection + [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/preferred-location`]: + { + [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/clinic-location`]: + { + data: 'transaction.preferredLocation', + value: 'NE12 7ET' + } + }, + [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/preferred-location-matches`]: + { + [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/preferred-location`]: + { + data: 'transaction.preferredLocation', + value: 'retry' + } + }, + [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/clinic-location`]: + {}, + [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/clinic-date`]: + {}, + [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/appointment-time-range`]: + {}, + [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/appointment-time`]: + {} + } + }) + + // Merge all the appointments' paths into a single sequence, preserving order + return Object.assign({}, ...allPaths) +} + +/** + * Get the path for a single health question + * + * @param {string} key + * @param {import('../models/clinic-appointment.js').ClinicAppointment} appointment + * @param {string} pathPrefix + * @returns {string} The full path to the given health question + */ +const getHealthQuestionPath = (key, appointment, pathPrefix) => { + return `${pathPrefix}${appointment.uuid}/health-question-${camelToKebabCase(key)}` +} + +/** + * Get health question paths for given vaccines + * + * @param {string} pathPrefix - Path prefix + * @param {string} booking_uuid - clinic booking identifier, for access to all appointments + * @param {object} bookingContext - the data context holding the booking and appointments + * @param {object} programmeContext - the data context holding the programme and vaccine info + * @returns {object} Health question paths + */ +export const getHealthQuestionPaths = ( + pathPrefix, + booking_uuid, + bookingContext, + programmeContext +) => { + const paths = {} + + const booking = ClinicBooking.findOne(booking_uuid, bookingContext) + if (!booking) { + return paths + } + + for (const appointment of booking.appointments) { + const healthQuestions = Object.entries( + appointment.getHealthQuestionsForSelectedProgrammes(programmeContext) + ) + + healthQuestions.forEach(([key, question], index) => { + const questionPath = getHealthQuestionPath(key, appointment, pathPrefix) + + if (question.conditional) { + const nextQuestion = healthQuestions[index + 1] + if (nextQuestion) { + const forkPath = getHealthQuestionPath( + nextQuestion[0], + appointment, + pathPrefix + ) + + paths[questionPath] = { + [forkPath]: { + data: `appointment.healthAnswers.${key}.answer`, + value: 'No' + } + } + } else { + paths[questionPath] = {} + } + + // Add paths for conditional sub-questions + for (const subKey of Object.keys(question.conditional)) { + const subQuestionPath = getHealthQuestionPath( + subKey, + appointment, + pathPrefix + ) + paths[subQuestionPath] = {} + } + } else { + paths[questionPath] = {} + } + }) + paths[`${pathPrefix}${appointment.uuid}/impairments`] = {} + paths[`${pathPrefix}${appointment.uuid}/adjustments`] = {} + } + + return paths +} diff --git a/app/utils/clinic-booking.js b/app/utils/clinic-booking.js new file mode 100644 index 000000000..9ed1b97be --- /dev/null +++ b/app/utils/clinic-booking.js @@ -0,0 +1,14 @@ +import { SessionPresets } from '../enums.js' + +/** + * Generate a URL for booking into a clinic whose primary programme is given by the session preset + * + * @param {string} sessionPresetName - the primary programme for the clinic + * @returns {string} - path to the start of the clinic booking journey for the given programme + */ +export const getClinicBookingUrl = (sessionPresetName) => { + const sessionPreset = SessionPresets.find( + (preset) => preset.name === sessionPresetName + ) + return `/book-into-a-clinic/${sessionPreset.slug}` +} diff --git a/app/views/book-into-a-clinic/form/address-selection.njk b/app/views/book-into-a-clinic/form/address-selection.njk new file mode 100644 index 000000000..e20d53f83 --- /dev/null +++ b/app/views/book-into-a-clinic/form/address-selection.njk @@ -0,0 +1,19 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("clinicBooking.addressSelection.title", firstName) %} + +{% block form %} + {{ radios({ + fieldset: { + legend: { + html: appHeading({ + title: title, + caption: __("clinicBooking.appointment.caption", fullName) if childCount > 1 + }) + } + }, + hint: { text: __("clinicBooking.addressSelection.hint") }, + items: previousAddressItems, + decorate: "transaction.previousAddress" + }) }} +{% endblock %} diff --git a/app/views/book-into-a-clinic/form/address.njk b/app/views/book-into-a-clinic/form/address.njk new file mode 100644 index 000000000..e1e89b59a --- /dev/null +++ b/app/views/book-into-a-clinic/form/address.njk @@ -0,0 +1,42 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("clinicBooking.address.title", firstName) %} + +{% block form %} + {% call fieldset({ + legend: { + html: appHeading({ + title: title, + caption: __("clinicBooking.appointment.caption", fullName) if childCount > 1 + }) + } + }) %} +

{{ __("clinicBooking.address.hint") }}

+ + {{ input({ + label: { text: __("location.addressLine1.label") }, + autocomplete: "address-line1", + decorate: "appointment.child.address.addressLine1" + }) }} + + {{ input({ + label: { text: __("location.addressLine2.label") + " (optional)" }, + autocomplete: "address-line2", + decorate: "appointment.child.address.addressLine2" + }) }} + + {{ input({ + classes: "nhsuk-u-width-two-thirds", + label: { text: __("location.addressLevel1.label") }, + autocomplete: "address-level1", + decorate: "appointment.child.address.addressLevel1" + }) }} + + {{ input({ + classes: "nhsuk-input--width-10", + label: { text: __("location.postalCode.label") }, + autocomplete: "postal-code", + decorate: "appointment.child.address.postalCode" + }) }} + {% endcall %} +{% endblock %} diff --git a/app/views/book-into-a-clinic/form/adjustments.njk b/app/views/book-into-a-clinic/form/adjustments.njk new file mode 100644 index 000000000..8a34a2a2d --- /dev/null +++ b/app/views/book-into-a-clinic/form/adjustments.njk @@ -0,0 +1,54 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("consent.child.adjustments.title") %} +{% if childCount > 1 and childNumber < childCount %} + {% set confirmButtonText = __("clinicBooking.nextChildButtonText") %} +{% endif %} + +{% block form %} + {{ checkboxes({ + fieldset: { + legend: { + html: appHeading({ + title: title, + caption: __("clinicBooking.healthAnswers.caption", fullName) if childCount > 1 + }) + } + }, + items: [{ + text: __("consent.child.adjustments.guideDog.label"), + value: Adjustment.GuideDog + }, { + text: __("consent.child.adjustments.distraction.label"), + value: Adjustment.Distraction + }, { + text: __("consent.child.adjustments.extendedAppointment.label"), + value: Adjustment.ExtendedAppointment + }, { + text: __("consent.child.adjustments.firstAppointment.label"), + value: Adjustment.FirstAppointment + }, { + text: __("consent.child.adjustments.lastAppointment.label"), + value: Adjustment.LastAppointment + }, { + text: __("consent.child.adjustments.privacy.label"), + value: Adjustment.Privacy, + hint: { + text: __("consent.child.adjustments.privacy.hint") + } + }, { + text: __("consent.child.adjustments.homeVisit.label"), + value: Adjustment.HomeVisit + }, { + text: __("consent.child.adjustments.other.label"), + value: Adjustment.Other, + conditional: { + html: input({ + label: { text: __("consent.child.adjustmentsOther.title") }, + decorate: "consent.child.adjustmentsOther" + }) + } + }], + decorate: "consent.child.adjustments" + }) }} +{% endblock %} diff --git a/app/views/book-into-a-clinic/form/appointment-time-range.njk b/app/views/book-into-a-clinic/form/appointment-time-range.njk new file mode 100644 index 000000000..be8edb929 --- /dev/null +++ b/app/views/book-into-a-clinic/form/appointment-time-range.njk @@ -0,0 +1,49 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("clinicBooking.timeRange.title", firstName) %} + +{% block form %} + {{ appHeading({ + title: title, + caption: __("clinicBooking.appointment.caption", fullName) if childCount > 1 + }) }} + + {# + {{ summaryList({ + card: { + heading: __("clinicBooking.timeRange.clinicSummary.title"), + headingSize: "m" + }, + rows: summaryRows(appointment, { + location: {}, + date: {} + }) + }) }} + #} + + {{ radios({ + fieldset: { + legend: { + text: __("clinicBooking.timeRange.ranges.label") + } + }, + items: [ + { + text: '9am to 10am', + value: '0900-1000', + hint: { text: __mf("clinicBooking.timeRange.range.slotsAvailable", { count: 6 }) } + }, + { + text: '10am to 11am', + value: "1000-1100", + hint: { text: __mf("clinicBooking.timeRange.range.slotsAvailable", { count: 4 }) } + }, + { + text: '11am to 12pm (midday)', + value: "1100-1200", + hint: { text: __mf("clinicBooking.timeRange.range.slotsAvailable", { count: 1 }) } + } + ], + decorate: "transaction.timeRange" + }) }} +{% endblock %} diff --git a/app/views/book-into-a-clinic/form/appointment-time.njk b/app/views/book-into-a-clinic/form/appointment-time.njk new file mode 100644 index 000000000..04b03f5a7 --- /dev/null +++ b/app/views/book-into-a-clinic/form/appointment-time.njk @@ -0,0 +1,62 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("clinicBooking.time.title", firstName) %} +{% if childCount > 1 and childNumber < childCount %} + {% set confirmButtonText = __("clinicBooking.nextChildButtonText") %} +{% endif %} + +{% block form %} + {{ appHeading({ + title: title, + caption: __("clinicBooking.appointment.caption", fullName) if childCount > 1 + }) }} + + {# + {{ summaryList({ + card: { + heading: __("clinicBooking.time.clinicSummary.title"), + headingSize: "m" + }, + rows: summaryRows(appointment, { + location: {}, + date: {} + }) + }) }} + #} + + {# TODO: use ISO formatted date-times for the values here, rather than just times? #} + {{ radios({ + fieldset: { + legend: { + text: __("clinicBooking.time.times.label") + } + }, + items: [ + { + text: '9:00am', + value: '0900' + }, + { + text: '9:10am', + value: '0910' + }, + { + text: '9:20am', + value: '0920' + }, + { + text: '9:30am', + value: '0930' + }, + { + text: '9:40am', + value: '0940' + }, + { + text: '9:50am', + value: '0950' + } + ], + decorate: "appointment.startAt" + }) }} +{% endblock %} diff --git a/app/views/book-into-a-clinic/form/check-answers.njk b/app/views/book-into-a-clinic/form/check-answers.njk new file mode 100644 index 000000000..fa52b3fe3 --- /dev/null +++ b/app/views/book-into-a-clinic/form/check-answers.njk @@ -0,0 +1,158 @@ +{% extends "_layouts/form.njk" %} + +{% set confirmButtonText = __("clinicBooking.check-answers.confirm") %} +{% set title = __("clinicBooking.check-answers.title") %} +{% set gridColumns = "three-quarters" %} +{% macro editPath(view) -%} + {{- view }}?referrer={{ clinicBooking.bookingUri }}/new/check-answers +{%- endmacro %} + +{% block form %} + {{ appHeading({ + title: title + }) }} + + {{ appHeading({ + title: "Child details", + level: 2, + size: "m" + }) }} + +
+
+
+ Name +
+
+ Billie Rushton +
+
+ Change name +
+
+
+
+ Date of birth +
+
+ 27 March 2012 +
+
+ Change date of birth +
+
+
+
+ Your relationship +
+
+ Mum +
+
+ Change your relationship +
+
+
+ +

+ Appointment details +

+ +
+
+
+ Clinic location +
+
+ Killingworth Library, White Swan Centre, Killingworth, NE12 6SS +
+
+ Change clinic location +
+
+
+
+ Date +
+
+ Wednesday 25 February +
+
+ Change clinic date +
+
+
+
+ Time +
+
+ 9:30am +
+
+ Change time +
+
+
+
+ Vaccinations +
+
+ HPV, MenACWY, Td/IPV +
+
+ Change vaccinations +
+
+
+ +

+ Your details +

+ +
+
+
+ Name +
+
+ Ellie Rushton +
+
+ Change your name +
+
+
+
+ Email +
+
+ ellier1982@outlook.com +
+
+ Change your email address +
+
+
+
+ Phone number +
+
+ 07787 123 456 +
+
+ Change your phone number +
+
+
+
+ Receive confirmation and reminder text messages? +
+
+ Yes +
+
+ Change your contact preferences +
+
+
+{% endblock %} diff --git a/app/views/book-into-a-clinic/form/child-count.njk b/app/views/book-into-a-clinic/form/child-count.njk new file mode 100644 index 000000000..536d0f595 --- /dev/null +++ b/app/views/book-into-a-clinic/form/child-count.njk @@ -0,0 +1,18 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("clinicBooking.childCount.title") %} + +{% block form %} + {{ appHeading({ + title: title + }) }} + + {{ __("clinicBooking.childCount.description") | nhsukMarkdown }} + + {{ input({ + classes: "nhsuk-input--width-2", + label: { text: __("clinicBooking.childCount.children.label") }, + hint: { text: __("clinicBooking.childCount.children.hint") }, + decorate: "transaction.childCount" + }) }} +{% endblock %} diff --git a/app/views/book-into-a-clinic/form/child.njk b/app/views/book-into-a-clinic/form/child.njk new file mode 100644 index 000000000..01fa91d3f --- /dev/null +++ b/app/views/book-into-a-clinic/form/child.njk @@ -0,0 +1,28 @@ +{% extends "_layouts/form.njk" %} + +{% if childNumber < 2 %} + {% set title = __("clinicBooking.child.title.first") %} +{% else %} + {% set title = __("clinicBooking.child.title.next") %} +{% endif %} + +{% block form %} + {{ appHeading({ + title: title, + caption: __("clinicBooking.child.caption", childNumber | ordinal) if childCount > 1 + }) }} + + {{ __("clinicBooking.child.description") | nhsukMarkdown }} + + {{ input({ + label: { text: __("clinicBooking.child.firstName.label") }, + hint: { text: __("clinicBooking.child.firstName.hint") }, + decorate: "appointment.child.firstName" + }) }} + + {{ input({ + label: { text: __("clinicBooking.child.lastName.label") }, + hint: { text: __("clinicBooking.child.lastName.hint") }, + decorate: "appointment.child.lastName" + }) }} +{% endblock %} diff --git a/app/views/book-into-a-clinic/form/clinic-date.njk b/app/views/book-into-a-clinic/form/clinic-date.njk new file mode 100644 index 000000000..0e70fd02d --- /dev/null +++ b/app/views/book-into-a-clinic/form/clinic-date.njk @@ -0,0 +1,26 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("clinicBooking.clinicDate.title", firstName) %} + +{% block form %} + {{ appHeading({ + title: title, + caption: __("clinicBooking.appointment.caption", fullName) if childCount > 1 + }) }} + + {{ __("clinicBooking.clinicDate.location") | nhsukMarkdown }} + + {{ radios({ + fieldset: { + legend: { + text: __("clinicBooking.clinicDate.date.label") + } + }, + items: [ + { text: 'Tuesday 24 February', value: '124' }, + { text: 'Wednesday 25 February', value: '125' }, + { text: 'Monday 2 March', value: '137' } + ], + decorate: "appointment.session_id" + }) }} +{% endblock %} diff --git a/app/views/book-into-a-clinic/form/clinic-location.njk b/app/views/book-into-a-clinic/form/clinic-location.njk new file mode 100644 index 000000000..d51d0bb99 --- /dev/null +++ b/app/views/book-into-a-clinic/form/clinic-location.njk @@ -0,0 +1,32 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("clinicBooking.clinicLocation.title", firstName) %} + +{% block form %} + {{ radios({ + fieldset: { + legend: { + html: appHeading({ + title: title, + caption: __("clinicBooking.appointment.caption", fullName) if childCount > 1 + }) + } + }, + hint: { text: __('clinicBooking.clinicLocation.hint') }, + items: [ + { + text: 'Killingworth Library, White Swan Centre, Killingworth, NE12 6SS', + hint: { text: '0.5 miles away'} + }, + { + text: 'Lane End Surgery, 2 Manor Walk, Four Lane Ends, NE7 7XX', + hint: { text: '2.4 miles away'} + }, + { + text: 'City Library, John Dobson Street, Newcastle upon Tyne, NE1 8AX', + hint: { text: '4.7 miles away'} + } + ], + decorate: "transaction.clinicLocation" + }) }} +{% endblock %} diff --git a/app/views/book-into-a-clinic/form/confirmation.njk b/app/views/book-into-a-clinic/form/confirmation.njk new file mode 100644 index 000000000..7e906dea9 --- /dev/null +++ b/app/views/book-into-a-clinic/form/confirmation.njk @@ -0,0 +1,47 @@ +{% extends "_layouts/default.njk" %} + +{% set paths = { back: false } %} +{% set title = __("clinicBooking.confirmation.title") %} +{% set subtitle = __("clinicBooking.confirmation.subtitle", booking.formatted.bookingReference) %} + +{% block content %} +
+
+ {{ panel({ + titleText: title, + html: subtitle | safe + }) }} + + {% call insetText() %} +

+ Make a note of your booking reference number if you have not entered an email + address or mobile number. You’ll need it to view, change, or cancel your appointment. +

+ {% endcall %} + +

+ What happens next? +

+ +

+ If you entered an email address, you’ll get a confirmation email with your booking details. +

+ +

+ This email will contain your booking reference number and a link to change or cancel your + appointment. You will also be able to answer any unanswered health questions via this link. + Doing this ahead of the clinic will save time on the day. +

+ +

+ Help us to improve this service +

+ +

+ Are you willing to answer some questions about your visit today? This typically takes around 2 minutes. +

+ +

Take our survey

+
+
+{% endblock %} diff --git a/app/views/book-into-a-clinic/form/contact-preference.njk b/app/views/book-into-a-clinic/form/contact-preference.njk new file mode 100644 index 000000000..732329c9b --- /dev/null +++ b/app/views/book-into-a-clinic/form/contact-preference.njk @@ -0,0 +1,31 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("clinicBooking.parent.contactPreference.title") %} + +{% block form %} + {{ appHeading({ + title: title + }) }} + + {{ __("clinicBooking.parent.contactPreference.description") | nhsukMarkdown }} + + {{ radios({ + fieldset: { + legend: { + text: __("clinicBooking.parent.contactPreference.label") + } + }, + items: [{ + text: __("clinicBooking.parent.contactPreference.yes"), + conditional: { + html: textarea({ + label: { text: __("clinicBooking.parent.contactPreferenceDetails.label") }, + decorate: "booking.parent.contactPreferenceDetails" + }) + } + }, { + text: __("clinicBooking.parent.contactPreference.no") + }], + decorate: "booking.parent.contactPreference" + }) }} +{% endblock %} diff --git a/app/views/book-into-a-clinic/form/dob.njk b/app/views/book-into-a-clinic/form/dob.njk new file mode 100644 index 000000000..bde223874 --- /dev/null +++ b/app/views/book-into-a-clinic/form/dob.njk @@ -0,0 +1,18 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("clinicBooking.dob.title", firstName) %} + +{% block form %} + {{ dateInput({ + fieldset: { + legend: { + html: appHeading({ + title: title, + caption: __("clinicBooking.appointment.caption", fullName) if childCount > 1 + }) + } + }, + hint: { text: __("clinicBooking.dob.hint") }, + decorate: "appointment.child.dob_" + }) }} +{% endblock %} diff --git a/app/views/book-into-a-clinic/form/extra-time.njk b/app/views/book-into-a-clinic/form/extra-time.njk new file mode 100644 index 000000000..fb089f932 --- /dev/null +++ b/app/views/book-into-a-clinic/form/extra-time.njk @@ -0,0 +1,26 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("clinicBooking.extraTime.title", firstName) %} + +{% block form %} + {# Ask for a reason if the parent says the child does need extra time #} + {%- set yesHtml = input({ + label: { text: __("clinicBooking.extraTime.reason.label") }, + decorate: "appointment.extraTimeReason" + }) %} + {% set items = injectConditionalHtml(booleanItems, true, yesHtml) %} + + {{ radios({ + fieldset: { + legend: { + html: appHeading({ + title: title, + caption: __("clinicBooking.appointment.caption", fullName) if childCount > 1 + }) + } + }, + hint: { text: __("clinicBooking.extraTime.hint") }, + items: items, + decorate: "appointment.needsExtraTime" + }) }} +{% endblock %} diff --git a/app/views/book-into-a-clinic/form/health-question.njk b/app/views/book-into-a-clinic/form/health-question.njk new file mode 100644 index 000000000..d2ea6ac92 --- /dev/null +++ b/app/views/book-into-a-clinic/form/health-question.njk @@ -0,0 +1,43 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("healthQuestions." + key + ".label") %} +{% set vaccineName = "nasal spray flu" if false else "MMR" %} {# CONCERN: need to replace `"Flu" in session.presetNames` #} +{% set hint = __("healthQuestions." + key + ".hint", vaccineName) %} +{% set detailsHint = __("healthQuestions." + key + ".detailsHint") %} + +{% block form %} + {% call fieldset({ + legend: { + html: appHeading({ + title: title | replace('the child', 'your child'), + caption: __("clinicBooking.healthAnswers.caption", fullName) if childCount > 1 + }) + } + }) %} + {% if __("healthQuestions." + key + ".options") %} +
    + {% for option in __("healthQuestions." + key + ".options") %} +
  • {{ option }}
  • + {% endfor %} +
+ {% endif %} + + {{ radios({ + hint: { text: hint } if hint, + items: [{ + text: __("clinicBooking.healthAnswers.yes"), + conditional: { + html: textarea({ + label: { text: __("clinicBooking.healthAnswers.details") }, + hint: { text: detailsHint } if detailsHint, + decorate: ["appointment", "healthAnswers", key, "details"] + }) + } if not hasSubQuestions + }, + { + text: __("clinicBooking.healthAnswers.no") + }], + decorate: ["appointment", "healthAnswers", key, "answer"] + }) }} + {% endcall %} +{% endblock %} diff --git a/app/views/book-into-a-clinic/form/impairments.njk b/app/views/book-into-a-clinic/form/impairments.njk new file mode 100644 index 000000000..fbd31f173 --- /dev/null +++ b/app/views/book-into-a-clinic/form/impairments.njk @@ -0,0 +1,56 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("consent.child.impairments.title") %} + +{% block form %} + {{ checkboxes({ + fieldset: { + legend: { + html: appHeading({ + title: title, + caption: __("clinicBooking.healthAnswers.caption", fullName) if childCount > 1 + }) + } + }, + items: [{ + text: Impairment.Vision, + hint: { + text: __("consent.child.impairments.vision.hint") + } + }, { + text: Impairment.Hearing, + hint: { + text: __("consent.child.impairments.hearing.hint") + } + }, { + text: Impairment.Mobility, + hint: { + text: __("consent.child.impairments.mobility.hint") + } + }, { + text: Impairment.Memory, + hint: { + text: __("consent.child.impairments.memory.hint") + } + }, { + text: Impairment.MentalHealth, + hint: { + text: __("consent.child.impairments.mentalHealth.hint") + } + }, { + text: Impairment.Communicative, + hint: { + text: __("consent.child.impairments.communicative.hint") + } + }, { + text: Impairment.Other, + conditional: { + html: input({ + label: { text: __("consent.child.impairmentsOther.title") }, + decorate: "consent.child.impairmentsOther" + }) + } + }], + decorate: "consent.child.impairments" + }) }} +{% endblock %} diff --git a/app/views/book-into-a-clinic/form/offer-health-questions.njk b/app/views/book-into-a-clinic/form/offer-health-questions.njk new file mode 100644 index 000000000..c136a9c24 --- /dev/null +++ b/app/views/book-into-a-clinic/form/offer-health-questions.njk @@ -0,0 +1,32 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("clinicBooking.offerHealthQuestions.title") %} + +{% block form %} + {{ appHeading({ + title: title + }) }} + + {{ __("clinicBooking.offerHealthQuestions.bookingReference", booking.formatted.bookingReference) | nhsukMarkdown }} + + {{ __("clinicBooking.offerHealthQuestions.beforeYouGo") | nhsukMarkdown }} + + {{ radios({ + fieldset: { + legend: { + text: __("clinicBooking.offerHealthQuestions.label"), + size: "m" + } + }, + items: [ + { + text: __('clinicBooking.offerHealthQuestions.yes'), + value: true + }, + { + text: __('clinicBooking.offerHealthQuestions.no'), + value: false + }], + decorate: "transaction.optedIntoHealthQuestions" + }) }} +{% endblock %} diff --git a/app/views/book-into-a-clinic/form/parent.njk b/app/views/book-into-a-clinic/form/parent.njk new file mode 100644 index 000000000..7127a30f2 --- /dev/null +++ b/app/views/book-into-a-clinic/form/parent.njk @@ -0,0 +1,40 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("clinicBooking.parent.title") %} + +{% block form %} + {% call fieldset({ + legend: { + text: title, + size: "l", + isPageHeading: true + } + }) %} + + {{ input({ + label: { text: __("clinicBooking.parent.fullName.label") }, + decorate: "booking.parent.fullName" + }) }} + + {{ input({ + label: { text: __("clinicBooking.parent.email.label") }, + hint: { text: __("clinicBooking.parent.email.hint") }, + decorate: "booking.parent.email" + }) }} + + {{ input({ + label: { text: __("clinicBooking.parent.tel.label") + " (optional)" }, + hint: { text: __("clinicBooking.parent.tel.hint") }, + decorate: "booking.parent.tel" + }) }} + + {{ checkboxes({ + items: [{ + text: __("clinicBooking.parent.sms.label"), + value: true + }], + decorate: "booking.parent.sms" + }) }} + + {% endcall %} +{% endblock %} diff --git a/app/views/book-into-a-clinic/form/parental-relationship.njk b/app/views/book-into-a-clinic/form/parental-relationship.njk new file mode 100644 index 000000000..6d34a8eec --- /dev/null +++ b/app/views/book-into-a-clinic/form/parental-relationship.njk @@ -0,0 +1,45 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("clinicBooking.parentalRelationship.title", firstName) %} + +{% block form %} + {{ appHeading({ + title: title, + caption: __("clinicBooking.appointment.caption", fullName) if childCount > 1 + }) }} + + {%- set fosterCarerHtml = radios({ + fieldset: { + legend: { text: __("clinicBooking.parentalRelationship.hasParentalResponsibility.label") } + }, + hint: { text: __("clinicBooking.parentalRelationship.hasParentalResponsibility.hint") }, + items: booleanItems, + decorate: "appointment.parentHasParentalResponsibility" + }) %} + + {%- set otherHtml = input({ + label: { text: __("clinicBooking.parentalRelationship.relationshipOther.label") }, + decorate: "appointment.parentalRelationshipOther" + }) + radios({ + fieldset: { + legend: { text: __("clinicBooking.parentalRelationship.hasParentalResponsibility.label") } + }, + hint: { text: __("clinicBooking.parentalRelationship.hasParentalResponsibility.hint") }, + items: booleanItems, + decorate: "appointment.parentHasParentalResponsibility" + }) %} + + {# Add conditional html for ‘Foster carer’ option #} + {% set items = injectConditionalHtml(parentalRelationshipItems, ParentalRelationship.Fosterer, fosterCarerHtml) %} + + {# Add conditional html for ‘Other’ option #} + {% set items = injectConditionalHtml(items, ParentalRelationship.Other, otherHtml) %} + + {{ radios({ + fieldset: { + legend: { text: __("clinicBooking.parentalRelationship.relationship.label") } + }, + items: items, + decorate: "appointment.parentalRelationship" + }) }} +{% endblock %} diff --git a/app/views/book-into-a-clinic/form/parental-responsibility.njk b/app/views/book-into-a-clinic/form/parental-responsibility.njk new file mode 100644 index 000000000..beb857b71 --- /dev/null +++ b/app/views/book-into-a-clinic/form/parental-responsibility.njk @@ -0,0 +1,33 @@ +{% extends "_layouts/default.njk" %} + +{% set title = __("clinicBooking.parentalResponsibility.title") %} + +{% block content %} +
+
+ {{ appHeading({ + title: title, + caption: __("clinicBooking.appointment.caption", fullName) if childCount > 1 + }) }} + + {# + TODO: offer the choice of saying that someone with parental responsibility will + attend the clinic, or that they're happy for the child to self-consent. If they + say No to both of those, then abandon this appointment, removing it from the + booking and moving to the next child (if there will be one), or possibly abandoning + the booking as a whole if this was the only child. + + As it stands, you might have gone through and entered 1 child's details successfully + (your biological son/daughter) and then got to a foster child that's blocking you + from even completing the booking of the first child's appointment. It's likely rare, + but it would be annoying. + + For now, however, it's simpler for us to ask the parent to talk to an admin and book + in that way. + #} + {{ __("clinicBooking.parentalResponsibility.description", { + team: data.team + }) | nhsukMarkdown }} +
+
+{% endblock %} diff --git a/app/views/book-into-a-clinic/form/preferred-location-matches.njk b/app/views/book-into-a-clinic/form/preferred-location-matches.njk new file mode 100644 index 000000000..94ebd88a5 --- /dev/null +++ b/app/views/book-into-a-clinic/form/preferred-location-matches.njk @@ -0,0 +1,36 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("clinicBooking.preferredLocationMatches.title") %} + +{% block form %} + {{ appHeading({ + title: title, + caption: __("clinicBooking.appointment.caption", fullName) if childCount > 1 + }) }} + + {{ radios({ + fieldset: { + legend: { text: __("clinicBooking.preferredLocationMatches.hits.label") } + }, + items: [{ + text: 'Newcastle upon Tyne, NE1', + value: 'NE1' + }, + { + text: 'Newcastle-under-Lyme, ST5', + value: 'ST5' + }, + { + text: 'Newcastle, SY7', + value: 'SY7' + }, + { + divider: 'or' + }, + { + text: __('clinicBooking.preferredLocationMatches.tryAgain'), + value: 'retry' + }], + decorate: "transaction.preferredLocation" + }) }} +{% endblock %} diff --git a/app/views/book-into-a-clinic/form/preferred-location.njk b/app/views/book-into-a-clinic/form/preferred-location.njk new file mode 100644 index 000000000..577155e05 --- /dev/null +++ b/app/views/book-into-a-clinic/form/preferred-location.njk @@ -0,0 +1,16 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("clinicBooking.preferredLocation.title", firstName) %} + +{% block form %} + {{ appHeading({ + title: title, + caption: __("clinicBooking.appointment.caption", fullName) if childCount > 1 + }) }} + + {{ input({ + label: { text: __("clinicBooking.preferredLocation.location.label") }, + hint: { text: __("clinicBooking.preferredLocation.location.hint") }, + decorate: "transaction.preferredLocation" + }) }} +{% endblock %} diff --git a/app/views/book-into-a-clinic/form/vaccination-choice.njk b/app/views/book-into-a-clinic/form/vaccination-choice.njk new file mode 100644 index 000000000..5ac988f6b --- /dev/null +++ b/app/views/book-into-a-clinic/form/vaccination-choice.njk @@ -0,0 +1,43 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("clinicBooking.vaccinationChoice.title", firstName) %} + +{% block form %} + {{ appHeading({ + title: title, + caption: __("clinicBooking.appointment.caption", fullName) if childCount > 1 + }) }} + +

+ While this clinic is primarily for {{ booking.formatted.primaryProgramme }} vaccinations, our records show that + {{ firstName }} is also eligible for other vaccinations. +

+ + {{ checkboxes({ + fieldset: { + legend: { + text: __("clinicBooking.vaccinationChoice.vaccinations.label", firstName), + size: "m" + } + }, + hint: { text: __("clinicBooking.vaccinationChoice.vaccinations.hint") }, + items: [{ + text: 'HPV', + value: 'hpv', + hint: { text: 'Protects against human papillomavirus, some types of which are linked to an increased risk of certain types of cancer' } + }, { + text: 'MenACWY', + value: 'menacwy', + hint: { text: 'Protects against life-threatening illnesses like meningitis and sepsis' } + },{ + text: 'Td/IPV', + value: 'td-ipv', + hint: { text: 'Protects against tetanus, diphtheria and polio' } + },{ + text: 'MMR(V)', + value: 'mmr', + hint: { text: 'Protects against measles, mumps, rubella and varicella (chickenpox)' } + }], + decorate: "appointment.selected_programme_ids" + }) }} +{% endblock %} diff --git a/app/views/book-into-a-clinic/start.njk b/app/views/book-into-a-clinic/start.njk new file mode 100644 index 000000000..2e6cdc39a --- /dev/null +++ b/app/views/book-into-a-clinic/start.njk @@ -0,0 +1,39 @@ +{% extends "_layouts/default.njk" %} + +{% set title = false %} + +{% block content %} +
+
+ {{ appHeading({ + size: "xl", + title: __("clinicBooking.start.title." + sessionPreset.name) + }) }} + +

+ If your child has not been vaccinated at school, or is not up to date with their vaccinations for any other reason, you can book into a clinic. +

+ + {# TODO: change the use of sessionPreset.name here to a more public-facing name. For example, present "MenACWY and Td/IPC" instead of "Doubles" #} +

+ Clinics have recently been set up to offer {{ __("clinicBooking.start.primaryProgrammeInSentence." + sessionPreset.name) }} vaccinations, but your child may be able to catch up on any outstanding + vaccinations during their appointment. +

+ +

+ {{ __("clinicBooking.start.confirm.title") }} +

+ + {{ button({ + text: __("clinicBooking.start.confirm.buttonText"), + href: "/book-into-a-clinic/" + sessionPreset.slug + "/new" + }) }} + +

+ {{ __("clinicBooking.start.otherMethods.title") }} +

+ + {{ __("clinicBooking.start.otherMethods.description", bookingPhoneNumber) | nhsukMarkdown }} +
+
+{% endblock %} \ No newline at end of file diff --git a/app/views/clinic-appointment/list.njk b/app/views/clinic-appointment/list.njk new file mode 100644 index 000000000..ca673f32a --- /dev/null +++ b/app/views/clinic-appointment/list.njk @@ -0,0 +1 @@ +{{ inspect(clinicAppointments) }} \ No newline at end of file diff --git a/app/views/clinic-appointment/show.njk b/app/views/clinic-appointment/show.njk new file mode 100644 index 000000000..67c8efcc0 --- /dev/null +++ b/app/views/clinic-appointment/show.njk @@ -0,0 +1,59 @@ +{% extends "_layouts/default.njk" %} + +{% set title = __("clinicAppointment.show.title", clinicAppointment.patient.fullName) %} + +{% block content %} +
+ {{ super() }} + + {{ appHeading({ + title: title + }) }} + + {{ summaryList({ + card: { + heading: __("child.label"), + headingSize: "m" + }, + rows: summaryRows(clinicAppointment.patient, { + fullName: {}, + preferredFirstName: {}, + preferredLastName: {}, + dob: {}, + address: {}, + gpSurgery: {}, + school: {} + }) + }) if clinicAppointment.patient }} + + {{ summaryList({ + card: { + heading: __("clinicAppointment.label"), + headingSize: "m" + }, + rows: summaryRows(clinicAppointment, { + location: {}, + date: {}, + timeSlot: {}, + vaccinations: {} + }) + }) if clinicAppointment }} + + {{ summaryList({ + card: { + heading: __("parent.label"), + headingSize: "m" + }, + rows: summaryRows(clinicAppointment.parent, { + fullName: {}, + relationship: {}, + hasParentalResponsibility: {}, + email: {}, + tel: {}, + contactPreference: {}, + sms: {} + }) + }) if clinicAppointment.parent }} + +
+{% endblock %} diff --git a/app/views/clinic-booking/list.njk b/app/views/clinic-booking/list.njk new file mode 100644 index 000000000..230e369b0 --- /dev/null +++ b/app/views/clinic-booking/list.njk @@ -0,0 +1 @@ +{{ inspect(clinicBookings) }} \ No newline at end of file diff --git a/app/views/clinic-booking/show.njk b/app/views/clinic-booking/show.njk new file mode 100644 index 000000000..733715943 --- /dev/null +++ b/app/views/clinic-booking/show.njk @@ -0,0 +1,71 @@ +{% extends "_layouts/default.njk" %} + +{% set title = __("clinicBooking.show.title") %} + +{% block content %} +
+ {{ super() }} + + {{ appHeading({ + title: title + }) }} + +
+ {{ __("clinicBooking.show.introduction") | nhsukMarkdown }} +
+ + {% for appointment in clinicBooking.appointments %} + {{ summaryList({ + card: { + heading: __("clinicBooking.show.appointment.title", loop.index), + headingSize: "m" + }, + rows: summaryRows(appointment, { + nameAndAge: {}, + location: {}, + dateAndTime: {}, + vaccinations: {} + }) + }) }} + + {# TODO: add extra, visually hidden text to the button to differentiate it from another child's appointment #} + {{ button({ + classes: "nhsuk-button--secondary", + text: __("clinicBooking.show.appointment.change.label"), + href: "/clinic-appointments/" + appointment.id + "/change" + }) }} + + {{ button({ + classes: "app-button--secondary-warning", + text: __("clinicBooking.show.appointment.cancel.label"), + href: "/clinic-appointments/" + appointment.id + "/cancel" + }) }} + {% endfor %} + + {# Parent details #} + {{ summaryList({ + card: { + heading: __("clinicBooking.show.parent.title"), + headingSize: "m" + }, + rows: summaryRows(clinicBooking.parent, { + fullName: {}, + email: {}, + phone: {}, + sms: {} + }) + }) }} + + {# TODO: need to figure out what routes I'm going to need for editing, but that can wait till I make the booking journey #} + {{ button({ + classes: "nhsuk-button--secondary", + text: __("clinicBooking.show.parent.change.label"), + href: "/clinic-appointments/" + appointment.id + "/change" + }) }} + +
+ {{ __("clinicBooking.show.referenceNumber", clinicBooking.bookingReference) | nhsukMarkdown }} +
+ +
+{% endblock %} diff --git a/app/views/index.njk b/app/views/index.njk index fec1f55f5..8acab4cc2 100644 --- a/app/views/index.njk +++ b/app/views/index.njk @@ -71,6 +71,23 @@ }) }} {% endif %} {% endfor %} + + {{ appHeading({ + level: 2, + title: "Book into a clinic", + size: "m" + }) }} + + {% for preset in SessionPresets %} + {% if preset.active %} + {{ actionLink({ + classes: "nhsuk-u-margin-bottom-2", + text: "Start page for parents to book into a clinic for " + preset.name | replace("Flu", "flu"), + href: navigation.clinicBookingUrl[preset.name] + "/start" + }) }} + {% endif %} + {% endfor %} + {% endblock %} diff --git a/lib/create-data.js b/lib/create-data.js index 32ce0705e..546787504 100644 --- a/lib/create-data.js +++ b/lib/create-data.js @@ -29,9 +29,12 @@ import { ReplyMethod } from '../app/enums.js' import { generateBatch } from '../app/generators/batch.js' +import { generateClinicAppointment } from '../app/generators/clinic-appointment.js' +import { generateEmptyClinicBooking } from '../app/generators/clinic-booking.js' import { generateConsent } from '../app/generators/consent.js' import { generateInstruction } from '../app/generators/instruction.js' import { generateNotice } from '../app/generators/notice.js' +import { generateParent } from '../app/generators/parent.js' import { generatePatient } from '../app/generators/patient.js' import { generateSession } from '../app/generators/session.js' import { generateTeam } from '../app/generators/team.js' @@ -67,6 +70,7 @@ import { generateDataFile } from './generate-data-file.js' const totalUsers = Number(process.env.USERS) || 20 const totalTeams = Number(process.env.TEAMS) || 5 const totalBatches = Number(process.env.BATCHES) || 100 +const totalClinicBookings = Number(process.env.CLINIC_BOOKINGS) || 20 const totalPatients = Number(process.env.RECORDS) || 4000 // Context @@ -204,6 +208,52 @@ if (!hasSessionToday) { context.sessions[earliestPlannedSchoolSession.id].date = today() } +// Clinic bookings +context.clinicBookings = {} +Array.from([...range(1, totalClinicBookings)]).forEach(() => { + const booking = generateEmptyClinicBooking(context) + context.clinicBookings[booking.uuid] = booking +}) + +// Clinic appointments +context.clinicAppointments = {} +for (const booking of Object.values(context.clinicBookings)) { + // Create the first appointment for the booking + const firstAppointment = generateClinicAppointment(booking, context) + if (!firstAppointment) { + // TEMP fix while I figure out what's causing the failure to find a clinic session with the relevant preset + continue + } + booking.addAppointment(firstAppointment) + context.clinicAppointments[firstAppointment.uuid] = firstAppointment + + // Generate parent details based on first child, updating both the booking and appointment with this info + const patient = firstAppointment.patient + booking.parent = + patient?.parent1 || + patient?.parent2 || + generateParent(firstAppointment.lastName, faker.datatype.boolean(0.5)) + firstAppointment.parentalRelationship = booking.parent.relationship + firstAppointment.parentalRelationshipOther = booking.parent.relationshipOther + firstAppointment.parentHasParentalResponsibility = + booking.parent.hasParentalResponsibility + + // Make any additional appointments for this booking + const additionalAppointmentsCount = faker.datatype.boolean(0.8) + ? 0 + : faker.helpers.weightedArrayElement([ + { value: 1, weight: 90 }, + { value: 2, weight: 9 }, + { value: 3, weight: 1 } + ]) + for (let i = 0; i < additionalAppointmentsCount; i++) { + // Don't sweat it making the child's last name match; can imagine they're from an earlier marriage or something + const nextAppointment = generateClinicAppointment(booking, context) + booking.addAppointment(nextAppointment) + context.clinicAppointments[nextAppointment.uuid] = nextAppointment + } +} + // Invite // TODO: Don’t invite patients who’ve already had a programme’s vaccination context.patientSessions = {} @@ -644,6 +694,8 @@ if (vaccinatedPatient) { // Generate date files generateDataFile('.data/batches.json', context.batches) +generateDataFile('.data/clinic-appointments.json', context.clinicAppointments) +generateDataFile('.data/clinic-bookings.json', context.clinicBookings) generateDataFile('.data/clinics.json', context.clinics) generateDataFile('.data/instructions.json', context.instructions) generateDataFile('.data/moves.json', context.moves)