diff --git a/app/assets/javascript/retry-worklist-connection.js b/app/assets/javascript/retry-worklist-connection.js new file mode 100644 index 00000000..925f2f7d --- /dev/null +++ b/app/assets/javascript/retry-worklist-connection.js @@ -0,0 +1,73 @@ +// app/assets/javascript/retry-worklist-connection.js +// +// Handles retry connection simulation entirely client-side. +// First click: shows "Attempting to reconnect" then shows a failure message. +// Second click: shows "Attempting to reconnect" then submits the form (server +// marks worklist as connected and redirects via referrerChain). + +;(function () { + const RECONNECT_DELAY_MS = 1500 + const button = document.querySelector('[data-retry-connection-button]') + if (!button) return + + const form = button.form + if (!form) return + + const failureMessage = document.querySelector('[data-retry-failure-message]') + const retryTimeSpan = document.querySelector('[data-retry-time]') + const originalText = button.textContent + let attempts = 0 + + // Reset button state on page load and bfcache restore + const resetButton = () => { + button.disabled = false + button.textContent = originalText + } + resetButton() + window.addEventListener('pageshow', resetButton) + + let isSubmitting = false + + form.addEventListener('submit', (event) => { + // Only intercept the Retry button (not the secondary Switch to manual button) + if (event.submitter !== button) return + + // After a successful retry we re-submit programmatically — let it through + if (isSubmitting) return + + event.preventDefault() + + button.disabled = true + button.textContent = 'Attempting to reconnect' + + setTimeout(() => { + attempts++ + + if (attempts >= 2) { + // Second attempt: simulate success — submit form to server + isSubmitting = true + button.disabled = false + if (typeof form.requestSubmit === 'function') { + form.requestSubmit(button) + } else { + form.submit() + } + return + } + + // First attempt: simulate failure — show message and re-enable button + if (failureMessage && retryTimeSpan) { + const now = new Date() + const timeString = now.toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + retryTimeSpan.textContent = timeString + failureMessage.style.display = '' + } + + resetButton() + }, RECONNECT_DELAY_MS) + }) +})() diff --git a/app/assets/sass/_utils.scss b/app/assets/sass/_utils.scss index 00cef5bc..caf09aba 100644 --- a/app/assets/sass/_utils.scss +++ b/app/assets/sass/_utils.scss @@ -17,3 +17,8 @@ .app-display-none { display: none; } + +.app-code-font { + font-family: $nhsuk-code-font; + word-spacing: -0.5ch; +} diff --git a/app/assets/sass/components/_status-bar.scss b/app/assets/sass/components/_status-bar.scss index 480c19c8..292e2345 100644 --- a/app/assets/sass/components/_status-bar.scss +++ b/app/assets/sass/components/_status-bar.scss @@ -28,6 +28,10 @@ align-items: center; } +// .app-status-bar__row--compact { +// gap: 10px; +// } + .app-status-bar__row + .app-status-bar__row { margin-top: 8px; padding-top: 8px; @@ -45,6 +49,10 @@ opacity: 0.9; } +.app-status-bar__worklist-status { + margin-left: 4px; +} + .app-status-bar__viewer-link { margin-left: auto; } diff --git a/app/lib/generators/clinic-generator.js b/app/lib/generators/clinic-generator.js index d80efc5f..b9f1b3d8 100644 --- a/app/lib/generators/clinic-generator.js +++ b/app/lib/generators/clinic-generator.js @@ -183,6 +183,7 @@ const generateClinic = ( clinicCode: generateClinicCode(), date: clinicDate.format('YYYY-MM-DD'), breastScreeningUnitId: breastScreeningUnit.id, + bsuAbbreviation: breastScreeningUnit.abbreviation, locationType: location.type, clinicType, riskLevels, diff --git a/app/lib/generators/event-generator.js b/app/lib/generators/event-generator.js index 1b6fa167..3867dc49 100644 --- a/app/lib/generators/event-generator.js +++ b/app/lib/generators/event-generator.js @@ -143,10 +143,19 @@ const generateEvent = ({ eventStatus = 'event_in_progress' } + // Generate accession number for this appointment - format: ABCYYYYMMDD##### + // ABC = BSU abbreviation, YYYYMMDD = clinic date, ##### = random 5-digit sequence + const accessionNumber = [ + clinic.bsuAbbreviation || 'BSU', + dayjs(clinic.date).format('YYYYMMDD'), + faker.number.int({ min: 10000, max: 99999 }) + ].join('') + const eventBase = { id: id || generateId(), participantId: participant.id, clinicId: clinic.id, + accessionNumber, slotId: slot.id, type: clinic.clinicType, timing: { @@ -271,6 +280,7 @@ const generateEvent = ({ // Add mammogram images for completed events event.mammogramData = generateMammogramImages({ startTime: actualStartTime, + accessionNumber: eventBase.accessionNumber, isSeedData: true, config: participant.config, scenarioWeights: seedDataProfile?.mammogram?.scenarioWeights, @@ -347,6 +357,7 @@ const generateEvent = ({ if (event.workflowStatus?.['take-images'] === 'completed') { event.mammogramData = generateMammogramImages({ startTime: dayjs(event.sessionDetails.startedAt), + accessionNumber: event.accessionNumber, isSeedData: true, config: participant.config, scenarioWeights: seedDataProfile?.mammogram?.scenarioWeights, diff --git a/app/lib/generators/mammogram-generator.js b/app/lib/generators/mammogram-generator.js index b0318d16..a9f19626 100644 --- a/app/lib/generators/mammogram-generator.js +++ b/app/lib/generators/mammogram-generator.js @@ -145,6 +145,7 @@ const generateViewImages = ({ * * @param {object} [options] - Generation options * @param {Date|string} [options.startTime] - Starting timestamp (defaults to now) + * @param {string} [options.accessionNumber] - Accession number for this study (from the event) * @param {boolean} [options.isSeedData] - Whether generating seed data * @param {object} [options.config] - Optional configuration for specific scenarios * @param {string} [options.config.scenario] - Force a specific scenario ('standard', 'extraImages', 'technicalRepeat', 'incomplete', 'incompleteImperfect') @@ -155,13 +156,15 @@ const generateViewImages = ({ */ const generateMammogramImages = ({ startTime = new Date(), + accessionNumber = null, isSeedData = false, config = {}, scenarioWeights = null, imperfectChanceForTechnicalOrIncomplete = 0.15, notesForReaderChanceWithoutImperfect = 0.05 } = {}) => { - const accessionBase = faker.number + // Use the provided accession number as base, or fall back to a random number + const accessionBase = accessionNumber || faker.number .int({ min: 100000000, max: 999999999 }) .toString() let currentIndex = 1 @@ -359,7 +362,6 @@ const generateMammogramImages = ({ } return { - accessionBase, views, ...incompleteMammographyData, ...imperfectData, diff --git a/app/lib/utils/strings.js b/app/lib/utils/strings.js index 01e2d329..5e58181c 100644 --- a/app/lib/utils/strings.js +++ b/app/lib/utils/strings.js @@ -356,6 +356,29 @@ const formatNhsNumber = (input) => { // formatNhsNumber(4857773456) // returns '485 777 3456' // formatNhsNumber('485 777 3456') // returns '485 777 3456' +/** + * Format an accession number for display with spaces (ABC YYYYMMDD ##### format) + * + * @param {string} input - Raw accession number, e.g. 'KOX2026052712345' + * @returns {string} Formatted accession number, e.g. 'KOX 20260527 12345' + * @example + * formatAccessionNumber('KOX2026052712345') // 'KOX 20260527 12345' + */ +const formatAccessionNumber = (input) => { + if (!input) return '' + const str = input.toString().replace(/\s/g, '') + + // Expect 3 letters + 8 digits (date) + remaining digits + if (!/^[A-Z]{3}\d{13,}$/.test(str)) { + return input + } + + const bsu = str.slice(0, 3) + const date = str.slice(3, 11) + const sequence = str.slice(11) + return `${bsu} ${date} ${sequence}` +} + /** * Make a word plural based on a count * @@ -394,6 +417,7 @@ const formatMammogramViewCode = (code) => { module.exports = { addIndefiniteArticle, + formatAccessionNumber, formatCurrency, formatCurrencyForCsv, formatNhsNumber, diff --git a/app/routes/events.js b/app/routes/events.js index 1903f4e5..1f5e5bd2 100644 --- a/app/routes/events.js +++ b/app/routes/events.js @@ -2228,18 +2228,99 @@ module.exports = (router) => { } ) + // Worklist connection retry routes + // + // The retry page is rendered as a simple GET (auto-routed or via this route). + // Retry counting is handled entirely client-side — the JS fakes a failed + // first attempt and a successful second attempt, then submits the form. + // + // POST retry-worklist-connection: marks worklist as connected and redirects + // back using the standard referrerChain system. + // + // POST switch-to-manual-image-mode: stores manual mode on the EVENT (not + // globally) and redirects back via referrerChain. + + // Handle successful "Retry connection" — client-side JS only submits after + // simulating a successful reconnect. + router.post( + '/clinics/:clinicId/events/:eventId/retry-worklist-connection', + (req, res) => { + const { clinicId, eventId } = req.params + const data = req.session.data + + // Mark this event as reconnected (per-event, doesn't change the global setting) + data.event.isOnWorklist = true + + const participantName = getFullName(data.participant) + req.flash('success', { + html: `

