diff --git a/app/controllers/appointment.js b/app/controllers/appointment.js new file mode 100644 index 000000000..f5d9d41d0 --- /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) + .filter(({ patient_uuid }) => !patient_uuid) + + // Sort + appointments = _.sortBy(appointments, 'startAt') + + // Session appointments + 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.appointmentsPath = session_id + ? `/sessions/${session_id}/appointments` + : '/appointments' + 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/controllers/book-into-a-clinic.js b/app/controllers/book-into-a-clinic.js index 76a27f17a..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 + + // 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(`${request.baseUrl}/${sessionPreset.slug}/start`) + 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) @@ -223,8 +227,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 +247,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,13 +271,18 @@ 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) }) }, 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/data.js b/app/data.js index 07fbe0608..1ac5b4fcc 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,16 +56,21 @@ const data = { } // Statistics -const consentCount = Consent.findAll(data).length || 0 +const unmatchedAppointmentCount = ClinicBooking.findAll(data) + ?.flatMap(({ appointments }) => appointments) + .filter(({ patient_uuid }) => !patient_uuid).length +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 = { - consents: consentCount, + appointments: unmatchedAppointmentCount, + consents: unmatchedConsentCount, moves: moveCount, notices: noticeCount, - review: consentCount + moveCount + noticeCount, + review: + unmatchedAppointmentCount + unmatchedConsentCount + moveCount + noticeCount, sessions: Session.findAll(data).length } 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/generators/clinic-appointment.js b/app/generators/clinic-appointment.js index 6db5fb8f3..2086110ac 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 - - // Find/create a child of an appropriate age for the chosen clinic and its programme - let matchedPatient + let patient_uuid, child 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 + // Matched appointment + patient_uuid = patient.uuid + + 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.fullName) { + // 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 68634af58..81fba5e58 100644 --- a/app/locales/en.js +++ b/app/locales/en.js @@ -234,6 +234,41 @@ export const en = { label: 'Parent' } }, + appointments: { + list: { + label: 'Clinic appointments', + title: '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: { + label: 'Response' + }, + location: { + label: 'Clinic location' + }, + date: { + label: 'Clinic date' + }, + time: { + label: 'Appointment time' + }, + vaccinations: { + label: 'Programmes' + }, + match: { + label: 'Match' + }, + archive: { + label: 'Archive' + } + }, clinicAppointment: { label: 'Appointment details', show: { @@ -260,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' @@ -500,7 +525,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/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/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 583bc9570..c677bc40f 100644 --- a/app/models/clinic-appointment.js +++ b/app/models/clinic-appointment.js @@ -1,8 +1,19 @@ import { fakerEN_GB as faker } from '@faker-js/faker' -import { Child, Patient, Programme, Session } from '../models.js' +import { + Child, + ClinicBooking, + Parent, + 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 @@ -10,6 +21,7 @@ import { stringToArray, stringToBoolean } from '../utils/string.js' * @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? @@ -20,7 +32,6 @@ import { stringToArray, stringToBoolean } from '../utils/string.js' * @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 */ @@ -35,6 +46,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({}) @@ -54,20 +66,22 @@ 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 || {} } /** - * Get URI of the booking journey + * Get the booking that this appointment's part of * - * @returns {string} Appointment URI + * @returns {ClinicBooking|undefined} - the booking that this is part of */ - get appointmentUri() { - return `${this.uuid}` + get booking() { + try { + if (this.booking_uuid) { + return ClinicBooking.findOne(this.booking_uuid, this.context) + } + } catch (error) { + console.error('ClinicAppointment.booking', error.message) + } } /** @@ -131,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) } /** @@ -231,12 +230,32 @@ 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}`, vaccinations: this.#getSelectedProgrammes(this.context) - .map((programme) => programme.name) - .join(', ') + .map((programme) => programme.nameTag) + .join(' ') + } + } + + /** + * Get formatted links + * + * @returns {object} Formatted links + */ + get link() { + const parent = new Parent({ + fullName: this.booking?.parent?.fullName, + relationship: this.parentalRelationship + }) + return { + summary: formatLinkWithSecondaryText( + this.uri, + parent.fullNameAndRelationship, + `for ${this.child.fullName}` + ) } } @@ -250,11 +269,11 @@ export class ClinicAppointment { } /** - * Get URI + * Get URI, without the context of the session * * @returns {string} URI */ get uri() { - return `/clinic-appointments/${this.uuid}` + return `/appointments/${this.uuid}` } } 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/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-programme.js b/app/models/patient-programme.js index fc81cbba5..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, @@ -161,22 +159,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 } diff --git a/app/models/patient.js b/app/models/patient.js index 8bf0a0d94..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' @@ -339,10 +338,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 } @@ -496,7 +495,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(' ') } } 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/routes.js b/app/routes.js index 28e9e4604..12245d876 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) // 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) @@ -68,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/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/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/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 new file mode 100644 index 000000000..05ca274d8 --- /dev/null +++ b/app/views/appointments/list.njk @@ -0,0 +1,100 @@ +{% 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 appointmentRows = [] %} + {% for appointment in results.page %} + {% set appointmentRows = appointmentRows | push([ + { + header: __("appointments.summary.label"), + html: appointment.link.summary | replace("/appointments", appointmentsPath) + }, + { + header: __("appointments.location.label"), + text: appointment.formatted.locationName + } if not session, + { + 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 | safe + } if session, + { + header: __("actions.label"), + html: appActionList({ + items: [{ + text: __("appointments.match.label"), + href: appointmentsPath + "/" + appointment.uuid + "/match?referrer=" + appointmentsPath + }, { + text: __("appointments.archive.label"), + href: appointmentsPath + "/" + appointment.uuid + "/archive?referrer=" + appointmentsPath + }] + }) + } + ]) %} + {% endfor %} + + {% if appointments.length %} + {{ table({ + id: "appointments", + responsive: true, + card: { + 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: [ + { 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: appointmentRows + }) }} + + {{ pagination(pages) }} + + {{ __mf("appointments.results", { + from: results.from, + to: results.to, + count: results.count + }) | nhsukMarkdown }} + {% else %} + {% 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/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..33a3fa7af 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: data.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 %} diff --git a/app/views/review/_navigation.njk b/app/views/review/_navigation.njk index 2ea9ed31d..6666a1df1 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(data.counts.appointments), + 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..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: session.uri + "/edit" + href: session.uri + "/appointments" }) }}

{% for programme in session.programmes %} diff --git a/lib/create-data.js b/lib/create-data.js index 9de60b896..96f6a8c8d 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, @@ -34,7 +33,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 +69,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 +160,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 +181,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 +192,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 +218,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 } } } @@ -535,48 +460,91 @@ 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, 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 = patient.clinicReadyProgramme_ids - 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)) { + // Exclude anyone not invited to clinic yet + if (!patient.clinicProgramme_ids?.length) { + continue + } - for (let patientSession of patientSessions) { - const patient = new Patient(patientSession.patient, context) + // 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( + 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) + }) + if (!matchingClinicSessions.length) { + continue + } + const session = faker.helpers.arrayElement(matchingClinicSessions) - // 2️⃣ INVITE TO BOOK CLINIC APPOINTMENT - patient.inviteToClinic(programmeClinicSession.programme_ids) - } - } + // 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 + }) } } 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": [