From fe1bed715a6a4a727902621a168ebacb72169bc3 Mon Sep 17 00:00:00 2001 From: Mal Ross Date: Thu, 30 Apr 2026 12:11:38 +0100 Subject: [PATCH 01/14] Initial, empty unmatched clinic appointments page --- app/controllers/appointment.js | 179 ++++++++++++++++++ app/locales/en.js | 10 +- app/routes.js | 2 + app/routes/appointment.js | 20 ++ app/views/appointments/list.njk | 86 +++++++++ app/views/review/_navigation.njk | 5 + app/views/session/_session-clinic-summary.njk | 2 +- 7 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 app/controllers/appointment.js create mode 100644 app/routes/appointment.js create mode 100644 app/views/appointments/list.njk diff --git a/app/controllers/appointment.js b/app/controllers/appointment.js new file mode 100644 index 000000000..92390ebed --- /dev/null +++ b/app/controllers/appointment.js @@ -0,0 +1,179 @@ +import _ from 'lodash' + +import { ClinicBooking, Session } from '../models.js' +import { getResults, getPagination } from '../utils/pagination.js' + +export const appointmentController = { + read(request, response, next, appointment_uuid) { + console.log(`read: ${appointment_uuid}`) + + next() + }, + + readAll(request, response, next) { + const { session_id } = request.params + let appointments = ClinicBooking.findAll(request.session.data)?.flatMap( + ({ appointments }) => appointments + ) + + // Sort + appointments = _.sortBy(appointments, 'startAt') + + // Session consents + if (session_id) { + const session = Session.findOne(session_id, request.session.data) + response.locals.session = session + + appointments = appointments.filter( + (appointment) => appointment.session_id === session_id + ) + } + + response.locals.appointments = appointments + // response.locals.consentsPath = session_id + // ? `/sessions/${session_id}/consents` + // : '/consents' + response.locals.results = getResults(appointments, request.query) + response.locals.pages = getPagination(appointments, request.query) + + next() + }, + + show(request, response) { + const view = request.params.view || 'show' + + response.render(`appointments/${view}`) + }, + + list(request, response) { + response.render('appointments/list') + } + + // readMatches(request, response, next) { + // let { hasMissingNhsNumber, page, limit, q } = request.query + // const { data } = request.session + + // let patients = Patient.findAll(data) + + // // Sort + // patients = _.sortBy(patients, 'lastName') + + // // Paginate + // page = parseInt(page) || 1 + // limit = parseInt(limit) || 50 + + // // Query + // if (q) { + // patients = patients.filter((patient) => + // patient.tokenized.includes(String(q).toLowerCase()) + // ) + // } + + // // Filter by missing NHS number + // if (hasMissingNhsNumber) { + // patients = patients.filter((patient) => patient.hasMissingNhsNumber) + // } + + // // Toggle initial view + // response.locals.initial = + // Object.keys(request.query).filter((key) => key !== 'referrer').length === + // 0 + + // // Results + // response.locals.patients = patients + // response.locals.results = getResults(patients, page, limit) + // response.locals.pages = getPagination(patients, request.query) + + // // Clean up session data + // delete data.hasMissingNhsNumber + // delete data.q + + // next() + // }, + + // filterMatches(request, response) { + // const { hasMissingNhsNumber, q } = request.body + // const { consent } = response.locals + // const params = new URLSearchParams() + + // if (q) { + // params.append('q', String(q)) + // } + + // if (hasMissingNhsNumber?.includes('true')) { + // params.append('hasMissingNhsNumber', 'true') + // } + + // response.redirect(`${consent.uri}/match?${params}`) + // }, + + // link(request, response) { + // const { consent_uuid } = request.params + // const { data } = request.session + // const { __, consent, patient, consentsPath } = response.locals + + // // Link consent with patient record + // consent.linkToPatient(patient) + + // // Update session data + // Consent.update(consent_uuid, consent, data) + // Patient.update(patient.uuid, patient, data) + + // request.flash('success', __(`consent.link.success`, { consent, patient })) + + // response.redirect(consentsPath) + // }, + + // add(request, response) { + // const { consent_uuid } = request.params + // const { data } = request.session + // const { __, consent, consentsPath } = response.locals + + // // Create patient + // const patient = Patient.create(consent.child, data) + + // // Create and add patient session + // const patientSession = PatientSession.create( + // { + // patient_uuid: patient.uuid, + // programme_id: consent.programme_id, + // session_id: consent.session_id + // }, + // data + // ) + + // // Add to session + // patient.addToSession(patientSession) + + // // Invite parent to give consent + // patient.requestConsent(patientSession) + + // // Link consent with patient record + // consent.linkToPatient(patient) + + // // Update session data + // Consent.update(consent_uuid, consent, data) + // Patient.update(patient.uuid, patient, data) + + // request.flash('success', __(`consent.add.success`, { consent, patient })) + + // response.redirect(consentsPath) + // }, + + // invalidate(request, response) { + // const { note } = request.body.consent + // const { consent_uuid } = request.params + // const { data } = request.session + // const { __, consentsPath } = response.locals + + // // Clean up session data + // delete data.consent + + // // Update session data + // const consent = Consent.update(consent_uuid, { invalid: true, note }, data) + + // request.flash('success', __(`consent.invalidate.success`, { consent })) + + // response.redirect(consentsPath) + // } +} diff --git a/app/locales/en.js b/app/locales/en.js index 68634af58..6d42b3d8a 100644 --- a/app/locales/en.js +++ b/app/locales/en.js @@ -234,6 +234,14 @@ export const en = { label: 'Parent' } }, + appointments: { + list: { + label: 'Clinic appointments', + title: 'Unmatched clinic appointments' + }, + count: + '{count, plural, =0 {No unmatched clinic appointments} one {1 unmatched clinic appointment} other {# unmatched clinic appointments}}' + }, clinicAppointment: { label: 'Appointment details', show: { @@ -500,7 +508,7 @@ export const en = { results: '{count, plural, =0 {No responses matching your search criteria were found} one {Showing {from} to {to} of {count} response} other {Showing {from} to {to} of {count} responses}}', list: { - label: 'Unmatched responses', + label: 'Consent responses', title: 'Unmatched consent responses', description: 'Review incoming consent responses that can’t be automatically matched' diff --git a/app/routes.js b/app/routes.js index 28e9e4604..0c66661c6 100644 --- a/app/routes.js +++ b/app/routes.js @@ -12,6 +12,7 @@ import { rollover } from './middleware/rollover.js' import { team } from './middleware/team.js' import { accountRoutes } from './routes/account.js' import { activityRoutes } from './routes/activity.js' +import { appointmentRoutes } from './routes/appointment.js' import { batchRoutes } from './routes/batch.js' import { bookIntoClinicRoutes } from './routes/book-into-a-clinic.js' import { clinicBookingRoutes } from './routes/clinic-booking.js' @@ -49,6 +50,7 @@ router.use(referrer) router.use('/', homeRoutes) router.use('/account', accountRoutes) router.use('/activity', activityRoutes) +router.use('/appointments', appointmentRoutes) // unmatched clinic appointments 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('/consents', consentRoutes) diff --git a/app/routes/appointment.js b/app/routes/appointment.js new file mode 100644 index 000000000..2a4a10b8d --- /dev/null +++ b/app/routes/appointment.js @@ -0,0 +1,20 @@ +import express from 'express' + +import { appointmentController as appointment } from '../controllers/appointment.js' + +const router = express.Router({ strict: true, mergeParams: true }) + +router.get('/', appointment.readAll, appointment.list) + +router.param('appointment_uuid', appointment.read) + +// router.all('/:appointment_uuid/match', appointment.readMatches) +// router.post('/:appointment_uuid/match', appointment.filterMatches) + +// router.post('/:appointment_uuid/invalidate', appointment.invalidate) +// router.post('/:appointment_uuid/link', appointment.link) +// router.post('/:appointment_uuid/add', appointment.add) + +// router.get('/:appointment_uuid{/:view}', appointment.show) + +export const appointmentRoutes = router diff --git a/app/views/appointments/list.njk b/app/views/appointments/list.njk new file mode 100644 index 000000000..f641a8861 --- /dev/null +++ b/app/views/appointments/list.njk @@ -0,0 +1,86 @@ +{% from "review/_navigation.njk" import reviewNavigation with context %} + +{% extends "_layouts/default.njk" %} + +{% set title = __("appointments.list.title") %} + +{% block beforeContent %} + {{ breadcrumb({ + items: [{ + text: __("home.show.title"), + href: "/" + }, { + text: __("session.list.title"), + href: "/sessions" + }, { + text: session.location.name, + href: session.uri + }] + }) if session }} +{% endblock %} + +{% block content %} + {{ super() }} + + {{ reviewNavigation({ + view: "appointments" + }) }} + + {# {% set consentRows = [] %} + {% for consent in results.page %} + {% set consentRows = consentRows | push([ + { + header: __("consent.summary.label"), + html: consent.link.summary | replace("/consents", consentsPath) + }, + { + header: __("consent.createdAt.label"), + text: consent.formatted.createdAt + }, + { + header: __("consent.decision.label"), + text: consent.decision + }, + { + header: __("actions.label"), + html: appActionList({ + items: [{ + text: __("consent.match.label"), + href: consentsPath + "/" + consent.uuid + "/match?referrer=" + consentsPath + }, { + text: __("consent.invalidate.label"), + href: consentsPath + "/" + consent.uuid + "/invalidate?referrer=" + consentsPath + }] + }) + } + ]) %} + {% endfor %} + + {% if consents.length %} + {{ table({ + id: "consents", + responsive: true, + card: { + heading: __mf("consent.count", { count: consents.length }), + headingLevel: 3 + }, + head: [ + { text: __("consent.summary.label") }, + { text: __("consent.createdAt.label") }, + { text: __("consent.decision.label") }, + { text: __("actions.label") } + ], + rows: consentRows + }) }} + + {{ pagination(pages) }} + + {{ __mf("consent.results", { + from: results.from, + to: results.to, + count: results.count + }) | nhsukMarkdown }} + {% else %} #} + {{ __mf("appointments.count", { count: 0 }) | nhsukMarkdown }} + {# {% endif %} #} +{% endblock %} diff --git a/app/views/review/_navigation.njk b/app/views/review/_navigation.njk index 2ea9ed31d..0bb8f05fb 100644 --- a/app/views/review/_navigation.njk +++ b/app/views/review/_navigation.njk @@ -19,6 +19,11 @@ href: "/consents", current: params.view == "consent" }, + { + text: __("appointments.list.label") + appCount(0), + href: "/appointments", + current: params.view == "appointments" + }, { text: __("move.list.label") + appCount(data.counts.moves), href: "/moves", diff --git a/app/views/session/_session-clinic-summary.njk b/app/views/session/_session-clinic-summary.njk index b3ef43a9b..8dd266742 100644 --- a/app/views/session/_session-clinic-summary.njk +++ b/app/views/session/_session-clinic-summary.njk @@ -52,7 +52,7 @@ colour: "orange" if session.unmatchedAppointments.length > 0, compact: true, data: session.unmatchedAppointments.length, - href: session.uri + "/edit" + href: "/appointments/?session_id=" + session.id }) }} {% for programme in session.programmes %} From 95ec8e232872fc789739faf8b18d12b854b60848 Mon Sep 17 00:00:00 2001 From: Mal Ross Date: Thu, 30 Apr 2026 13:33:11 +0100 Subject: [PATCH 02/14] List unmatched appointments, globally and in a session --- app/controllers/appointment.js | 14 ++--- app/data.js | 6 +- app/locales/en.js | 25 +++++++- app/models/clinic-appointment.js | 23 ++++++- app/routes.js | 3 +- app/views/appointments/list.njk | 60 +++++++++++-------- app/views/review/_navigation.njk | 2 +- app/views/session/_session-clinic-summary.njk | 2 +- 8 files changed, 96 insertions(+), 39 deletions(-) diff --git a/app/controllers/appointment.js b/app/controllers/appointment.js index 92390ebed..f5d9d41d0 100644 --- a/app/controllers/appointment.js +++ b/app/controllers/appointment.js @@ -12,14 +12,14 @@ export const appointmentController = { readAll(request, response, next) { const { session_id } = request.params - let appointments = ClinicBooking.findAll(request.session.data)?.flatMap( - ({ appointments }) => appointments - ) + let appointments = ClinicBooking.findAll(request.session.data) + ?.flatMap(({ appointments }) => appointments) + .filter(({ patient_uuid }) => !patient_uuid) // Sort appointments = _.sortBy(appointments, 'startAt') - // Session consents + // Session appointments if (session_id) { const session = Session.findOne(session_id, request.session.data) response.locals.session = session @@ -30,9 +30,9 @@ export const appointmentController = { } response.locals.appointments = appointments - // response.locals.consentsPath = session_id - // ? `/sessions/${session_id}/consents` - // : '/consents' + response.locals.appointmentsPath = session_id + ? `/sessions/${session_id}/appointments` + : '/appointments' response.locals.results = getResults(appointments, request.query) response.locals.pages = getPagination(appointments, request.query) diff --git a/app/data.js b/app/data.js index 07fbe0608..3b15fae5b 100644 --- a/app/data.js +++ b/app/data.js @@ -17,7 +17,7 @@ import users from '../.data/users.json' with { type: 'json' } import vaccinations from '../.data/vaccinations.json' with { type: 'json' } import vaccines from './datasets/vaccines.js' -import { Consent, Move, Notice, Session } from './models.js' +import { ClinicBooking, Consent, Move, Notice, Session } from './models.js' // Use Coventry and Warwickshire as team const team = teams['001'] @@ -56,12 +56,16 @@ const data = { } // Statistics +const unmatchedAppointmentCount = ClinicBooking.findAll(data) + ?.flatMap(({ appointments }) => appointments) + .filter(({ patient_uuid }) => !patient_uuid).length const consentCount = Consent.findAll(data).length || 0 const moveCount = Move.findAll(data).length || 0 const noticeCount = Notice.findAll(data).filter(({ archivedAt }) => !archivedAt).length || 0 data.counts = { + appointments: unmatchedAppointmentCount, consents: consentCount, moves: moveCount, notices: noticeCount, diff --git a/app/locales/en.js b/app/locales/en.js index 6d42b3d8a..5e0afc8c1 100644 --- a/app/locales/en.js +++ b/app/locales/en.js @@ -240,7 +240,30 @@ export const en = { title: 'Unmatched clinic appointments' }, count: - '{count, plural, =0 {No unmatched clinic appointments} one {1 unmatched clinic appointment} other {# unmatched clinic appointments}}' + '{count, plural, =0 {No unmatched clinic appointments} one {1 unmatched clinic appointment} other {# unmatched clinic appointments}}', + results: + '{count, plural, =0 {No unmatched appointments matching your search criteria were found} one {Showing {from} to {to} of {count} unmatched appointment} other {Showing {from} to {to} of {count} unmatched appointments}}', + summary: { + label: 'Response' + }, + location: { + label: 'Clinic location' + }, + date: { + label: 'Clinic date' + }, + time: { + label: 'Appointment time' + }, + vaccinations: { + label: 'Programmes' + }, + match: { + label: 'Match' + }, + invalidate: { + label: 'Invalidate' + } }, clinicAppointment: { label: 'Appointment details', diff --git a/app/models/clinic-appointment.js b/app/models/clinic-appointment.js index 583bc9570..59c9b6160 100644 --- a/app/models/clinic-appointment.js +++ b/app/models/clinic-appointment.js @@ -2,7 +2,11 @@ import { fakerEN_GB as faker } from '@faker-js/faker' import { Child, Patient, Programme, Session } from '../models.js' import { formatDate } from '../utils/date.js' -import { stringToArray, stringToBoolean } from '../utils/string.js' +import { + formatLinkWithSecondaryText, + stringToArray, + stringToBoolean +} from '../utils/string.js' /** * @class ClinicAppointment @@ -240,6 +244,21 @@ export class ClinicAppointment { } } + /** + * Get formatted links + * + * @returns {object} Formatted links + */ + get link() { + return { + summary: formatLinkWithSecondaryText( + this.uri, + this.parentalRelationship, // TODO: look up name of parent in booking + `for ${this.child.fullName}` + ) + } + } + /** * Get the prefix used for looking up localised strings for this model * @@ -255,6 +274,6 @@ export class ClinicAppointment { * @returns {string} URI */ get uri() { - return `/clinic-appointments/${this.uuid}` + return `/appointments/${this.uuid}` } } diff --git a/app/routes.js b/app/routes.js index 0c66661c6..12245d876 100644 --- a/app/routes.js +++ b/app/routes.js @@ -50,7 +50,7 @@ router.use(referrer) router.use('/', homeRoutes) router.use('/account', accountRoutes) router.use('/activity', activityRoutes) -router.use('/appointments', appointmentRoutes) // unmatched clinic appointments +router.use('/appointments', appointmentRoutes) // all unmatched clinic appointments 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('/consents', consentRoutes) @@ -70,6 +70,7 @@ router.use( ) router.use('/reviews', reviewRoutes) router.use('/schools', schoolRoutes) +router.use('/sessions/:session_id/appointments', appointmentRoutes) router.use('/sessions/:session_id/consents', consentRoutes) router.use('/sessions/:session_id/default-batch', defaultBatchRoutes) router.use('/sessions/:session_id/patients', patientSessionRoutes) diff --git a/app/views/appointments/list.njk b/app/views/appointments/list.njk index f641a8861..13f298748 100644 --- a/app/views/appointments/list.njk +++ b/app/views/appointments/list.njk @@ -26,61 +26,71 @@ view: "appointments" }) }} - {# {% set consentRows = [] %} - {% for consent in results.page %} - {% set consentRows = consentRows | push([ + {% set appointmentRows = [] %} + {% for appointment in results.page %} + {% set appointmentRows = appointmentRows | push([ { - header: __("consent.summary.label"), - html: consent.link.summary | replace("/consents", consentsPath) + header: __("appointments.summary.label"), + html: appointment.link.summary | replace("/appointments", appointmentsPath) }, { - header: __("consent.createdAt.label"), - text: consent.formatted.createdAt - }, + header: __("appointments.location.label"), + text: appointment.formatted.location + } if not session, { - header: __("consent.decision.label"), - text: consent.decision - }, + header: __("appointments.date.label"), + text: appointment.formatted.date + } if not session, + { + header: __("appointments.time.label"), + text: appointment.formatted.timeSlot + } if session, + { + header: __("appointments.vaccinations.label"), + text: appointment.formatted.vaccinations + } if session, { header: __("actions.label"), html: appActionList({ items: [{ - text: __("consent.match.label"), - href: consentsPath + "/" + consent.uuid + "/match?referrer=" + consentsPath + text: __("appointments.match.label"), + href: appointmentsPath + "/" + appointment.uuid + "/match?referrer=" + appointmentsPath }, { - text: __("consent.invalidate.label"), - href: consentsPath + "/" + consent.uuid + "/invalidate?referrer=" + consentsPath + text: __("appointments.invalidate.label"), + href: appointmentsPath + "/" + appointment.uuid + "/invalidate?referrer=" + appointmentsPath }] }) } ]) %} {% endfor %} - {% if consents.length %} + {% if appointments.length %} {{ table({ - id: "consents", + id: "appointments", responsive: true, card: { - heading: __mf("consent.count", { count: consents.length }), + heading: __mf("appointments.count", { count: appointments.length }), headingLevel: 3 }, head: [ - { text: __("consent.summary.label") }, - { text: __("consent.createdAt.label") }, - { text: __("consent.decision.label") }, + { text: __("appointments.summary.label") }, + { text: __("appointments.location.label") } if not session, + { text: __("appointments.date.label") } if not session, + { text: __("appointments.time.label") } if session, + { text: __("appointments.vaccinations.label") } if session, { text: __("actions.label") } ], - rows: consentRows + rows: appointmentRows }) }} {{ pagination(pages) }} - {{ __mf("consent.results", { + {{ __mf("appointments.results", { from: results.from, to: results.to, count: results.count }) | nhsukMarkdown }} - {% else %} #} + {% else %} {{ __mf("appointments.count", { count: 0 }) | nhsukMarkdown }} - {# {% endif %} #} + {% endif %} {% endblock %} diff --git a/app/views/review/_navigation.njk b/app/views/review/_navigation.njk index 0bb8f05fb..6666a1df1 100644 --- a/app/views/review/_navigation.njk +++ b/app/views/review/_navigation.njk @@ -20,7 +20,7 @@ current: params.view == "consent" }, { - text: __("appointments.list.label") + appCount(0), + text: __("appointments.list.label") + appCount(data.counts.appointments), href: "/appointments", current: params.view == "appointments" }, diff --git a/app/views/session/_session-clinic-summary.njk b/app/views/session/_session-clinic-summary.njk index 8dd266742..dbd15e467 100644 --- a/app/views/session/_session-clinic-summary.njk +++ b/app/views/session/_session-clinic-summary.njk @@ -52,7 +52,7 @@ colour: "orange" if session.unmatchedAppointments.length > 0, compact: true, data: session.unmatchedAppointments.length, - href: "/appointments/?session_id=" + session.id + href: session.uri + "/appointments" }) }} {% for programme in session.programmes %} From b9fc2ca2660e324fc4e6274c9acb4ae48d9194c9 Mon Sep 17 00:00:00 2001 From: Mal Ross Date: Thu, 30 Apr 2026 13:42:10 +0100 Subject: [PATCH 03/14] Show venue name only in unmatched clinic appointments --- app/models/clinic-appointment.js | 1 + app/views/appointments/list.njk | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/clinic-appointment.js b/app/models/clinic-appointment.js index 59c9b6160..6d38dd055 100644 --- a/app/models/clinic-appointment.js +++ b/app/models/clinic-appointment.js @@ -235,6 +235,7 @@ export class ClinicAppointment { location: Object.values(session?.clinic?.location ?? {}) .filter(Boolean) .join(', '), + locationName: session?.clinic?.name, date: session?.formatted.date ?? '', dateAndTime: `${session?.formatted.date} at ${formattedStartTime}`, timeSlot: `${formattedStartTime} to ${formattedEndTime}`, diff --git a/app/views/appointments/list.njk b/app/views/appointments/list.njk index 13f298748..072eab290 100644 --- a/app/views/appointments/list.njk +++ b/app/views/appointments/list.njk @@ -35,7 +35,7 @@ }, { header: __("appointments.location.label"), - text: appointment.formatted.location + text: appointment.formatted.locationName } if not session, { header: __("appointments.date.label"), From a2ef3d53a653c196c11a730cfb76cbf5c7db1d68 Mon Sep 17 00:00:00 2001 From: Mal Ross Date: Thu, 30 Apr 2026 18:21:59 +0100 Subject: [PATCH 04/14] Remove the misleading appointmentUri method --- app/controllers/book-into-a-clinic.js | 8 +++----- app/models/clinic-appointment.js | 9 --------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/app/controllers/book-into-a-clinic.js b/app/controllers/book-into-a-clinic.js index 76a27f17a..98a920677 100644 --- a/app/controllers/book-into-a-clinic.js +++ b/app/controllers/book-into-a-clinic.js @@ -223,8 +223,6 @@ export const bookIntoClinicController = { _.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) @@ -245,8 +243,8 @@ export const bookIntoClinicController = { // 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 + const firstAppointmentUrl = `${request.baseUrl}/${booking.bookingUri}/new/${firstAppointment.uuid}/child` + paths.next = firstAppointmentUrl } else if ( view === 'address-selection' && request.body.transaction.addressChoice !== 'new' @@ -269,7 +267,7 @@ export const bookIntoClinicController = { // NB: request.session.save was needed to avoid race condition issues on heroku request.session.save((error) => { - if (!error) response.redirect(nextUrl) + if (!error) response.redirect(paths.next) }) }, diff --git a/app/models/clinic-appointment.js b/app/models/clinic-appointment.js index 6d38dd055..43300dbd8 100644 --- a/app/models/clinic-appointment.js +++ b/app/models/clinic-appointment.js @@ -65,15 +65,6 @@ export class ClinicAppointment { this.healthAnswers = options?.healthAnswers || {} } - /** - * Get URI of the booking journey - * - * @returns {string} Appointment URI - */ - get appointmentUri() { - return `${this.uuid}` - } - /** * Get patient * From 087b9888dabb763312a6ddd779c4dbdc9f1bc6a3 Mon Sep 17 00:00:00 2001 From: Mal Ross Date: Thu, 30 Apr 2026 18:23:13 +0100 Subject: [PATCH 05/14] Replace Invalidate with Archive for unmatched appointments --- app/locales/en.js | 4 ++-- app/views/appointments/list.njk | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/locales/en.js b/app/locales/en.js index 5e0afc8c1..ecebc219d 100644 --- a/app/locales/en.js +++ b/app/locales/en.js @@ -261,8 +261,8 @@ export const en = { match: { label: 'Match' }, - invalidate: { - label: 'Invalidate' + archive: { + label: 'Archive' } }, clinicAppointment: { diff --git a/app/views/appointments/list.njk b/app/views/appointments/list.njk index 072eab290..975d36682 100644 --- a/app/views/appointments/list.njk +++ b/app/views/appointments/list.njk @@ -56,8 +56,8 @@ text: __("appointments.match.label"), href: appointmentsPath + "/" + appointment.uuid + "/match?referrer=" + appointmentsPath }, { - text: __("appointments.invalidate.label"), - href: appointmentsPath + "/" + appointment.uuid + "/invalidate?referrer=" + appointmentsPath + text: __("appointments.archive.label"), + href: appointmentsPath + "/" + appointment.uuid + "/archive?referrer=" + appointmentsPath }] }) } From 55d9936079fac05d3680d997d6c568a4ed4acbbf Mon Sep 17 00:00:00 2001 From: Mal Ross Date: Wed, 6 May 2026 11:30:08 +0100 Subject: [PATCH 06/14] Rewrite clinic-related model creation in create-data For sessions: - left largely as is For consent: - no longer requesting consent for clinics - no longer creating consent replies for clinics For screening and recording: - no longer screening or recording for clinic sessions (may revisit this) For clinic invites - now inviting all clinic-ready children to clinic for all programmes for which they're ready (WILL revisit this) For clinic bookings and appointments: - reworked to be based on children's needs rather than a random selection of children and a random selection of clinics - actually finding a free slot and allocating it (in theory, not yet fully tested and looks a bit broken) - made unmatched details a near-miss - simplified to a single child per booking for now (WILL revisit this) - ensured that clinics aren't filled up --- app/generators/clinic-appointment.js | 148 +++++++------- app/generators/clinic-booking.js | 7 +- app/globals.js | 6 +- app/locales/en.js | 8 +- app/models/child.js | 5 +- app/models/clinic-appointment.js | 27 ++- app/models/clinic-vaccination-period.js | 19 +- app/models/patient.js | 8 +- app/models/session.js | 101 ++++++++-- app/utils/session.js | 35 ++++ app/views/appointments/list.njk | 10 +- lib/create-data.js | 247 ++++++++++-------------- 12 files changed, 353 insertions(+), 268 deletions(-) diff --git a/app/generators/clinic-appointment.js b/app/generators/clinic-appointment.js index 6db5fb8f3..c11ba1067 100644 --- a/app/generators/clinic-appointment.js +++ b/app/generators/clinic-appointment.js @@ -1,84 +1,87 @@ import { fakerEN_GB as faker } from '@faker-js/faker' -import { addMinutes } from 'date-fns' +import { addMinutes, addYears } from 'date-fns' -import { ParentalRelationship, SessionType } from '../enums.js' +import { ParentalRelationship } from '../enums.js' import { ClinicAppointment } from '../models.js' -import { getAge } from '../utils/date.js' + +import { generateParent } from './parent.js' const clinicSlotLength = Number(process.env.CLINIC_SLOT_LENGTH) || 10 /** * Generate fake clinic appointment * + * @param {import('../models/patient.js').Patient} patient - The patient for whom the appointment is being created + * @param {import('../models/session.js').Session} session - The clinic session into which we're booking the patient * @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) { +export function generateClinicAppointment(patient, session, booking) { const uuid = faker.string.uuid() + const booking_uuid = booking.uuid + const session_id = session.id - // 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 + let patient_uuid, child + if (faker.datatype.boolean(0.95)) { + // Matched appointment + patient_uuid = patient.uuid - // 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 + child = { + firstName: patient.firstName, + lastName: patient.lastName, + dob: patient.dob + } + } else { + // Unmatched appointment; no patient ID, and get one of the details 'wrong' + const wrongness = faker.helpers.arrayElement([ + 'firstName', + 'lastName', + 'dob' + ]) + child = { + firstName: + wrongness === 'firstName' + ? faker.person.firstName() + : patient.firstName, + lastName: + wrongness === 'lastName' ? faker.person.lastName() : patient.lastName, + dob: + wrongness === 'dob' + ? addYears(patient.dob, faker.helpers.arrayElement([-2, -1, 1, 2])) + : patient.dob } - 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 + // Set up the relationship to the child for this appointment. If the booking doesn't already have + // a parent set up, we'll create the booking and appointment's parent based on the first appointment's + // child details let parentalRelationship, parentalRelationshipOther, parentHasParentalResponsibility - if (parent) { + if (!booking.parent) { + // First appointment, so set up the booking's parent + booking.parent = + patient.parent1 || + patient.parent2 || + generateParent(child.lastName, faker.datatype.boolean(0.5)) + // ...and their relationship to this child + parentalRelationship = booking.parent.relationship + parentalRelationshipOther = booking.parent.relationshipOther + parentHasParentalResponsibility = booking.parent.hasParentalResponsibility + } else { + // This isn't the first appointment, so set up parent details similar to the first one + const parent = booking.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)) { + if (faker.datatype.boolean(0.9)) { + parentalRelationship = parent.relationship + parentalRelationshipOther = parent.relationshipOther + parentHasParentalResponsibility = parent.hasParentalResponsibility + } else { parentalRelationship = faker.helpers.arrayElement([ ParentalRelationship.Fosterer, ParentalRelationship.Guardian, @@ -91,14 +94,14 @@ export function generateClinicAppointment(booking, context) { parentHasParentalResponsibility = true } } else { - // Fosterer, Guardian or Other + // Fosterer, Guardian or Other - for these, we'll keep the relationship exactly the same parentalRelationship = parent.relationship parentalRelationshipOther = parent.relationshipOther parentHasParentalResponsibility = parent.hasParentalResponsibility } } - // Slot details (NB: session date is expected to specify midday) + // Extra time requirement (and reason) const needsExtraTime = faker.datatype.boolean(0.2) let extraTimeReason if (needsExtraTime) { @@ -109,32 +112,20 @@ export function generateClinicAppointment(booking, context) { ]) 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 - ] + // Appointment time + const startAt = faker.helpers.arrayElement(session.availableAppointmentTimes) + const slotsCovered = 1 // TODO: needsExtraTime ? 2 : 1 + const endAt = addMinutes(startAt, clinicSlotLength * slotsCovered) + + // Have the child signed up for whatever they were invited for + const selected_programme_ids = patient.clinicProgramme_ids return booking.addAppointment({ uuid, + booking_uuid, patient_uuid, - unmatchedFirstName, - unmatchedLastName, - unmatchedDob, + child, needsExtraTime, extraTimeReason, parentalRelationship, @@ -143,7 +134,6 @@ export function generateClinicAppointment(booking, context) { session_id, startAt, endAt, - selected_programme_ids, - primary_programme_ids + selected_programme_ids }) } diff --git a/app/generators/clinic-booking.js b/app/generators/clinic-booking.js index 3e5f509ce..b102c7623 100644 --- a/app/generators/clinic-booking.js +++ b/app/generators/clinic-booking.js @@ -1,6 +1,5 @@ import { fakerEN_GB as faker } from '@faker-js/faker' -import { SessionPresets } from '../enums.js' import { ClinicBooking } from '../models.js' /** @@ -12,15 +11,11 @@ import { ClinicBooking } from '../models.js' 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 + bookingReference }, context ) diff --git a/app/globals.js b/app/globals.js index c09fc9704..9fbcde8e7 100644 --- a/app/globals.js +++ b/app/globals.js @@ -314,8 +314,10 @@ export default () => { } const summaryRows = [] - const appointmentsByHour = session.appointmentsByHour - for (const [hour, appointmentTimes] of Object.entries(appointmentsByHour)) { + const appointmentTimesByHour = session.appointmentTimesByHour + for (const [hour, appointmentTimes] of Object.entries( + appointmentTimesByHour + )) { summaryRows.push({ key: { text: `${hour}:00 to ${parseInt(hour) + 1}:00` }, value: { text: `${appointmentTimes.length} available` } diff --git a/app/locales/en.js b/app/locales/en.js index ecebc219d..8a6ccaef2 100644 --- a/app/locales/en.js +++ b/app/locales/en.js @@ -239,8 +239,12 @@ export const en = { label: 'Clinic appointments', title: 'Unmatched clinic appointments' }, - count: - '{count, plural, =0 {No unmatched clinic appointments} one {1 unmatched clinic appointment} other {# unmatched clinic appointments}}', + count: { + total: + '{count, plural, =0 {No unmatched clinic appointments} one {1 unmatched clinic appointment} other {{count} unmatched clinic appointments}}', + session: + '{count, plural, =0 {No unmatched clinic appointments at {location}} one {1 unmatched clinic appointment at {location}} other {{count} unmatched clinic appointments at {location}}}' + }, results: '{count, plural, =0 {No unmatched appointments matching your search criteria were found} one {Showing {from} to {to} of {count} unmatched appointment} other {Showing {from} to {to} of {count} unmatched appointments}}', summary: { diff --git a/app/models/child.js b/app/models/child.js index 1851c6e47..a60edd775 100644 --- a/app/models/child.js +++ b/app/models/child.js @@ -9,6 +9,7 @@ import { EthnicGroup, Impairment } from '../enums.js' +import { School } from '../models.js' import { convertIsoDateToObject, convertObjectToIsoDate, @@ -261,11 +262,11 @@ export class Child { /** * Get school * - * @returns {object|undefined} School + * @returns {School|undefined} School */ get school() { if (this.school_id) { - return schools[this.school_id] + return new School(schools[this.school_id], this.context) } } diff --git a/app/models/clinic-appointment.js b/app/models/clinic-appointment.js index 43300dbd8..00f15dc80 100644 --- a/app/models/clinic-appointment.js +++ b/app/models/clinic-appointment.js @@ -1,6 +1,6 @@ import { fakerEN_GB as faker } from '@faker-js/faker' -import { Child, Patient, Programme, Session } from '../models.js' +import { Child, ClinicBooking, Patient, Programme, Session } from '../models.js' import { formatDate } from '../utils/date.js' import { formatLinkWithSecondaryText, @@ -14,6 +14,7 @@ import { * @param {object} [context] - Context * @property {object} [context] - Context, for access to patients, programmes, etc. * @property {string} uuid - Unique ID for this clinic appointment + * @property {string} booking_uuid - Unique ID for the booking under 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? @@ -39,6 +40,7 @@ export class ClinicAppointment { 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({}) @@ -65,6 +67,21 @@ export class ClinicAppointment { this.healthAnswers = options?.healthAnswers || {} } + /** + * Get the booking that this appointment's part of + * + * @returns {ClinicBooking|undefined} - the booking that this is part of + */ + get booking() { + try { + if (this.booking_uuid) { + return ClinicBooking.findOne(this.booking_uuid, this.context) + } + } catch (error) { + console.error('ClinicAppointment.booking', error.message) + } + } + /** * Get patient * @@ -231,8 +248,8 @@ export class ClinicAppointment { dateAndTime: `${session?.formatted.date} at ${formattedStartTime}`, timeSlot: `${formattedStartTime} to ${formattedEndTime}`, vaccinations: this.#getSelectedProgrammes(this.context) - .map((programme) => programme.name) - .join(', ') + .map((programme) => programme.nameTag) + .join(' ') } } @@ -245,7 +262,7 @@ export class ClinicAppointment { return { summary: formatLinkWithSecondaryText( this.uri, - this.parentalRelationship, // TODO: look up name of parent in booking + this.booking?.parent?.fullNameAndRelationship, `for ${this.child.fullName}` ) } @@ -261,7 +278,7 @@ export class ClinicAppointment { } /** - * Get URI + * Get URI, without the context of the session * * @returns {string} URI */ diff --git a/app/models/clinic-vaccination-period.js b/app/models/clinic-vaccination-period.js index 07b06901c..a8e167dca 100644 --- a/app/models/clinic-vaccination-period.js +++ b/app/models/clinic-vaccination-period.js @@ -93,12 +93,12 @@ export class ClinicVaccinationPeriod { } /** - * Get all appointment slot start times grouped by the hour in which they start + * Get all appointment slot start times, replicated for the number of vaccinators * * @param {number} appointmentLengthInMinutes - the length of a single appointment slot, in minutes - * @returns {Array} - appointment start times grouped by the hour in which they start + * @returns {Array} - all appointment slot start times */ - appointmentsByHour(appointmentLengthInMinutes) { + allAppointmentTimes(appointmentLengthInMinutes) { const totalMinutesInPeriod = (this.endAt.getTime() - this.startAt.getTime()) / 1000 / 60 if (totalMinutesInPeriod <= 0) { @@ -116,6 +116,19 @@ export class ClinicVaccinationPeriod { .map((index) => addMinutes(this.startAt, index * appointmentLengthInMinutes) ) + return appointmentStartTimes + } + + /** + * Get all appointment slot start times grouped by the hour in which they start + * + * @param {number} appointmentLengthInMinutes - the length of a single appointment slot, in minutes + * @returns {Array} - appointment start times grouped by the hour in which they start + */ + appointmentTimesByHour(appointmentLengthInMinutes) { + const appointmentStartTimes = this.allAppointmentTimes( + appointmentLengthInMinutes + ) return _.groupBy(appointmentStartTimes, (time) => time.getHours()) } diff --git a/app/models/patient.js b/app/models/patient.js index 8bf0a0d94..aa2ebe224 100644 --- a/app/models/patient.js +++ b/app/models/patient.js @@ -339,10 +339,10 @@ export class Patient extends Child { this.context ) - // Patient invited to clinic if invitation needed and invitation sent - patientProgramme.invitedToClinic = - patientProgramme.canInviteToSession && - this.clinicProgramme_ids.includes(programme.id) + // Patient invited to clinic if invitation sent + patientProgramme.invitedToClinic = this.clinicProgramme_ids.includes( + programme.id + ) programmes[programme.id] = patientProgramme } diff --git a/app/models/session.js b/app/models/session.js index 1a57393af..3b6ab1717 100644 --- a/app/models/session.js +++ b/app/models/session.js @@ -39,7 +39,11 @@ import { today } from '../utils/date.js' import { tokenize } from '../utils/object.js' -import { getConsentWindow, getSessionActivityCount } from '../utils/session.js' +import { + getConsentWindow, + getSessionActivityCount, + removeSlots +} from '../utils/session.js' import { formatLink, formatList, @@ -418,17 +422,7 @@ export class Session { * @returns {number} - total number of appointment slots in this clinic session */ get totalAppointmentCount() { - if (this.type !== SessionType.Clinic) { - return 0 - } - - if (!this.vaccinationPeriods?.length) { - return 0 - } - - return this.vaccinationPeriods - .map((period) => period.appointmentCount(this.appointmentLength)) - .reduce((total, next) => total + next, 0) + return this.allAppointmentTimes.length } /** @@ -437,8 +431,7 @@ export class Session { * @returns {number} - the number of appointment slots remaining in this clinic session */ get availableAppointmentCount() { - // TODO: calculate this value from the actual appointments - return this.totalAppointmentCount + return this.availableAppointmentTimes.length } /** @@ -463,7 +456,7 @@ export class Session { * * @returns {Array} - the start times of all possible appointments, grouped by their start hour */ - get appointmentsByHour() { + get appointmentTimesByHour() { if (this.type !== SessionType.Clinic) { throw new Error('Session must be a clinic to have appointments') } @@ -471,7 +464,7 @@ export class Session { const sortedPeriods = _.sortBy(this.vaccinationPeriods, 'startAt') const allAppointmentsByHour = sortedPeriods.reduce( (result, vaccinationPeriod) => { - const periodAppointments = vaccinationPeriod.appointmentsByHour( + const periodAppointments = vaccinationPeriod.appointmentTimesByHour( this.appointmentLength ) for (const [hour, appointments] of Object.entries(periodAppointments)) { @@ -537,20 +530,86 @@ export class Session { } /** - * Get all appointments for this clinic with unmatched child details + * Get a list of all available appointment slot times, including parallel appointments + * + * @returns {Array} - a list of appointment times available to book + */ + get availableAppointmentTimes() { + return removeSlots(this.allAppointmentTimes, this.bookedAppointmentTimes) + } + + /** + * Get a list of all appointment slot times, booked or otherwise, including parallel appointments + * + * @returns {Array} - the start times of all possible appointments in this clinic + */ + get allAppointmentTimes() { + const sortedPeriods = _.sortBy(this.vaccinationPeriods, 'startAt') + return sortedPeriods + .map((period) => period.allAppointmentTimes(this.appointmentLength)) + .flat() + } + + /** + * Get a list of all booked appointment time slots, including parallel appointments + * + * @returns {Array} - a list of appointment times booked so far + */ + get bookedAppointmentTimes() { + const appointments = this.appointments + return appointments.map(({ startAt }) => startAt) + + // TODO: expand on this when we can have appointments that cover multiple slots + } + + /** + * For a clinic session, get the percentage of slots already booked + * + * @returns {number} - the (rounded) percentage of slots booked + */ + get percentBooked() { + if (this.type !== SessionType.Clinic) { + throw new Error( + 'Booking percentages are only relevant to clinic sessions' + ) + } + + if (!this.allAppointmentTimes.length) { + return 100 + } + + return Math.round( + (this.bookedAppointmentTimes.length / this.allAppointmentTimes.length) * + 100 + ) + } + + /** + * Get all appointments for this clinic * * @returns {Array} - the appointments made for unmatched children */ - get unmatchedAppointments() { + get appointments() { if (this.type !== SessionType.Clinic) { throw new Error( 'Unmatched clinic appointments are only relevant to clinic sessions' ) } - const appointments = this.patientSessions - .map(({ clinicAppointment }) => clinicAppointment) - .filter(Boolean) + const appointments = this.patientSessions.map( + ({ clinicAppointment }) => clinicAppointment + ) + + return appointments + } + + /** + * Get all appointments for this clinic with unmatched child details + * + * @returns {Array} - the appointments made for unmatched children + */ + get unmatchedAppointments() { + const appointments = this.appointments return appointments.filter((patient_uuid) => !patient_uuid) } diff --git a/app/utils/session.js b/app/utils/session.js index 50458241b..d178e8546 100644 --- a/app/utils/session.js +++ b/app/utils/session.js @@ -114,3 +114,38 @@ export const getSessionYearGroups = (school_id, sessionPresets) => { [...programmeYearGroups].includes(yearGroup) ) } + +/** + * Remove a list of slots from a wider list of all possible slots + * + * @param {Array} allSlots - the full set of time slots + * @param {Array} slotsToRemove - the slots to remove from allSlots + * @returns {Array} - an array of the remaining slots + */ +export function removeSlots(allSlots, slotsToRemove) { + const slotRemovalCounts = new Map() + + // Work out how many of each time we need to remove + for (const date of slotsToRemove) { + slotRemovalCounts.set( + date.getTime(), + (slotRemovalCounts.get(date.getTime()) || 0) + 1 + ) + } + + // Get rid of the according number of slots for each time + return allSlots.filter((date) => { + const countToRemove = slotRemovalCounts.get(date.getTime()) || 0 + + if (countToRemove === 0) { + // No need to remove this time slot + return true + } + + // Scratch one off from the number to remove at this timepoint... + slotRemovalCounts.set(date.getTime(), countToRemove - 1) + + // ...and filter this one out of the original array + return false + }) +} diff --git a/app/views/appointments/list.njk b/app/views/appointments/list.njk index 975d36682..05ca274d8 100644 --- a/app/views/appointments/list.njk +++ b/app/views/appointments/list.njk @@ -47,7 +47,7 @@ } if session, { header: __("appointments.vaccinations.label"), - text: appointment.formatted.vaccinations + text: appointment.formatted.vaccinations | safe } if session, { header: __("actions.label"), @@ -69,7 +69,7 @@ id: "appointments", responsive: true, card: { - heading: __mf("appointments.count", { count: appointments.length }), + heading: __mf("appointments.count.session", { count: appointments.length, location: session.location.name }) if session else __mf("appointments.count.total", { count: appointments.length }), headingLevel: 3 }, head: [ @@ -91,6 +91,10 @@ count: results.count }) | nhsukMarkdown }} {% else %} - {{ __mf("appointments.count", { count: 0 }) | nhsukMarkdown }} + {% if not session %} + {{ __mf("appointments.count.total", { count: 0 }) | nhsukMarkdown }} + {% else %} + {{ __mf("appointments.count.session", { count: appointments.length, location: session.location.name }) | nhsukMarkdown }} + {% endif %} {% endif %} {% endblock %} diff --git a/lib/create-data.js b/lib/create-data.js index 9de60b896..5f54ad125 100644 --- a/lib/create-data.js +++ b/lib/create-data.js @@ -11,7 +11,6 @@ import usersData from '../app/datasets/users.js' import vaccinesData from '../app/datasets/vaccines.js' import { ArchiveRecordReason, - ConsentOutcome, ConsentWindow, PatientStatus, ProgrammeType, @@ -25,7 +24,8 @@ import { UploadType, UserRole, ReplyDecision, - ReplyMethod + ReplyMethod, + PatientClinicStatus } from '../app/enums.js' import { generateBatch } from '../app/generators/batch.js' import { generateClinicAppointment } from '../app/generators/clinic-appointment.js' @@ -34,7 +34,6 @@ import { generateClinicVaccinationPeriods } from '../app/generators/clinic-vacci 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 { generatePDSRecord } from '../app/generators/pds-record.js' import { generateSession } from '../app/generators/session.js' @@ -71,7 +70,6 @@ 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) || 10 const totalPatients = Number(process.env.RECORDS) || 4000 // Context @@ -163,7 +161,7 @@ context.uploads[cohortUpload.id] = cohortUpload for (const school of Object.values(context.schools)) { const patient_uuids = Object.values(context.patients) .filter(({ school_id }) => school_id === school.id) - .flatMap(({ uuid }) => uuid) + .map(({ uuid }) => uuid) const schoolUpload = generateUpload( patient_uuids, @@ -184,7 +182,7 @@ for (const preset of Object.values(SessionPresets)) { // Adolescent programmes are only held at secondary schools preset.adolescent ? phase === SchoolPhase.Secondary : phase ) - .flatMap(({ id }) => id) + .map(({ id }) => id) // Schedule school sessions for (const school_id of ids) { @@ -195,7 +193,6 @@ for (const preset of Object.values(SessionPresets)) { } // Schedule clinic sessions - // TODO: Get clinics from team (linked to patient’s school) const clinicsPerPreset = 3 const clinic_ids = faker.helpers.arrayElements( Object.values(context.teams).flatMap((team) => team.clinic_ids), @@ -222,113 +219,42 @@ 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 -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 - } - - // 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 } - ]) - Array.from({ length: additionalAppointmentsCount }).forEach(() => - generateClinicAppointment(booking, context) - ) -} - // Invite // TODO: Don’t invite patients who’ve already had a programme’s vaccination context.patientSessions = {} -for (let session of Object.values(context.sessions)) { +for (let session of Object.values(context.sessions).filter( + ({ type }) => type === SessionType.School +)) { session = new Session(session, context) - if (session.type === SessionType.School) { - const patientsInsideSchool = Object.values(context.patients).filter( - ({ school_id }) => school_id === session.school_id - ) - - for (let patient of patientsInsideSchool) { - patient = new Patient(patient, context) - - for (const programme_id of session.programme_ids) { - const { canInviteToSession } = patient.programmes[programme_id] - - if (canInviteToSession) { - const patientSession = new PatientSession( - { - createdAt: session.openAt, - patient_uuid: patient.uuid, - programme_id, - session_id: session.id - }, - context - ) - - // Add patient to session - patient.addToSession(patientSession) + const patientsInsideSchool = Object.values(context.patients).filter( + ({ school_id }) => school_id === session.school_id + ) - // 2️⃣🅰️ REQUEST CONSENT - patient.requestConsent(patientSession) + for (let patient of patientsInsideSchool) { + patient = new Patient(patient, context) - context.patientSessions[patientSession.uuid] = patientSession - } - } - } - } - - if (session.type === SessionType.Clinic) { - const patientsOutsideSchool = Object.values(context.patients).filter( - ({ school_id }) => ['888888', '999999'].includes(school_id) - ) + for (const programme_id of session.programme_ids) { + const { canInviteToSession } = patient.programmes[programme_id] - for (const patient of patientsOutsideSchool) { - for (const programme_id of session.programme_ids) { - const { canInviteToSession } = patient.programmes[programme_id] - - if (canInviteToSession) { - const patientSession = new PatientSession( - { - patient_uuid: patient.uuid, - programme_id, - session_id: session.id - }, - context - ) + if (canInviteToSession) { + const patientSession = new PatientSession( + { + createdAt: session.openAt, + patient_uuid: patient.uuid, + programme_id, + session_id: session.id + }, + context + ) - // Add patient to session - patient.addToSession(patientSession) + // Add patient to session + patient.addToSession(patientSession) - // 2️⃣🅱️ INVITE home-educated/school unknown patient to clinic - patient.requestConsent(patientSession) + // 2️⃣🅰️ REQUEST CONSENT + patient.requestConsent(patientSession) - context.patientSessions[patientSession.uuid] = patientSession - } + context.patientSessions[patientSession.uuid] = patientSession } } } @@ -337,7 +263,9 @@ for (let session of Object.values(context.sessions)) { // Consent let programme context.replies = {} -for (const patientSession of Object.values(context.patientSessions)) { +for (const patientSession of Object.values(context.patientSessions).filter( + (patientSession) => patientSession.session.type === SessionType.School +)) { const { patient, session } = patientSession let getConsentForPatient @@ -402,7 +330,9 @@ for (const patientSession of Object.values(context.patientSessions)) { // Screen and record context.instructions = {} context.vaccinations = {} -for (const patientSession of Object.values(context.patientSessions)) { +for (const patientSession of Object.values(context.patientSessions).filter( + (patientSession) => patientSession.session.type === SessionType.School +)) { // Screen answers to health questions if (patientSession.screen === ScreenOutcome.NeedsTriage) { // Get triage notes @@ -535,48 +465,83 @@ for (const patientSession of Object.values(context.patientSessions)) { } } -// Invite remaining unvaccinated patients to clinics -for (const programme of Object.values(context.programmes)) { - const programmeSchoolSessions = Object.values(context.sessions).filter( - ({ programme_ids }) => programme_ids.includes(programme.id) - ) +// Clinic invites for children who are clinic ready e.g. home-educated or missed school session +for (const patient of Patient.findAll(context)) { + const clinicReadyProgramme_ids = Object.values(patient.programmes) + .filter(({ clinicStatus }) => clinicStatus === PatientClinicStatus.Ready) + .map(({ programme_id }) => programme_id) - const programmeClinicSession = Object.values(context.sessions) - .filter(({ programme_ids }) => programme_ids.includes(programme.id)) - .find(({ type }) => type === SessionType.Clinic) + // Invite to book a clinic appointment... + if (clinicReadyProgramme_ids.length) { + patient.inviteToClinic(clinicReadyProgramme_ids) + Patient.update(patient.uuid, patient, context) + } +} - // Move patients without outcome in a completed school session to a clinic - for (const session of programmeSchoolSessions) { - if (session.isCompleted) { - // TODO: Patients have no context, so won’t have outcomes to filter on - const patientSessions = session.patients - .filter(({ report }) => report !== PatientStatus.Vaccinated) - .filter(({ screen }) => screen !== ScreenOutcome.DoNotVaccinate) - .filter(({ consent }) => consent !== ConsentOutcome.Refused) - .filter(({ consent }) => consent !== ConsentOutcome.FinalRefusal) +// Clinic appointments +// To prevent us just filling every possible clinic slot, decide how full we want each clinic to get +const clinicSessions = Object.values(context.sessions).filter( + ({ type }) => type === SessionType.Clinic +) +const clinicTargets = new Map( + clinicSessions.map((session) => [ + session, + faker.number.int({ min: 30, max: 100 }) + ]) +) +context.clinicBookings = {} +for (const patient of Patient.findAll(context)) { + if (!patient.clinicProgramme_ids?.length) { + continue + } - for (let patientSession of patientSessions) { - const patient = new Patient(patientSession.patient, context) + const matchingClinicSessions = clinicSessions.filter((session) => { + const matchingProgramme_ids = [ + ...new Set(patient.clinicProgramme_ids).intersection( + new Set(session.programme_ids) + ) + ] - const clinicPatientSession = new PatientSession( - { - createdBy_uid: nurse.uid, - patient_uuid: patient.uuid, - programme_id: programme.id, - session_id: programmeClinicSession.id - }, - context - ) - context.patientSessions[clinicPatientSession.uuid] = - clinicPatientSession + if (!matchingProgramme_ids.length) { + return false + } - // Add patient to community clinic - patient.addToSession(clinicPatientSession) + return session.percentBooked < clinicTargets.get(session) + }) - // 2️⃣ INVITE TO BOOK CLINIC APPOINTMENT - patient.inviteToClinic(programmeClinicSession.programme_ids) - } - } + if (!matchingClinicSessions.length) { + continue + } + + // Choose a clinic session in which we'll book an appointment + const session = faker.helpers.arrayElement(matchingClinicSessions) + + // Create a single child's appointment and containing booking + // TODO: find or create siblings to add as well + const booking = generateEmptyClinicBooking(context) + const appointment = generateClinicAppointment(patient, session, booking) + + // Store the booking on the context + context.clinicBookings[booking.uuid] = booking + + // If we've matched the child, formally add them to the session (otherwise the appointment will appear as an umatched appointment) + if (appointment.patient) { + // Create a patient session for each programme being vaccinated, assuming the child + // will be vaccinated for everything for which they're clinic-ready + appointment.patient.clinicProgramme_ids.forEach((programme_id) => { + const patientSession = new PatientSession( + { + patient_uuid: appointment.patient.uuid, + programme_id, + session_id: session.id + }, + context + ) + + appointment.patient.addToSession(patientSession) + + context.patientSessions[patientSession.uuid] = patientSession + }) } } From eec0cf7b97f8cecaf46825a4401c64c9d8df8b38 Mon Sep 17 00:00:00 2001 From: Mal Ross Date: Wed, 6 May 2026 12:16:40 +0100 Subject: [PATCH 07/14] Improvements to faked clinic appointments New: - added unmatched appointment count to total for Review tab - fix display of the name and relationship of parent for unmatched appointments --- app/data.js | 7 ++++--- app/generators/clinic-appointment.js | 4 ++-- app/models/clinic-appointment.js | 15 +++++++++++++-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/app/data.js b/app/data.js index 3b15fae5b..1ac5b4fcc 100644 --- a/app/data.js +++ b/app/data.js @@ -59,17 +59,18 @@ const data = { const unmatchedAppointmentCount = ClinicBooking.findAll(data) ?.flatMap(({ appointments }) => appointments) .filter(({ patient_uuid }) => !patient_uuid).length -const consentCount = Consent.findAll(data).length || 0 +const unmatchedConsentCount = Consent.findAll(data).length || 0 const moveCount = Move.findAll(data).length || 0 const noticeCount = Notice.findAll(data).filter(({ archivedAt }) => !archivedAt).length || 0 data.counts = { appointments: unmatchedAppointmentCount, - consents: consentCount, + consents: unmatchedConsentCount, moves: moveCount, notices: noticeCount, - review: consentCount + moveCount + noticeCount, + review: + unmatchedAppointmentCount + unmatchedConsentCount + moveCount + noticeCount, sessions: Session.findAll(data).length } diff --git a/app/generators/clinic-appointment.js b/app/generators/clinic-appointment.js index c11ba1067..2086110ac 100644 --- a/app/generators/clinic-appointment.js +++ b/app/generators/clinic-appointment.js @@ -22,7 +22,7 @@ export function generateClinicAppointment(patient, session, booking) { const session_id = session.id let patient_uuid, child - if (faker.datatype.boolean(0.95)) { + if (faker.datatype.boolean(0.9)) { // Matched appointment patient_uuid = patient.uuid @@ -58,7 +58,7 @@ export function generateClinicAppointment(patient, session, booking) { let parentalRelationship, parentalRelationshipOther, parentHasParentalResponsibility - if (!booking.parent) { + if (!booking.parent.fullName) { // First appointment, so set up the booking's parent booking.parent = patient.parent1 || diff --git a/app/models/clinic-appointment.js b/app/models/clinic-appointment.js index 00f15dc80..dfd8ec2f3 100644 --- a/app/models/clinic-appointment.js +++ b/app/models/clinic-appointment.js @@ -1,6 +1,13 @@ import { fakerEN_GB as faker } from '@faker-js/faker' -import { Child, ClinicBooking, Patient, Programme, Session } from '../models.js' +import { + Child, + ClinicBooking, + Parent, + Patient, + Programme, + Session +} from '../models.js' import { formatDate } from '../utils/date.js' import { formatLinkWithSecondaryText, @@ -259,10 +266,14 @@ export class ClinicAppointment { * @returns {object} Formatted links */ get link() { + const parent = new Parent({ + fullName: this.booking?.parent?.fullName, + relationship: this.parentalRelationship + }) return { summary: formatLinkWithSecondaryText( this.uri, - this.booking?.parent?.fullNameAndRelationship, + parent.fullNameAndRelationship, `for ${this.child.fullName}` ) } From 7f1e9bb40286fb8464ce13fb6b6f4c9560654d74 Mon Sep 17 00:00:00 2001 From: Mal Ross Date: Wed, 6 May 2026 13:29:10 +0100 Subject: [PATCH 08/14] Further create-data improvements for clinics New: - now only inviting half of the schools to clinic (plus home-ed and unknown) - removed unnecessary filters for consent replies and recording (only school sessions will have patient-session records at that point) --- lib/create-data.js | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/lib/create-data.js b/lib/create-data.js index 5f54ad125..fdd1116af 100644 --- a/lib/create-data.js +++ b/lib/create-data.js @@ -263,9 +263,7 @@ for (let session of Object.values(context.sessions).filter( // Consent let programme context.replies = {} -for (const patientSession of Object.values(context.patientSessions).filter( - (patientSession) => patientSession.session.type === SessionType.School -)) { +for (const patientSession of Object.values(context.patientSessions)) { const { patient, session } = patientSession let getConsentForPatient @@ -330,9 +328,7 @@ for (const patientSession of Object.values(context.patientSessions).filter( // Screen and record context.instructions = {} context.vaccinations = {} -for (const patientSession of Object.values(context.patientSessions).filter( - (patientSession) => patientSession.session.type === SessionType.School -)) { +for (const patientSession of Object.values(context.patientSessions)) { // Screen answers to health questions if (patientSession.screen === ScreenOutcome.NeedsTriage) { // Get triage notes @@ -465,8 +461,19 @@ for (const patientSession of Object.values(context.patientSessions).filter( } } -// Clinic invites for children who are clinic ready e.g. home-educated or missed school session +// Clinic invites +// for children who are clinic ready e.g. home-educated or missed school session, but +// only do it for half of the schools (so we leave some children in the clinic-ready state) +const invited_school_ids = new Set([ + ...Object.keys(context.schools).filter((_, index) => index % 2 === 0), + '888888', + '999999' +]) for (const patient of Patient.findAll(context)) { + // Skip this school to avoid inviting everyone? + if (!invited_school_ids.has(patient.school_id)) { + continue + } const clinicReadyProgramme_ids = Object.values(patient.programmes) .filter(({ clinicStatus }) => clinicStatus === PatientClinicStatus.Ready) .map(({ programme_id }) => programme_id) @@ -491,10 +498,12 @@ const clinicTargets = new Map( ) context.clinicBookings = {} for (const patient of Patient.findAll(context)) { + // Exclude anyone not invited to clinic yet if (!patient.clinicProgramme_ids?.length) { continue } + // Choose a clinic session in which we'll book an appointment const matchingClinicSessions = clinicSessions.filter((session) => { const matchingProgramme_ids = [ ...new Set(patient.clinicProgramme_ids).intersection( @@ -508,12 +517,9 @@ for (const patient of Patient.findAll(context)) { return session.percentBooked < clinicTargets.get(session) }) - if (!matchingClinicSessions.length) { continue } - - // Choose a clinic session in which we'll book an appointment const session = faker.helpers.arrayElement(matchingClinicSessions) // Create a single child's appointment and containing booking From 916e083543dcf711ecc196836f06947876f726a4 Mon Sep 17 00:00:00 2001 From: Mal Ross Date: Wed, 6 May 2026 21:43:49 +0100 Subject: [PATCH 09/14] Remove duplicated logic for clinic-ready programmes --- lib/create-data.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/create-data.js b/lib/create-data.js index fdd1116af..96f6a8c8d 100644 --- a/lib/create-data.js +++ b/lib/create-data.js @@ -24,8 +24,7 @@ import { UploadType, UserRole, ReplyDecision, - ReplyMethod, - PatientClinicStatus + ReplyMethod } from '../app/enums.js' import { generateBatch } from '../app/generators/batch.js' import { generateClinicAppointment } from '../app/generators/clinic-appointment.js' @@ -474,9 +473,7 @@ for (const patient of Patient.findAll(context)) { if (!invited_school_ids.has(patient.school_id)) { continue } - const clinicReadyProgramme_ids = Object.values(patient.programmes) - .filter(({ clinicStatus }) => clinicStatus === PatientClinicStatus.Ready) - .map(({ programme_id }) => programme_id) + const clinicReadyProgramme_ids = patient.clinicReadyProgramme_ids // Invite to book a clinic appointment... if (clinicReadyProgramme_ids.length) { From cef7d9b04186feb65b36862474782146ff5e0aa2 Mon Sep 17 00:00:00 2001 From: Mal Ross Date: Wed, 6 May 2026 22:11:14 +0100 Subject: [PATCH 10/14] Simplify the set of clinic statuses, removing those with no clear need We decided not to use registration for clinic sessions, so there's no need for attending or attended statuses. At least, none yet stated in user research, so let's simplify till we hear the need. This commit also checks for clinic booked status by looking for evidence of appointments in yet-to-happen clinics instead of the old way of looking at the last patient-session and hoping it was a clinic. --- app/enums.js | 4 +--- app/models/patient-programme.js | 22 ++++++++-------------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/app/enums.js b/app/enums.js index e23ef3eb9..5789f3f8c 100644 --- a/app/enums.js +++ b/app/enums.js @@ -342,9 +342,7 @@ export const PatientStatus = { export const PatientClinicStatus = { Ready: 'Can be invited', Invited: 'Invited', - Booked: 'Booked in', - Registered: 'Attending', - Completed: 'Attended' + Booked: 'Booked' } /** diff --git a/app/models/patient-programme.js b/app/models/patient-programme.js index fc81cbba5..6dacdbf3f 100644 --- a/app/models/patient-programme.js +++ b/app/models/patient-programme.js @@ -161,22 +161,16 @@ export class PatientProgramme { get clinicStatus() { // Work backwards from the most complete status - const { lastPatientSession } = this // should we look beyond the last session? + // Booked into a clinic that hasn't happened / isn't happening yet? if ( - lastPatientSession && - lastPatientSession.session.type === SessionType.Clinic + this.patientSessions.some( + ({ session }) => + session.type === SessionType.Clinic && + ![SessionStatus.Completed, SessionStatus.Closed].includes( + session.status + ) + ) ) { - // Clinic vaccination has already happened? - if (lastPatientSession.outcome === VaccinationOutcome.Vaccinated) { - return PatientClinicStatus.Completed - } - - // Attending a clinic right now? - if (lastPatientSession.register === RegistrationOutcome.Present) { - return PatientClinicStatus.Registered - } - - // For the PatientSession at a clinic to exist, the child must be booked in return PatientClinicStatus.Booked } From a290bdfc1b893852b32fd57a1766478ea5506ca2 Mon Sep 17 00:00:00 2001 From: Mal Ross Date: Wed, 6 May 2026 22:12:00 +0100 Subject: [PATCH 11/14] Differentiate MMR vs MMRV in clinic invitation summaries --- app/models/patient.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/patient.js b/app/models/patient.js index aa2ebe224..1eab136e8 100644 --- a/app/models/patient.js +++ b/app/models/patient.js @@ -496,7 +496,7 @@ export class Patient extends Child { ? `Last reminder sent on ${this.lastReminderDate}` : 'No reminders sent', clinicProgramme_ids: this.clinicProgramme_ids - .map((id) => Programme.findOne(id, this.context).nameTag) + .map((id) => this.programmes[id].programme.nameTag) .join(' ') } } From 9064e72527ee8904599055413f85c860259a576e Mon Sep 17 00:00:00 2001 From: Mal Ross Date: Thu, 7 May 2026 12:30:20 +0100 Subject: [PATCH 12/14] Replace programme route param in clinic booking with querystring This commit also removes the session preset from clinic bookings and the primary programme from bookings and appointments; it's not needed there. --- app/controllers/book-into-a-clinic.js | 81 ++++++++++--------- app/locales/en.js | 20 ++--- app/middleware/navigation.js | 12 +-- app/models/clinic-appointment.js | 24 +----- app/models/clinic-booking.js | 39 +-------- app/models/patient-programme.js | 4 +- app/models/patient.js | 1 - app/routes/book-into-a-clinic.js | 34 +++----- app/utils/clinic-appointment.js | 80 +++++++----------- app/utils/clinic-booking.js | 14 +++- .../form/vaccination-choice.njk | 2 +- app/views/book-into-a-clinic/start.njk | 13 +-- app/views/index.njk | 2 +- 13 files changed, 122 insertions(+), 204 deletions(-) diff --git a/app/controllers/book-into-a-clinic.js b/app/controllers/book-into-a-clinic.js index 98a920677..8a832614c 100644 --- a/app/controllers/book-into-a-clinic.js +++ b/app/controllers/book-into-a-clinic.js @@ -2,54 +2,59 @@ 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 { ParentalRelationship } from '../enums.js' import { ClinicBooking } from '../models.js' import { getAllAppointmentPaths, getHealthQuestionPaths, getPreviousAddressItems } from '../utils/clinic-appointment.js' +import { + ConjunctionType, + programmeNamesListForSentence +} from '../utils/programme.js' import { kebabToCamelCase } from '../utils/string.js' export const bookIntoClinicController = { - read(request, response, next, session_preset_slug) { + setupServiceHeader(request, response, next) { 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() }, - redirect(request, response) { - const { sessionPreset } = response.locals + readProgrammes(request, response) { + const { data } = request.session - response.redirect(`${request.baseUrl}/${sessionPreset.slug}/start`) + // Read the invited programme IDs from the querystring and store them + const { programme_id } = request.query + let programme_ids + if (programme_id) { + programme_ids = Array.isArray(programme_id) + ? programme_id + : [programme_id] + + data.clinicInvite = { + programme_ids, + programmeNames: programmeNamesListForSentence( + programme_ids, + ConjunctionType.and, + data + ) + } + } + + response.redirect(`/book-into-a-clinic/start`) }, 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 - ) + const booking = ClinicBooking.create({}, 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` @@ -57,7 +62,7 @@ export const bookIntoClinicController = { }, readForm(request, response, next) { - const { session_preset_slug, booking_uuid } = request.params + const { booking_uuid } = request.params const appointment_uuid = request.params.appointment_uuid const { data, referrer } = request.session @@ -115,30 +120,29 @@ export const bookIntoClinicController = { } const journey = { - [`/${session_preset_slug}`]: {}, - [`/${session_preset_slug}/${booking_uuid}/new/child-count`]: {}, + [`/`]: {}, + [`/${booking_uuid}/new/child-count`]: {}, // Appointment journey; once per child ...getAllAppointmentPaths( - session_preset_slug, booking_uuid, request.session.data, booking.appointments ), // Parent journey - [`/${session_preset_slug}/${booking_uuid}/new/parent`]: { - [`/${session_preset_slug}/${booking_uuid}/new/offer-health-questions`]: - () => !request.session.data.booking?.parent?.tel + [`/${booking_uuid}/new/parent`]: { + [`/${booking_uuid}/new/offer-health-questions`]: () => + !request.session.data.booking?.parent?.tel }, - [`/${session_preset_slug}/${booking_uuid}/new/contact-preference`]: {}, + [`/${booking_uuid}/new/contact-preference`]: {}, // Check answers - [`/${session_preset_slug}/${booking_uuid}/new/check-answers`]: {}, + [`/${booking_uuid}/new/check-answers`]: {}, // Health questions (optional) - [`/${session_preset_slug}/${booking_uuid}/new/offer-health-questions`]: { - [`/${session_preset_slug}/${booking_uuid}/new/confirmation`]: { + [`/${booking_uuid}/new/offer-health-questions`]: { + [`/${booking_uuid}/new/confirmation`]: { data: 'transaction.optedIntoHealthQuestions', value: 'false' } @@ -147,14 +151,14 @@ export const bookIntoClinicController = { // 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}/new/`, booking_uuid, data.wizard, data ), // Confirmation! \o/ - [`/${session_preset_slug}/${booking_uuid}/new/confirmation`]: {} + [`/${booking_uuid}/new/confirmation`]: {} } const paths = wizard(journey, request) @@ -274,6 +278,11 @@ export const bookIntoClinicController = { show(request, response) { const view = request.params.view || 'start' + // 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### ######') + response.render(`book-into-a-clinic/${view}`) } } diff --git a/app/locales/en.js b/app/locales/en.js index 8a6ccaef2..81fba5e58 100644 --- a/app/locales/en.js +++ b/app/locales/en.js @@ -295,21 +295,11 @@ export const en = { }, 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 MMRV catch-up vaccination' - }, - primaryProgrammeInSentence: { - [SessionPresetName.Flu]: 'flu', - [SessionPresetName.Doubles]: 'MenACWY and Td/IPV', - [SessionPresetName.HPV]: 'HPV', - [SessionPresetName.MMR]: 'MMR and MMRV' - }, + title: 'Book an appointment for your child’s vaccination', + intro: + '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.', + programmes: + 'Clinics have recently been set up to offer {{programmeNames}} vaccinations, but your child may be able to catch up on any outstanding vaccinations during their appointment.', confirm: { title: 'Book an appointment', buttonText: 'Start now' diff --git a/app/middleware/navigation.js b/app/middleware/navigation.js index 18c91e4b8..1440e05b0 100644 --- a/app/middleware/navigation.js +++ b/app/middleware/navigation.js @@ -1,6 +1,6 @@ import { SessionPresetName } from '../enums.js' import { Session } from '../models.js' -import { getClinicBookingUrl } from '../utils/clinic-booking.js' +import { getClinicInviteUrl } from '../utils/clinic-booking.js' import { formatDate, today } from '../utils/date.js' import { getSessionConsentUrl } from '../utils/session.js' @@ -28,11 +28,11 @@ export const navigation = (request, response, next) => { 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) + clinicInviteUrl: { + Flu: getClinicInviteUrl(SessionPresetName.Flu), + HPV: getClinicInviteUrl(SessionPresetName.HPV), + Doubles: getClinicInviteUrl(SessionPresetName.Doubles), + 'MMR(V)': getClinicInviteUrl(SessionPresetName.MMR) } } diff --git a/app/models/clinic-appointment.js b/app/models/clinic-appointment.js index dfd8ec2f3..c677bc40f 100644 --- a/app/models/clinic-appointment.js +++ b/app/models/clinic-appointment.js @@ -32,7 +32,6 @@ import { * @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 */ @@ -67,10 +66,6 @@ export class ClinicAppointment { (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 || {} } @@ -150,23 +145,8 @@ export class ClinicAppointment { * @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 - ) + const programme_ids = this.patient?.clinicReadyProgramme_ids ?? [] + return ClinicAppointment.#getProgrammesFromIDs(programme_ids, this.context) } /** diff --git a/app/models/clinic-booking.js b/app/models/clinic-booking.js index e7fca0207..0f8b3709f 100644 --- a/app/models/clinic-booking.js +++ b/app/models/clinic-booking.js @@ -1,9 +1,7 @@ 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 { ClinicAppointment, Parent } from '../models.js' import { formatMonospace, stringToArray, @@ -17,7 +15,6 @@ import { * @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 - the appointments created in this booking (one per child) */ @@ -27,7 +24,6 @@ export class ClinicBooking { 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({}) @@ -51,29 +47,7 @@ export class ClinicBooking { * @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) - ) + return `${this.uuid}` } /** @@ -84,12 +58,7 @@ export class ClinicBooking { */ addAppointment(options) { this.appointments = this.appointments || [] - this.appointments.push( - new ClinicAppointment( - options ?? { primary_programme_ids: this.primaryProgrammeIDs }, - this.context - ) - ) + this.appointments.push(new ClinicAppointment(options, this.context)) return this.appointments.at(-1) } @@ -138,8 +107,6 @@ export class ClinicBooking { */ 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) } } diff --git a/app/models/patient-programme.js b/app/models/patient-programme.js index 6dacdbf3f..a98346a9c 100644 --- a/app/models/patient-programme.js +++ b/app/models/patient-programme.js @@ -8,10 +8,8 @@ import { PatientRefusedStatus, PatientStatus, ProgrammeType, - RegistrationOutcome, SessionStatus, - SessionType, - VaccinationOutcome + SessionType } from '../enums.js' import { AuditEvent, diff --git a/app/models/patient.js b/app/models/patient.js index 1eab136e8..847560dce 100644 --- a/app/models/patient.js +++ b/app/models/patient.js @@ -20,7 +20,6 @@ import { Parent, PatientProgramme, PatientSession, - Programme, Reply, Vaccination } from '../models.js' diff --git a/app/routes/book-into-a-clinic.js b/app/routes/book-into-a-clinic.js index ace58add0..2fad026c9 100644 --- a/app/routes/book-into-a-clinic.js +++ b/app/routes/book-into-a-clinic.js @@ -4,46 +4,34 @@ import { bookIntoClinicController as bookIntoClinic } from '../controllers/book- const router = express.Router({ strict: true, mergeParams: true }) -router.param('session_preset_slug', bookIntoClinic.read) +router.use(bookIntoClinic.setupServiceHeader) -router.get( - ['/:session_preset_slug', '/:session_preset_slug/'], - bookIntoClinic.redirect -) +router.get('/', bookIntoClinic.readProgrammes) -router.get('/:session_preset_slug/new', bookIntoClinic.new) +router.get('/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', + '/:booking_uuid/new/:appointment_uuid/:view', bookIntoClinic.readForm ) +router.all('/: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', + '/:booking_uuid/new/:appointment_uuid/:view', bookIntoClinic.showForm ) +router.get('/: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('/: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', + '/:booking_uuid/new/:appointment_uuid/:view', bookIntoClinic.updateForm ) +router.post('/:booking_uuid/new/:view', bookIntoClinic.updateForm) -router.get('/:session_preset_slug{/:view}', bookIntoClinic.show) +router.get('{/:view}', bookIntoClinic.show) export const bookIntoClinicRoutes = router diff --git a/app/utils/clinic-appointment.js b/app/utils/clinic-appointment.js index e21e0929a..5225494a1 100644 --- a/app/utils/clinic-appointment.js +++ b/app/utils/clinic-appointment.js @@ -5,14 +5,12 @@ import { camelToKebabCase } from './string.js' /** * Get wizard journey paths and forking details for all appointments in the given clinic booking * - * @param {string} session_preset_slug - URL part that represents the primary programme * @param {string} booking_uuid - the ID of the booking we're creating * @param {object} sessionData - the request.session.data object * @param {Array} appointments - the appointments whose journeys we're mapping * @returns {object} An object containing all relevants page and forks */ export const getAllAppointmentPaths = ( - session_preset_slug, booking_uuid, sessionData, appointments @@ -25,62 +23,46 @@ export const getAllAppointmentPaths = ( const appointment_uuid = appointment.uuid return { // Child details - [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/child`]: - {}, - [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/dob`]: - {}, + [`/${booking_uuid}/new/${appointment_uuid}/child`]: {}, + [`/${booking_uuid}/new/${appointment_uuid}/dob`]: {}, ...(appointments[0].uuid !== appointment_uuid && getPreviousAddressItems(appointments).length > 2 ? { - [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/address-selection`]: - { - [`/${session_preset_slug}/${booking_uuid}/new/${appointment_uuid}/parental-relationship`]: - () => sessionData.transaction.addressChoice !== 'new' - } + [`/${booking_uuid}/new/${appointment_uuid}/address-selection`]: { + [`/${booking_uuid}/new/${appointment_uuid}/parental-relationship`]: + () => sessionData.transaction.addressChoice !== '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' - } - }, + [`/${booking_uuid}/new/${appointment_uuid}/address`]: {}, + [`/${booking_uuid}/new/${appointment_uuid}/parental-relationship`]: { + [`/${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`]: - {}, + [`/${booking_uuid}/new/${appointment_uuid}/vaccination-choice`]: {}, + [`/${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`]: - {} + [`/${booking_uuid}/new/${appointment_uuid}/preferred-location`]: { + [`/${booking_uuid}/new/${appointment_uuid}/clinic-location`]: { + data: 'transaction.preferredLocation', + value: 'NE12 7ET' + } + }, + [`/${booking_uuid}/new/${appointment_uuid}/preferred-location-matches`]: { + [`/${booking_uuid}/new/${appointment_uuid}/preferred-location`]: { + data: 'transaction.preferredLocation', + value: 'retry' + } + }, + [`/${booking_uuid}/new/${appointment_uuid}/clinic-location`]: {}, + [`/${booking_uuid}/new/${appointment_uuid}/clinic-date`]: {}, + [`/${booking_uuid}/new/${appointment_uuid}/appointment-time-range`]: {}, + [`/${booking_uuid}/new/${appointment_uuid}/appointment-time`]: {} } }) diff --git a/app/utils/clinic-booking.js b/app/utils/clinic-booking.js index 9ed1b97be..779f769ae 100644 --- a/app/utils/clinic-booking.js +++ b/app/utils/clinic-booking.js @@ -1,3 +1,4 @@ +import programmesData from '../datasets/programmes.js' import { SessionPresets } from '../enums.js' /** @@ -6,9 +7,18 @@ import { SessionPresets } from '../enums.js' * @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) => { +export const getClinicInviteUrl = (sessionPresetName) => { const sessionPreset = SessionPresets.find( (preset) => preset.name === sessionPresetName ) - return `/book-into-a-clinic/${sessionPreset.slug}` + const programme_ids = sessionPreset.programmeTypes.map( + (type) => programmesData[type].id + ) + + const searchParams = new URLSearchParams() + for (const programme_id of programme_ids) { + searchParams.append('programme_id', programme_id) + } + + return `/book-into-a-clinic/?${searchParams.toString()}` } diff --git a/app/views/book-into-a-clinic/form/vaccination-choice.njk b/app/views/book-into-a-clinic/form/vaccination-choice.njk index 4c8337a9d..565a8faf3 100644 --- a/app/views/book-into-a-clinic/form/vaccination-choice.njk +++ b/app/views/book-into-a-clinic/form/vaccination-choice.njk @@ -9,7 +9,7 @@ }) }}

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

diff --git a/app/views/book-into-a-clinic/start.njk b/app/views/book-into-a-clinic/start.njk index c70c8c33a..1eacfb2fc 100644 --- a/app/views/book-into-a-clinic/start.njk +++ b/app/views/book-into-a-clinic/start.njk @@ -7,17 +7,12 @@
{{ appHeading({ size: "xl", - title: __("clinicBooking.start.title." + sessionPreset.name) + title: __("clinicBooking.start.title") }) }} -

- 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. -

+ {{ __("clinicBooking.start.intro") | nhsukMarkdown }} -

- 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.programmes", { programmeNames: clinicInvite.programmeNames }) | nhsukMarkdown }}

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

diff --git a/app/views/index.njk b/app/views/index.njk index 8acab4cc2..14945fdb3 100644 --- a/app/views/index.njk +++ b/app/views/index.njk @@ -83,7 +83,7 @@ {{ 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" + href: navigation.clinicInviteUrl[preset.name] }) }} {% endif %} {% endfor %} From dd20af0a9045956551c3165c91f06bb1d393df80 Mon Sep 17 00:00:00 2001 From: Mal Ross Date: Mon, 11 May 2026 11:34:10 +0100 Subject: [PATCH 13/14] Updates to package-lock.json These came in after rebasing on main. --- package-lock.json | 50 ----------------------------------------------- 1 file changed, 50 deletions(-) diff --git a/package-lock.json b/package-lock.json index 18befc42a..5077c90cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1619,9 +1619,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1642,9 +1639,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1665,9 +1659,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1688,9 +1679,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1711,9 +1699,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1734,9 +1719,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2171,9 +2153,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2188,9 +2167,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2205,9 +2181,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2222,9 +2195,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2239,9 +2209,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2256,9 +2223,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2273,9 +2237,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2290,9 +2251,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -13322,7 +13280,6 @@ "cpu": [ "arm" ], - "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -13339,7 +13296,6 @@ "cpu": [ "arm64" ], - "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -13356,7 +13312,6 @@ "cpu": [ "arm" ], - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -13373,7 +13328,6 @@ "cpu": [ "arm64" ], - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -13390,7 +13344,6 @@ "cpu": [ "riscv64" ], - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -13407,7 +13360,6 @@ "cpu": [ "x64" ], - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -13424,7 +13376,6 @@ "cpu": [ "riscv64" ], - "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -13441,7 +13392,6 @@ "cpu": [ "x64" ], - "libc": "glibc", "license": "MIT", "optional": true, "os": [ From 44021ba933edb2b113e65aedfc7a4d52bce6fbd5 Mon Sep 17 00:00:00 2001 From: Mal Ross Date: Mon, 11 May 2026 12:23:09 +0100 Subject: [PATCH 14/14] Fix missing programme details from invite link in clinic booking --- app/views/book-into-a-clinic/start.njk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/book-into-a-clinic/start.njk b/app/views/book-into-a-clinic/start.njk index 1eacfb2fc..33a3fa7af 100644 --- a/app/views/book-into-a-clinic/start.njk +++ b/app/views/book-into-a-clinic/start.njk @@ -12,7 +12,7 @@ {{ __("clinicBooking.start.intro") | nhsukMarkdown }} - {{ __("clinicBooking.start.programmes", { programmeNames: clinicInvite.programmeNames }) | nhsukMarkdown }} + {{ __("clinicBooking.start.programmes", { programmeNames: data.clinicInvite.programmeNames }) | nhsukMarkdown }}

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