${participantName} is now on the worklist

+

Image information will be sent automatically from the mammogram machine

` + }) + + const returnUrl = getReturnUrl( + `/clinics/${clinicId}/events/${eventId}/take-images`, + req.query.referrerChain + ) + return res.redirect(returnUrl) + } + ) + + // Handle "Switch to manual image mode" — stores override on the event only. + router.post( + '/clinics/:clinicId/events/:eventId/switch-to-manual-image-mode', + (req, res) => { + const { clinicId, eventId } = req.params + const data = req.session.data + + // Store manual mode on the event itself, not globally + data.event.isManualImageCollection = true + + req.flash('success', { + html: '

Manual image mode enabled

' + }) + + const returnUrl = getReturnUrl( + `/clinics/${clinicId}/events/${eventId}/take-images`, + req.query.referrerChain + ) + res.redirect(returnUrl) + } + ) + // Manual imaging routes - // Handle take-images route - redirect to appropriate page based on state - router.get('/clinics/:clinicId/events/:eventId/take-images', (req, res) => { + // Handle take-images route - redirect to appropriate page based on state. + // Use `all` so the gate applies to the POST from review-medical-information + // as well as direct GET navigation. + router.all('/clinics/:clinicId/events/:eventId/take-images', (req, res) => { const { clinicId, eventId } = req.params const data = req.session.data + const isAddedToWorklist = + data.settings?.screening?.addedToWorklist !== 'false' || + data.event?.isOnWorklist === true + + // Manual mode is true if the global setting says so, OR this specific + // event was switched to manual (e.g. via the retry-connection page). const isManualImageCollection = - data.settings?.screening?.manualImageCollection === 'true' + data.settings?.screening?.manualImageCollection === 'true' || + data.event?.isManualImageCollection === true + const imagesStageCompleted = data.event?.workflowStatus?.['take-images'] === 'completed' + // Gate: if the appointment was not added to the worklist and the user + // hasn't yet switched to manual image mode, divert to the retry page + // before letting them into the image-taking step. + if (!isAddedToWorklist && !isManualImageCollection) { + return res.redirect( + urlWithReferrer( + `/clinics/${clinicId}/events/${eventId}/retry-worklist-connection`, + `/clinics/${clinicId}/events/${eventId}/take-images` + ) + ) + } + // If manual flow and images already completed, redirect to details page for editing if ( isManualImageCollection && @@ -2270,6 +2351,12 @@ module.exports = (router) => { router.get('/clinics/:clinicId/events/:eventId/images-manual', (req, res) => { const { clinicId, eventId } = req.params const data = req.session.data + const validTroubleshootingIssues = [ + 'worklist-participant', + 'wrong-image-count', + 'incorrect-image-labels' + ] + const troubleshootingIssue = req.query.issue // If mammogramData exists and is manual entry, prepopulate temp for editing if (data.event?.mammogramData?.isManualEntry) { @@ -2283,14 +2370,16 @@ module.exports = (router) => { // Clear any existing temp data for fresh start delete data.event.mammogramDataTemp - // Check if this is a failover from automatic mode - const isManualImageCollection = + // Check if this is a failover from automatic mode (event was switched + // to manual via the retry-connection page, or user navigated here from + // the troubleshooting link on the automatic images page). + const isGlobalManualSetting = data.settings?.screening?.manualImageCollection === 'true' const hadAutomaticData = !!data.event?.mammogramData && !data.event?.mammogramData?.isManualEntry // Set failover flag if switching from automatic to manual - if (!isManualImageCollection || hadAutomaticData) { + if (!isGlobalManualSetting || hadAutomaticData) { if (!data.event.mammogramDataTemp) { data.event.mammogramDataTemp = {} } @@ -2298,6 +2387,17 @@ module.exports = (router) => { } } + // Persist troubleshooting issue context when navigating from troubleshooting links + if (validTroubleshootingIssues.includes(troubleshootingIssue)) { + if (!data.event.mammogramDataTemp) { + data.event.mammogramDataTemp = {} + } + data.event.mammogramDataTemp.troubleshootingIssue = troubleshootingIssue + } else if (data.event?.mammogramDataTemp?.troubleshootingIssue) { + // Clear stale issue context for non-troubleshooting entry points + delete data.event.mammogramDataTemp.troubleshootingIssue + } + // Let the dynamic routing handle the actual rendering res.render('events/images-manual') }) diff --git a/app/views/_components/status-bar/template.njk b/app/views/_components/status-bar/template.njk index b31f0a1a..b771bb61 100644 --- a/app/views/_components/status-bar/template.njk +++ b/app/views/_components/status-bar/template.njk @@ -6,7 +6,7 @@
{% for row in params.rows %} -
+
{% for item in row.items %}
{% if item.key and item.value %} diff --git a/app/views/_includes/appointment-status-bar.njk b/app/views/_includes/appointment-status-bar.njk index b454a757..e246564b 100644 --- a/app/views/_includes/appointment-status-bar.njk +++ b/app/views/_includes/appointment-status-bar.njk @@ -4,7 +4,7 @@ {% set appointmentRowItems = [] %} {# Date and time #} -{% set appointmentHtml %} +{% set appointmentDateTimeHtml %} {{ clinic.date | formatDate }} at {{ event.timing.startTime | formatTimeString }} {% if event | isSpecialAppointment %} {{ tag({ @@ -13,21 +13,36 @@ classes: "nhsuk-u-margin-left-1" })}} {% endif %} - {% if data.settings.screening.addedToWorklist == 'false' %} +{% endset %} +{% set appointmentRowItems = appointmentRowItems | push({ + key: 'Appt:', + value: appointmentDateTimeHtml +}) %} + +{# Worklist status #} +{% set worklistStatusHtml %} + {% if data.settings.screening.addedToWorklist == 'false' and not data.event.isOnWorklist %} {{ appIcon("cross", { classes: "app-header-status__icon" }) }} - Not added to worklist (Retry) + {% if data.event.isManualImageCollection %} + Worklist issue, manual mode enabled + {% else %} + Not on worklist (Retry) + {% endif %} {% else %} {{ appIcon("tick", { classes: "app-header-status__icon" }) }} - Added to worklist + On worklist {% endif %} {% endset %} + +{# Worklist accession number with inline worklist status #} +{% set accessionNumberFormatted = event.accessionNumber | formatAccessionNumber %} {% set appointmentRowItems = appointmentRowItems | push({ - key: "Appointment:", - value: appointmentHtml + key: 'Accn:', + value: '' + accessionNumberFormatted + '' + worklistStatusHtml + '' }) %} {# Appointment type #} @@ -86,7 +101,7 @@ {# NHS Number #} {% set participantRowItems = participantRowItems | push({ key: "NHS:", - value: participant.medicalInformation.nhsNumber | formatNhsNumber + value: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber) + '' }) %} {{ appStatusBar({ diff --git a/app/views/_includes/images/image-troubleshooting.njk b/app/views/_includes/images/image-troubleshooting.njk index 562b8cbd..2db4334e 100644 --- a/app/views/_includes/images/image-troubleshooting.njk +++ b/app/views/_includes/images/image-troubleshooting.njk @@ -19,7 +19,7 @@

If this needs to be changed, update the mammogram machine and wait a few seconds for the image details to refresh.

-

If the issue is not resolved, enable manual image mode.

+

If the issue is not resolved, enable manual image mode.

Images details will be reconciled after the appointment.

@@ -29,7 +29,7 @@ summaryText: "The wrong number of images are displayed" }) %} -

Enable manual image mode.

+

Enable manual image mode.

Images details will be reconciled after the appointment.

@@ -41,7 +41,7 @@

Update image information on the mammogram machine and wait a few seconds for the image details to refresh.

-

If the issue is not resolved, enable manual image mode.

+

If the issue is not resolved, enable manual image mode.

Images details will be reconciled after the appointment.

diff --git a/app/views/_includes/summary-lists/medical-information/mammogram-image-data.njk b/app/views/_includes/summary-lists/medical-information/mammogram-image-data.njk index 6b8e3cf7..31ee0a73 100644 --- a/app/views/_includes/summary-lists/medical-information/mammogram-image-data.njk +++ b/app/views/_includes/summary-lists/medical-information/mammogram-image-data.njk @@ -117,6 +117,26 @@ {% set defaultHref = "./images-automatic" %} +{# Image recording method - only show if manual entry #} +{% if isManualEntry %} + {% set imageRecordingValueHtml %} +

Manual

+ {# Commented out until we decide how to collect a reason #} + {# {% if event.mammogramData.isManualFailover %} +

Add a reason for using manual data collection

+ {% endif %} #} + {% endset %} + + {% set summaryRows = summaryRows | push({ + key: { + text: "Image recording method" + }, + value: { + html: imageRecordingValueHtml + } + }) %} +{% endif %} + {# Machine room #} {% if event.mammogramData.machineRoom %} {# Check if this is a mobile clinic #} @@ -146,25 +166,6 @@ }) %} {% endif %} -{# Image recording method - only show if manual entry #} -{% if isManualEntry %} - {% set imageRecordingValueHtml %} -

Manual

- {% if event.mammogramData.isManualFailover %} -

Add a reason for using manual data collection

- {% endif %} - {% endset %} - - {% set summaryRows = summaryRows | push({ - key: { - text: "Image recording method" - }, - value: { - html: imageRecordingValueHtml - } - }) %} -{% endif %} - {# Combined views taken (showing all view information in one row) #} {# Sort views by side and standard order before displaying #} {% set sortedViews = [] %} diff --git a/app/views/events/appointment.html b/app/views/events/appointment.html index 565df501..0832f24a 100644 --- a/app/views/events/appointment.html +++ b/app/views/events/appointment.html @@ -252,6 +252,17 @@ } | openInModal ] if event.status != "event_complete" else [] } + }, + { + key: { + text: "Accession number" + }, + value: { + html: '' + (event.accessionNumber | formatAccessionNumber) + '' + }, + actions: { + items: [] + } } ] } | handleSummaryListMissingInformation) }} diff --git a/app/views/events/images-manual.html b/app/views/events/images-manual.html index c7351ec6..f43289d7 100644 --- a/app/views/events/images-manual.html +++ b/app/views/events/images-manual.html @@ -4,7 +4,7 @@ {% set hideBackLink = true %} {% set activeWorkflowStep = 'take-images' %} -{% set pageHeading = "Record images taken" %} +{% set pageHeading = "Manually record image information" %} {% set gridColumn = "nhsuk-grid-column-two-thirds" %} @@ -79,6 +79,58 @@

{{ pageHeading }}

] }) }} #} + {# Show unscheduled-appointment setup details whenever the appointment is + being handled in manual image collection mode. The reason-for-switching + input is only shown when the user manually switched mid-appointment via + the troubleshooting link on the automatic images page (failover). #} + {% set isManualFailover = event.mammogramDataTemp.isManualFailover %} + {% set troubleshootingIssue = event.mammogramDataTemp.troubleshootingIssue %} + {% set hasTroubleshootingIssue = troubleshootingIssue in ['worklist-participant', 'wrong-image-count', 'incorrect-image-labels'] %} + {% set showWorklistMatchingContent = troubleshootingIssue == 'worklist-participant' %} + {% set showDefaultManualContent = data.settings.screening.manualImageCollection == 'true' or data.event.isManualImageCollection or isManualFailover %} + + {% if (hasTroubleshootingIssue and showWorklistMatchingContent) or (not hasTroubleshootingIssue and showDefaultManualContent) %} +

Manually add participant details

+

Set up an unscheduled appointment using the following information so mammograms can be assigned correctly:

+ {{ summaryList({ + rows: [ + { + key: { text: "Accession number" }, + value: { html: '' + (event.accessionNumber | formatAccessionNumber) + '' } + }, + { + key: { text: "NHS number" }, + value: { html: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber) + '' } + }, + { + key: { text: "Full name" }, + value: { text: participant | getFullName } + }, + { + key: { text: "Date of birth" }, + value: { text: participant.demographicInformation.dateOfBirth | formatDate } + } + ] + }) }} + + {# {% if isManualFailover %} + {% call details({ + summaryText: "Add reason for switching to manual" + }) %} + {{ textarea({ + label: { + text: "Reason for switching to manual", + classes: "nhsuk-label--s" + }, + id: "manualImageModeReason", + name: "event[mammogramDataTemp][manualImageModeReason]", + rows: 3, + value: mammogramSource.manualImageModeReason + }) }} + {% endcall %} + {% endif %} #} + {% endif %} + {{ radios({ idPrefix: "imagingComplete", name: "event[mammogramDataTemp][isStandardSet]", diff --git a/app/views/events/retry-worklist-connection.html b/app/views/events/retry-worklist-connection.html new file mode 100644 index 00000000..62a46edc --- /dev/null +++ b/app/views/events/retry-worklist-connection.html @@ -0,0 +1,44 @@ +{# app/views/events/retry-worklist-connection.html #} + +{% extends 'layout-appointment.html' %} + +{% set activeWorkflowStep = 'take-images' %} +{% set activeTab = 'images' %} +{% set isForm = true %} + +{% set pageHeading = (participant | getFullName) + " has not been added to the worklist" %} + +{% set gridColumn = "nhsuk-grid-column-two-thirds" %} + +{% block pageContent %} + +

{{ pageHeading }}

+ +

Due to a connection issue, image information for this appointment cannot be transferred automatically from the mammogram machine.

+ +
+ {{ button({ + text: "Retry connection", + attributes: { + formaction: "./retry-worklist-connection" | urlWithReferrer(referrerChain), + "data-retry-connection-button": "" + } + }) }} + + {{ button({ + text: "Switch to manual image mode", + classes: "nhsuk-button--secondary", + attributes: { + formaction: "./switch-to-manual-image-mode" | urlWithReferrer(referrerChain) + } + }) }} +
+ + {# Failure message rendered by client-side JS after first retry attempt #} + + + + +{% endblock %} diff --git a/app/views/events/take-images.html b/app/views/events/take-images.html index f132bb22..deff8a81 100644 --- a/app/views/events/take-images.html +++ b/app/views/events/take-images.html @@ -10,7 +10,7 @@ {# Used in automatic flow to show holding page #} {# {% set isAwaitingImages = event.workflowStatus['awaiting-images'] != 'completed' %} #} -{% set isManualImageCollection = data.settings.screening.manualImageCollection | falsify %} +{% set isManualImageCollection = (data.settings.screening.manualImageCollection | falsify) or data.event.isManualImageCollection %} {# {% set imagesStageCompleted = true if data.event.workflowStatus['take-images'] == 'completed' else false %} #} diff --git a/package-lock.json b/package-lock.json index 30b92a55..c187cdf0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1010,6 +1010,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1030,6 +1033,9 @@ "cpu": [ "arm" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1050,6 +1056,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1070,6 +1079,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1090,6 +1102,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1110,6 +1125,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1230,11 +1248,10 @@ } }, "node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "version": "25.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", + "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -1252,13 +1269,15 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", + "peer": true, "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" @@ -1340,7 +1359,8 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/async": { "version": "2.6.4", @@ -1672,7 +1692,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1792,7 +1811,6 @@ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "license": "MIT", - "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -1876,6 +1894,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 6" } @@ -1986,6 +2005,7 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2584,7 +2604,6 @@ "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2684,6 +2703,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -2782,6 +2802,7 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.6.0" } @@ -2852,6 +2873,7 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", @@ -2930,6 +2952,7 @@ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -2939,6 +2962,7 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -3233,6 +3257,7 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.10" } @@ -3340,7 +3365,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/is-wsl": { "version": "1.1.0", @@ -3504,6 +3530,7 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -3639,16 +3666,16 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } }, "node_modules/nhsuk-frontend": { - "version": "10.5.1", - "resolved": "https://registry.npmjs.org/nhsuk-frontend/-/nhsuk-frontend-10.5.1.tgz", - "integrity": "sha512-O6GqxHZ6UBePTc7uiItNttZoi8+J3dTC5vUN6bMs0cAoL8TgvNOiYwppFMrMB/nHVk+PtFtf+F8x2tz7OdNS1Q==", + "version": "10.5.2", + "resolved": "https://registry.npmjs.org/nhsuk-frontend/-/nhsuk-frontend-10.5.2.tgz", + "integrity": "sha512-lTOmzSDJkEn8uhuEuj3NKAW5MYuG/5tqMRsp15An2oLKlpGEoEAoREp+tHYJ7DLOPDRp9Z/zmp6/pLea75ae1g==", "license": "MIT", - "peer": true, "engines": { "node": "^20.9.0 || ^22.11.0 || >= 24.11.0" }, @@ -3873,6 +3900,7 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", + "peer": true, "dependencies": { "wrappy": "1" } @@ -3921,6 +3949,7 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" @@ -4000,7 +4029,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -4504,6 +4532,7 @@ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "license": "MIT", + "peer": true, "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" @@ -4748,6 +4777,7 @@ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", @@ -4848,7 +4878,6 @@ "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.100.0.tgz", "integrity": "sha512-Ut8wlQSk19tm7jMK6mz6cF1+e+E7tUnW2tM02zQDPnOTcVbV8qCQG8UWxZkkNlY50+hV3hqP24OOkUlMz8xBpw==", "license": "MIT", - "peer": true, "dependencies": { "@bufbuild/protobuf": "^2.5.0", "colorjs.io": "^0.5.0", @@ -5004,6 +5033,7 @@ "cpu": [ "arm" ], + "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -5020,6 +5050,7 @@ "cpu": [ "arm64" ], + "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -5036,6 +5067,7 @@ "cpu": [ "arm" ], + "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -5052,6 +5084,7 @@ "cpu": [ "arm64" ], + "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -5068,6 +5101,7 @@ "cpu": [ "riscv64" ], + "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -5084,6 +5118,7 @@ "cpu": [ "x64" ], + "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -5100,6 +5135,7 @@ "cpu": [ "riscv64" ], + "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -5116,6 +5152,7 @@ "cpu": [ "x64" ], + "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -5239,9 +5276,9 @@ } }, "node_modules/semver": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", - "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", + "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5255,6 +5292,7 @@ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", @@ -5395,6 +5433,7 @@ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", + "peer": true, "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", @@ -6053,7 +6092,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ws": { "version": "8.20.1",