diff --git a/app/controllers/vaccination.js b/app/controllers/vaccination.js index 1716dc7cc..21a02bcb2 100644 --- a/app/controllers/vaccination.js +++ b/app/controllers/vaccination.js @@ -48,6 +48,20 @@ export const vaccinationController = { response.render('vaccination/show') }, + duplicates(request, response) { + const { vaccination } = response.locals + + if (!vaccination) { + return response.redirect('/') + } + + if (vaccination.canonicalVaccination_uuid || !vaccination.duplicates.length) { + return response.redirect(vaccination.uri) + } + + response.render('vaccination/duplicates') + }, + edit(request, response) { const { vaccination_uuid } = request.params const { data, referrer } = request.session diff --git a/app/datasets/vaccines.js b/app/datasets/vaccines.js index d9c1a1c71..99638964d 100644 --- a/app/datasets/vaccines.js +++ b/app/datasets/vaccines.js @@ -266,5 +266,38 @@ export default { PreScreenQuestion.IsHappy, PreScreenQuestion.IsNotContraindicated ] + }, + // MMRV (measles, mumps, rubella, varicella) — not used on the UK schedule. + // Included only to simulate miscoded records surfacing in Mavis. SNOMED is + // fabricated (99 prefix) since there is no real UK dm+d code to lean on. + '99926011000001103': { + snomed: '99926011000001103', + type: 'MMRV', + brand: 'ProQuad', + manufacturer: 'Merck Sharp & Dohme (UK) Ltd', + criteria: VaccineCriteria.Injection, + method: VaccineMethod.Injection, + dose: 0.5, + sideEffects: [ + VaccineSideEffect.Bruising, + VaccineSideEffect.TemperatureShiver, + VaccineSideEffect.SickFeeling, + VaccineSideEffect.PainArms + ], + healthQuestions: { + 'bleeding': {}, + 'bloodThinning': {}, + 'bloodTransfusion': {}, + 'previousReactionMmr': {}, + 'previousReactionNeomycinGelatine': {}, + 'immuneSystem': {}, + 'immunisations': {} + }, + preScreenQuestions: [ + PreScreenQuestion.IsWell, + PreScreenQuestion.IsPregnant, + PreScreenQuestion.IsHappy, + PreScreenQuestion.IsNotContraindicated + ] } } diff --git a/app/locales/en.js b/app/locales/en.js index 68634af58..f4c0c288e 100644 --- a/app/locales/en.js +++ b/app/locales/en.js @@ -1579,6 +1579,22 @@ export const en = { count: '{count, plural, =0 {No vaccination record} one {Vaccination record} other {# vaccination records}}' }, + encounters: { + count: + '{count, plural, =0 {No encounters recorded} one {Encounters} other {# encounters}}' + }, + vaccinationRecord: { + label: 'Vaccination record', + dosesComplete: '{complete} of {needed} doses complete.', + dose: { + label: 'Dose', + number: 'Dose {sequence}' + }, + ignored: { + label: 'Out of schedule' + }, + eligibleFrom: 'Eligible from {date}' + }, status: { label: 'Status' }, @@ -3148,6 +3164,7 @@ export const en = { show: { summary: 'Vaccination record' }, + reportProblem: 'Report a problem with this duplicate record', count: '{count, plural, =0 {No vaccination records} one {1 vaccination record} other {# vaccination records}}', administer: { @@ -3260,6 +3277,9 @@ export const en = { countryOther: { title: 'Which country was the vaccination given in?' }, + schedule: { + label: 'Schedule' + }, outcome: { label: 'Outcome', title: 'Vaccination outcome', @@ -3303,6 +3323,28 @@ export const en = { source: { label: 'Source' }, + duplicates: { + count: + '{count, plural, =0 {} one {+# duplicate} other {+# duplicates}}', + canonical: { + heading: 'Canonical record' + }, + reportProblem: 'Report a problem with this duplicate set', + list: { + heading: + '{count, plural, one {# duplicate record} other {# duplicate records}}' + } + }, + canonicalRole: { + label: 'Canonical record', + yes: 'Yes', + no: 'No', + view: 'View canonical record' + }, + duplicateRecords: { + label: 'Duplicate records', + count: '{count, plural, one {# record} other {# records}}' + }, ttcv: { label: 'Dose' }, diff --git a/app/models/patient-programme.js b/app/models/patient-programme.js index fc81cbba5..d7e6043ab 100644 --- a/app/models/patient-programme.js +++ b/app/models/patient-programme.js @@ -21,10 +21,12 @@ import { Vaccination } from '../models.js' import { + formatDate, getCurrentAcademicYear, getDateValueDifference, today } from '../utils/date.js' +import { getScheduleSummary } from '../utils/dose-schedule.js' import { ordinal } from '../utils/number.js' import { getReportOutcome } from '../utils/patient-session.js' import { getPatientStatus } from '../utils/status.js' @@ -137,7 +139,7 @@ export class PatientProgramme { */ get lastPatientSession() { if (this.patientSessions?.length > 0) { - return this.patientSessions.at(-1) + return this.patientSessions.at(0) } } @@ -287,6 +289,17 @@ export class PatientProgramme { ) } + /** + * Get canonical vaccination outcomes (excluding duplicates) + * + * @returns {Array|undefined} Vaccinations + */ + get canonicalVaccinationOutcomes() { + return this.vaccinationOutcomes?.filter( + (vaccination) => !vaccination.canonicalVaccination_uuid + ) + } + /** * Get last vaccination outcome * @@ -362,13 +375,38 @@ export class PatientProgramme { * @returns {number} Doses remaining */ get dosesRemaining() { - if (this.vaccinationsGiven?.length > 0) { - return this.dosesNeeded - this.vaccinationsGiven?.length + const validCount = this.scheduleSummary.dosesComplete + if (validCount > 0) { + return this.dosesNeeded - validCount } return this.dosesNeeded } + /** + * Get dose schedule summary + * + * @returns {object} Schedule summary + */ + get scheduleSummary() { + const canonicalGiven = (this.vaccinationsGiven || []).filter( + (vaccination) => !vaccination.canonicalVaccination_uuid + ) + const summary = getScheduleSummary({ + vaccinationsGiven: canonicalGiven, + dob: this.patient?.dob, + programme: this.programme + }) + return { + ...summary, + dosesComplete: summary.validDoses.length, + dosesNeeded: this.dosesNeeded, + nextEligibleFromFormatted: summary.nextEligibleFrom + ? formatDate(summary.nextEligibleFrom, { dateStyle: 'long' }) + : null + } + } + /** * Get dose due (ordinal) * diff --git a/app/models/patient-session.js b/app/models/patient-session.js index 8b3e1847a..9e2b44a66 100644 --- a/app/models/patient-session.js +++ b/app/models/patient-session.js @@ -470,7 +470,9 @@ export class PatientSession { try { if (this.patient?.vaccinations && this.programme_id) { return this.patient.vaccinations.filter( - ({ programme }) => programme?.id === this.programme_id + (vaccination) => + vaccination.programme?.id === this.programme_id && + vaccination.patientSession_uuid === this.uuid ) } } catch (error) { @@ -832,8 +834,18 @@ export class PatientSession { */ get reportDescription() { switch (this.report) { - case PatientStatus.Vaccinated: - return `${this.patient?.firstName} was vaccinated by ${this.lastVaccinationOutcome.createdBy.fullName} on ${this.lastVaccinationOutcome.formatted.createdAt}.` + case PatientStatus.Vaccinated: { + const vaccination = + this.lastVaccinationOutcome || + this.patientProgramme?.lastVaccinationGiven + if (!vaccination) { + return `${this.patient?.firstName} has been vaccinated.` + } + const by = vaccination.createdBy?.fullName + return by + ? `${this.patient?.firstName} was vaccinated by ${by} on ${vaccination.formatted.createdAt}.` + : `${this.patient?.firstName} was vaccinated on ${vaccination.formatted.createdAt}.` + } case PatientStatus.Due: return this.vaccineCriteria ? `${this.patient?.firstName} is ready to vaccinate (${this.vaccineCriteria.toLowerCase()}).` diff --git a/app/models/vaccination.js b/app/models/vaccination.js index 465b8b901..7c1cff96d 100644 --- a/app/models/vaccination.js +++ b/app/models/vaccination.js @@ -87,6 +87,7 @@ import { * @property {string} [batch_id] - Batch ID * @property {string} [variant] - Programme variant * @property {string} [vaccine_snomed] - Vaccine SNOMED code + * @property {string} [canonicalVaccination_uuid] - UUID of the canonical record this is a duplicate of */ export class Vaccination { constructor(options, context) { @@ -131,6 +132,7 @@ export class Vaccination { this.batch_id = this.given ? options?.batch_id || '' : undefined this.variant = options?.variant && stringToBoolean(options.variant) this.vaccine_snomed = options?.vaccine_snomed + this.canonicalVaccination_uuid = options?.canonicalVaccination_uuid if (this.outcome === VaccinationOutcome.AlreadyVaccinated) { this.locationName = options?.locationName @@ -405,6 +407,70 @@ export class Vaccination { } } + /** + * Whether this record is a duplicate of another vaccination record. + * + * @returns {boolean} True if this record points to a canonical record + */ + get isDuplicate() { + return Boolean(this.canonicalVaccination_uuid) + } + + /** + * Whether this record is the canonical record in a duplicate set. + * + * @returns {boolean} True if at least one other record points to this one + */ + get isCanonical() { + if (!this.context?.vaccinations) return false + return Object.values(this.context.vaccinations).some( + (vaccination) => vaccination.canonicalVaccination_uuid === this.uuid + ) + } + + /** + * Get the canonical record this duplicate points to, or `this` if not a + * duplicate. + * + * @returns {Vaccination} Canonical vaccination + */ + get canonical() { + if (this.isDuplicate) { + return ( + Vaccination.findOne(this.canonicalVaccination_uuid, this.context) || + this + ) + } + return this + } + + /** + * Get records that point to this one as their canonical, in chronological + * order. Empty for duplicate or standalone records. + * + * @returns {Array} Duplicate vaccinations + */ + get duplicates() { + if (!this.context?.vaccinations) return [] + return Object.values(this.context.vaccinations) + .filter( + (vaccination) => vaccination.canonicalVaccination_uuid === this.uuid + ) + .map((vaccination) => new Vaccination(vaccination, this.context)) + .sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)) + } + + /** + * Get this record together with any duplicates pointing to it. On a + * duplicate record, returns `[this]`. + * + * @returns {Array} Duplicate set + */ + get duplicateSet() { + if (this.isDuplicate) return [this] + return [this, ...this.duplicates] + } + /** * Get status of sync with NHS England API * @@ -613,6 +679,21 @@ export class Vaccination { ) } + /** + * Find all canonical vaccination records — those that are not themselves + * duplicates of another record. Includes standalone records (canonical-of- + * one). + * + * @param {object} context - Context + * @returns {Array} Vaccinations + * @static + */ + static findAllCanonical(context) { + return Vaccination.findAll(context).filter( + (vaccination) => !vaccination.canonicalVaccination_uuid + ) + } + /** * Find one * diff --git a/app/routes/vaccination.js b/app/routes/vaccination.js index b8bac6281..73830a2d5 100644 --- a/app/routes/vaccination.js +++ b/app/routes/vaccination.js @@ -22,6 +22,8 @@ router.all('/:vaccination_uuid/edit/:view', vaccination.readForm('edit')) router.get('/:vaccination_uuid/edit/:view', vaccination.showForm('edit')) router.post('/:vaccination_uuid/edit/:view', vaccination.updateForm) +router.get('/:vaccination_uuid/duplicates', vaccination.duplicates) + router.get('/:vaccination_uuid', vaccination.show) export const vaccinationRoutes = router diff --git a/app/utils/dose-schedule.js b/app/utils/dose-schedule.js new file mode 100644 index 000000000..a17816cf7 --- /dev/null +++ b/app/utils/dose-schedule.js @@ -0,0 +1,117 @@ +import { + addDays, + addMonths, + differenceInCalendarDays, + isBefore, + max as maxDate +} from 'date-fns' + +const MMR_MIN_AGE_MONTHS = [null, 12, 15] +const MMR_MIN_INTERVAL_DAYS = 28 +const MMR_MAX_SEQUENCE = 2 + +const REASON = { + BeforeAge12Months: 'Given before age 12 months', + BeforeAge15Months: 'Given before age 15 months', + LessThan28DaysAfterPrevious: 'Given less than 28 days after previous dose', + ExtraDose: 'Additional dose' +} + +/** + * Classify a patient's given vaccinations for a programme into valid (counted + * toward the schedule) and ignored (excluded by a rule), and compute the date + * from which the next empty slot is eligible. + * + * Pure function — no side effects. Phase 1 only implements MMR rules + * (dose 1 at ≥ 12 months; dose 2 at ≥ 15 months and ≥ 28 days after dose 1). + * Other programmes fall through: all given doses treated as valid. + * + * @param {object} params + * @param {Array} params.vaccinationsGiven - Vaccinations where given === true + * @param {Date} params.dob - Patient date of birth + * @param {object} params.programme - Programme (uses programme.id) + * @returns {{ + * validDoses: Array<{ vaccination: object, sequence: number }>, + * ignoredDoses: Array<{ vaccination: object, reason: string }>, + * recordedDoses: Array<{ vaccination: object, kind: 'valid'|'ignored', sequence?: number, reason?: string }>, + * nextEligibleFrom: Date|null + * }} + */ +export function getScheduleSummary({ vaccinationsGiven = [], dob, programme }) { + const givenDoses = [...vaccinationsGiven].sort( + (a, b) => new Date(a.createdAt) - new Date(b.createdAt) + ) + + if (programme?.id !== 'mmr') { + const validDoses = givenDoses.map((vaccination, i) => ({ + vaccination, + sequence: i + 1 + })) + return { + validDoses, + ignoredDoses: [], + recordedDoses: validDoses.map((d) => ({ ...d, kind: 'valid' })), + nextEligibleFrom: null + } + } + + const validDoses = [] + const ignoredDoses = [] + const recordedDoses = [] + let lastValidCreatedAt = null + + for (const vaccination of givenDoses) { + const nextSequence = validDoses.length + 1 + const atScheduleMax = nextSequence > MMR_MAX_SEQUENCE + const createdAt = new Date(vaccination.createdAt) + + if (lastValidCreatedAt) { + const gap = differenceInCalendarDays(createdAt, lastValidCreatedAt) + if (gap < MMR_MIN_INTERVAL_DAYS) { + const reason = REASON.LessThan28DaysAfterPrevious + ignoredDoses.push({ vaccination, reason }) + recordedDoses.push({ vaccination, kind: 'ignored', reason }) + continue + } + } + + if (!atScheduleMax) { + const minAgeDate = addMonths(dob, MMR_MIN_AGE_MONTHS[nextSequence]) + + if (isBefore(createdAt, minAgeDate)) { + const reason = + nextSequence === 1 + ? REASON.BeforeAge12Months + : REASON.BeforeAge15Months + ignoredDoses.push({ vaccination, reason }) + recordedDoses.push({ vaccination, kind: 'ignored', reason }) + continue + } + } + + if (atScheduleMax) { + const reason = REASON.ExtraDose + ignoredDoses.push({ vaccination, reason }) + recordedDoses.push({ vaccination, kind: 'ignored', reason }) + continue + } + + validDoses.push({ vaccination, sequence: nextSequence }) + recordedDoses.push({ vaccination, kind: 'valid', sequence: nextSequence }) + lastValidCreatedAt = createdAt + } + + let nextEligibleFrom = null + const nextSlot = validDoses.length + 1 + if (nextSlot <= MMR_MAX_SEQUENCE) { + const minAgeDate = addMonths(dob, MMR_MIN_AGE_MONTHS[nextSlot]) + nextEligibleFrom = lastValidCreatedAt + ? maxDate([ + minAgeDate, + addDays(lastValidCreatedAt, MMR_MIN_INTERVAL_DAYS) + ]) + : minAgeDate + } + + return { validDoses, ignoredDoses, recordedDoses, nextEligibleFrom } +} diff --git a/app/views/patient/_navigation.njk b/app/views/patient/_navigation.njk index 5e3ab3969..7aa45b8e2 100644 --- a/app/views/patient/_navigation.njk +++ b/app/views/patient/_navigation.njk @@ -4,9 +4,14 @@ {% macro patientNavigation(params) %} {{ appHeading({ - title: params.patient.fullName + title: params.patient.fullName, + classes: "nhsuk-u-margin-bottom-2" }) }} +

+ {{ __("child.dob.label") }}: {{ params.patient.formatted.dob }} +

+ {{ appActionList({ items: [{ tag: { diff --git a/app/views/patient/_vaccination-record.njk b/app/views/patient/_vaccination-record.njk new file mode 100644 index 000000000..118e9dbcf --- /dev/null +++ b/app/views/patient/_vaccination-record.njk @@ -0,0 +1,105 @@ +{% set summary = patientProgramme.scheduleSummary %} + +{% call card({ + heading: __("patientProgramme.vaccinationRecord.label"), + headingLevel: 3 +}) %} + {{ patientProgramme.formatted.statusWithNotes | nhsukMarkdown }} + +

+ {{ __mf("patientProgramme.vaccinationRecord.dosesComplete", { + complete: summary.dosesComplete, + needed: summary.dosesNeeded + }) }} +

+ + {% set rows = [] %} + + {% for recorded in summary.recordedDoses %} + {% set doseCellHtml %} + {% if recorded.kind == "ignored" %} + + {{- tag({ + text: __("patientProgramme.vaccinationRecord.ignored.label"), + classes: "nhsuk-tag--grey" + }) -}} +
+ {{ recorded.reason }} +
+ {% else %} + {{ __mf("patientProgramme.vaccinationRecord.dose.number", { + sequence: recorded.sequence + }) }} + {% if recorded.vaccination.vaccine.type %} +
+ {{ recorded.vaccination.vaccine.type }} + {% endif %} + {% endif %} + {% endset %} + {% set sourceHtml = recorded.vaccination.source %} + {% if recorded.vaccination.duplicates.length %} + {% set sourceHtml = sourceHtml + '
' + __mf("vaccination.duplicates.count", { count: recorded.vaccination.duplicates.length }) + '' %} + {% endif %} + {% set rows = rows | push([ + { + header: __("patientProgramme.vaccinationRecord.dose.label"), + html: doseCellHtml + }, + { + header: __("vaccination.createdAt.label"), + html: recorded.vaccination.link.createdAt + }, + { + header: __("vaccination.age.label"), + text: patient.dob | age(at=recorded.vaccination.createdAt) + }, + { + header: __("vaccination.source.label"), + html: sourceHtml + } + ]) %} + {% endfor %} + + {% set firstEmptySequence = summary.validDoses.length + 1 %} + {% for sequence in range(firstEmptySequence, summary.dosesNeeded + 1) %} + {% set isFirstEmpty = sequence == firstEmptySequence %} + {% set dateText = "—" %} + {% if isFirstEmpty and summary.nextEligibleFromFormatted %} + {% set dateText = __mf("patientProgramme.vaccinationRecord.eligibleFrom", { + date: summary.nextEligibleFromFormatted + }) %} + {% endif %} + {% set rows = rows | push([ + { + header: __("patientProgramme.vaccinationRecord.dose.label"), + text: __mf("patientProgramme.vaccinationRecord.dose.number", { + sequence: sequence + }) + }, + { + header: __("vaccination.createdAt.label"), + text: dateText + }, + { + header: __("vaccination.age.label"), + text: "—" + }, + { + header: __("vaccination.source.label"), + text: "—" + } + ]) %} + {% endfor %} + + {{ table({ + id: "vaccination-record", + responsive: true, + head: [ + { text: __("patientProgramme.vaccinationRecord.dose.label") }, + { text: __("vaccination.createdAt.label") }, + { text: __("vaccination.age.label") }, + { text: __("vaccination.source.label") } + ], + rows: rows + }) if rows.length }} +{% endcall %} diff --git a/app/views/patient/programme.njk b/app/views/patient/programme.njk index 85b6a8771..1d85f3fdd 100644 --- a/app/views/patient/programme.njk +++ b/app/views/patient/programme.njk @@ -4,7 +4,7 @@ {% set gridColumns = "full" %} {% set hideConfirmButton = true %} -{% set title = patient.fullName + " – " + patient.programmes[programme.slug].name %} +{% set title = patient.fullName + " – " + patientProgramme.programme.name %} {% set formAction = patientProgramme.uri + "/record?referrer=" + referrer %} {% block beforeContent %} @@ -26,17 +26,21 @@ patient: patient }) }} + {% include "patient/_vaccination-record.njk" %} + {% call card({ - heading: __mf("patientProgramme.vaccinationsGiven.count", { - count: patientProgramme.vaccinationsGiven.length + heading: __mf("patientProgramme.encounters.count", { + count: patientProgramme.canonicalVaccinationOutcomes.length }), headingLevel: 3 }) %} - {{ patientProgramme.formatted.statusWithNotes | nhsukMarkdown }} - - {% if patientProgramme.vaccinationsGiven.length %} + {% if patientProgramme.canonicalVaccinationOutcomes.length %} {% set vaccinationRows = [] %} - {% for vaccination in patientProgramme.vaccinationsGiven %} + {% for vaccination in patientProgramme.canonicalVaccinationOutcomes %} + {% set sourceHtml = vaccination.source %} + {% if vaccination.duplicates.length %} + {% set sourceHtml = sourceHtml + '
' + __mf("vaccination.duplicates.count", { count: vaccination.duplicates.length }) + '' %} + {% endif %} {% set vaccinationRows = vaccinationRows | push([ { header: __("vaccination.createdAt.label"), @@ -47,23 +51,23 @@ text: patient.dob | age(at=vaccination.createdAt) }, { - header: __("vaccination.programme.label"), - html: vaccination.formatted.programmeWithSequence + header: __("vaccination.outcome.label"), + html: vaccination.formatted.outcome }, { header: __("vaccination.source.label"), - text: vaccination.source + html: sourceHtml } ]) %} {% endfor %} {{ table({ - id: "vaccinations", + id: "encounters", responsive: true, head: [ { text: __("vaccination.createdAt.label") }, { text: __("vaccination.age.label") }, - { text: __("vaccination.programme.label") }, + { text: __("vaccination.outcome.label") }, { text: __("vaccination.source.label") } ], rows: vaccinationRows diff --git a/app/views/vaccination/duplicates.njk b/app/views/vaccination/duplicates.njk new file mode 100644 index 000000000..bddd8a49c --- /dev/null +++ b/app/views/vaccination/duplicates.njk @@ -0,0 +1,110 @@ +{% extends "_layouts/default.njk" %} + +{% set patient = vaccination.patient %} +{% set patientProgramme = patient.programmes[vaccination.programme_id] %} +{% set title = patient.fullName %} + +{% block beforeContent %} + {{ breadcrumb({ + items: [{ + text: __("home.show.title"), + href: "/" + }, { + text: __("patient.list.title"), + href: "/patients" + }, { + text: patient.fullName, + href: patient.uri + }, { + text: patientProgramme.programme.name, + href: patientProgramme.uri + }] + }) }} +{% endblock %} + +{% block content %} + {{ super() }} + + {{ appHeading({ + title: title + }) }} + + {% set primaryRows = [[ + { + header: __("vaccination.createdAt.label"), + html: vaccination.link.createdAt + }, + { + header: __("vaccination.age.label"), + text: patient.dob | age(at=vaccination.createdAt) + }, + { + header: __("vaccination.outcome.label"), + html: vaccination.formatted.outcome + }, + { + header: __("vaccination.source.label"), + text: vaccination.source + } + ]] %} + + {{ table({ + id: "primary-record", + responsive: true, + card: { + heading: __("vaccination.duplicates.canonical.heading"), + headingLevel: 3 + }, + head: [ + { text: __("vaccination.createdAt.label") }, + { text: __("vaccination.age.label") }, + { text: __("vaccination.outcome.label") }, + { text: __("vaccination.source.label") } + ], + rows: primaryRows + }) }} + + {% set duplicateRows = [] %} + {% for duplicate in vaccination.duplicates %} + {% set duplicateRows = duplicateRows | push([ + { + header: __("vaccination.createdAt.label"), + html: duplicate.link.createdAt + }, + { + header: __("vaccination.age.label"), + text: patient.dob | age(at=duplicate.createdAt) + }, + { + header: __("vaccination.outcome.label"), + html: duplicate.formatted.outcome + }, + { + header: __("vaccination.source.label"), + text: duplicate.source + } + ]) %} + {% endfor %} + + {{ table({ + id: "duplicate-records", + responsive: true, + card: { + heading: __mf("vaccination.duplicates.list.heading", { + count: vaccination.duplicates.length + }), + headingLevel: 3 + }, + head: [ + { text: __("vaccination.createdAt.label") }, + { text: __("vaccination.age.label") }, + { text: __("vaccination.outcome.label") }, + { text: __("vaccination.source.label") } + ], + rows: duplicateRows + }) }} + +

+ {{ __("vaccination.duplicates.reportProblem") }} +

+{% endblock %} diff --git a/app/views/vaccination/show.njk b/app/views/vaccination/show.njk index 5d451464b..309c35bbc 100644 --- a/app/views/vaccination/show.njk +++ b/app/views/vaccination/show.njk @@ -28,6 +28,46 @@ title: title }) }} + {% set patientProgramme = vaccination.patient.programmes[vaccination.programme_id] %} + {% set summary = patientProgramme.scheduleSummary %} + {% set scheduleValue %} + {% set found = false %} + {% for valid in summary.validDoses %} + {% if valid.vaccination.uuid == vaccination.uuid %} + {% set found = true %} + {{ __mf("patientProgramme.vaccinationRecord.dose.number", { sequence: valid.sequence }) }} + {% endif %} + {% endfor %} + {% for ignored in summary.ignoredDoses %} + {% if ignored.vaccination.uuid == vaccination.uuid %} + {% set found = true %} + + {{- tag({ + text: __("patientProgramme.vaccinationRecord.ignored.label"), + classes: "nhsuk-tag--grey" + }) -}} +
+ {{ ignored.reason }} +
+ {% endif %} + {% endfor %} + {% endset %} + + {% set canonicalRoleValue %} + {% if vaccination.isDuplicate %} + {{ __("vaccination.canonicalRole.no") }}. {{ __("vaccination.canonicalRole.view") }} + {% elif vaccination.duplicates.length %} + {{ __("vaccination.canonicalRole.yes") }} + {% endif %} + {% endset %} + + {% set duplicateRecordsValue %} + {% if vaccination.isDuplicate or vaccination.duplicates.length %} + {% set duplicatesUri = vaccination.canonical.uri + "/duplicates" %} + {{ __mf("vaccination.duplicateRecords.count", { count: vaccination.canonical.duplicates.length }) }} + {% endif %} + {% endset %} + {{ summaryList({ card: { heading: __("vaccination.show.summary"), @@ -43,6 +83,7 @@ }, rows: summaryRows(vaccination, { programme: {}, + schedule: { value: scheduleValue | trim, label: __("patientProgramme.vaccinationRecord.dose.label") }, outcome: {}, vaccine_snomed: {}, method: {}, @@ -59,8 +100,16 @@ protocol: {}, note: {}, source: {}, - syncStatus: {} + syncStatus: {}, + canonicalRole: { value: canonicalRoleValue | trim }, + duplicateRecords: { value: duplicateRecordsValue | trim } }) }) }} + + {% if vaccination.isDuplicate or vaccination.duplicates.length %} +

+ {{ __("vaccination.reportProblem") }} +

+ {% endif %} {% endblock %} diff --git a/lib/create-data.js b/lib/create-data.js index 9de60b896..b63e6a832 100644 --- a/lib/create-data.js +++ b/lib/create-data.js @@ -1,7 +1,7 @@ import process from 'node:process' import { faker } from '@faker-js/faker' -import { addMinutes, isSameDay } from 'date-fns' +import { addDays, addMinutes, addMonths, isSameDay } from 'date-fns' import clinicsData from '../app/datasets/clinics.js' import programmesData from '../app/datasets/programmes.js' @@ -13,6 +13,7 @@ import { ArchiveRecordReason, ConsentOutcome, ConsentWindow, + NotifyEmailStatus, PatientStatus, ProgrammeType, NoticeType, @@ -20,12 +21,16 @@ import { RegistrationOutcome, SchoolPhase, ScreenOutcome, + SessionPresetName, SessionPresets, SessionType, UploadType, UserRole, ReplyDecision, - ReplyMethod + ReplyMethod, + ReplyRefusal, + VaccinationOutcome, + VaccinationSource } from '../app/enums.js' import { generateBatch } from '../app/generators/batch.js' import { generateClinicAppointment } from '../app/generators/clinic-appointment.js' @@ -44,6 +49,7 @@ import { generateUser } from '../app/generators/user.js' import { generateVaccination } from '../app/generators/vaccination.js' import { Clinic, + Consent, Gillick, Instruction, Move, @@ -54,9 +60,11 @@ import { Session, Team, User, - Vaccination + Vaccination, + Vaccine } from '../app/models.js' import { + getAcademicYear, getDateValueDifference, formatDate, removeDays, @@ -717,6 +725,601 @@ if (vaccinatedPatient) { } } +// All seeded MMR patients are a Y9/Y10 cohort for AY 2025/26 being checked +// for MMR catch-up. Added last, after the random session/consent/vaccination +// loops, so they stay isolated reference cases. +const seededMmrSchoolId = '141104A' // Seva School - Primary (historical site) + +function buildSeededMmrVaccination({ + uuid, + patient_uuid, + patientSession_uuid, + dob, + ageMonths, + ageDays = 0, + given, + service = false, + notGivenOutcome, + sequence, + note, + clinic_id, + school_id, + canonicalVaccination_uuid, + source, + vaccineSnomed = '13968211000001108' // M-M-RvaxPro (MMR) by default +}) { + const createdAt = addDays(addMonths(dob, ageMonths), ageDays) + return new Vaccination({ + uuid, + createdAt, + createdBy_uid: nurse.uid, + patient_uuid, + patientSession_uuid, + programme_id: 'mmr', + vaccine_snomed: vaccineSnomed, + sequence, + note, + clinic_id, + school_id, + canonicalVaccination_uuid, + outcome: given + ? VaccinationOutcome.Vaccinated + : notGivenOutcome || VaccinationOutcome.Unwell, + source: + source || + (service || !given + ? VaccinationSource.Service + : VaccinationSource.NhsImmunisationsApi) + }) +} + +const seededMmrPatients = [ + { + uuid: 'mmr00001-0000-4000-8000-000000000001', + nhsn: '9990000011', + firstName: 'Alice', + lastName: 'Adams', + dob: new Date('2012-01-12'), // Y9 in AY 2025/26 + doses: [ + // Case 1 — two valid doses; Dose 2 miscoded as MMRV (a product not used + // on the UK schedule) to simulate a common data-entry error. + { + uuid: 'mmr00001-v001-4000-8000-000000000001', + ageMonths: 12, + ageDays: 14, + given: true + }, + { + uuid: 'mmr00001-v002-4000-8000-000000000002', + ageMonths: 40, + ageDays: 10, + given: true, + vaccineSnomed: '99926011000001103' + } + ] + }, + { + uuid: 'mmr00002-0000-4000-8000-000000000002', + nhsn: '9990000029', + firstName: 'Bilal', + lastName: 'Begum', + dob: new Date('2011-03-03'), // Y10 in AY 2025/26 + doses: [ + // Case 2 — one valid Dose 1, two missed pre-school GP appointments, + // then refused at Y9 Doubles + MMR catch-up (see below). + { + uuid: 'mmr00002-v001-4000-8000-000000000001', + ageMonths: 12, + ageDays: 14, + given: true + }, + { + uuid: 'mmr00002-v002-4000-8000-000000000002', + ageMonths: 50, + ageDays: 0, + given: false, + notGivenOutcome: VaccinationOutcome.Absent, + source: VaccinationSource.NhsImmunisationsApi + }, + { + uuid: 'mmr00002-v004-4000-8000-000000000004', + ageMonths: 57, + ageDays: 0, + given: false, + notGivenOutcome: VaccinationOutcome.Absent, + source: VaccinationSource.NhsImmunisationsApi + } + ] + }, + { + uuid: 'mmr00003-0000-4000-8000-000000000003', + nhsn: '9990000037', + firstName: 'Chiamaka', + lastName: 'Chen', + dob: new Date('2011-11-28'), // Y9 in AY 2025/26 + doses: [ + // Case 3 — early dose at 11m (out of schedule), then a valid Dose 1 + // given by SAIS at school age. Still needs Dose 2. + { + uuid: 'mmr00003-v001-4000-8000-000000000001', + ageMonths: 11, + ageDays: 0, + given: true + }, + { + uuid: 'mmr00003-v002-4000-8000-000000000002', + ageMonths: 50, + ageDays: 0, + given: true, + service: true, + sessionKey: 'session_50m' + } + ] + }, + { + uuid: 'mmr00004-0000-4000-8000-000000000004', + nhsn: '9990000045', + firstName: 'Dmitri', + lastName: 'Dixit', + dob: new Date('2012-07-05'), // Y9 in AY 2025/26 + doses: [ + // Case 4 — dose given one day before 1st birthday (ignored as under 12m), + // then the scheduled pre-school booster at 4 years. The clinician at the + // time treated these as Dose 1 + Dose 2, but Mavis ignores the early dose + // and classifies the booster as Dose 1. + { + uuid: 'mmr00004-v001-4000-8000-000000000001', + ageMonths: 11, + ageDays: 29, + given: true + }, + { + uuid: 'mmr00004-v002-4000-8000-000000000002', + ageMonths: 48, + ageDays: 14, + given: true + } + ] + }, + { + uuid: 'mmr00005-0000-4000-8000-000000000005', + nhsn: '9990000053', + firstName: 'Eshe', + lastName: 'Edwards', + dob: new Date('2010-10-15'), // Y10 in AY 2025/26 + doses: [ + // Case 5 — duplicate records (echoes from other systems). Clinically + // one Dose 1, but recorded three times. + { + uuid: 'mmr00005-v001-4000-8000-000000000001', + ageMonths: 13, + ageDays: 0, + given: true + }, + { + uuid: 'mmr00005-v002-4000-8000-000000000002', + ageMonths: 13, + ageDays: 3, + given: true + }, + { + uuid: 'mmr00005-v003-4000-8000-000000000003', + ageMonths: 13, + ageDays: 4, + given: true + } + ] + }, + { + uuid: 'mmr00006-0000-4000-8000-000000000006', + nhsn: '9990000061', + firstName: 'Farah', + lastName: 'Farooq', + dob: new Date('2012-03-15'), // Y9 in AY 2025/26 + doses: [ + // Case 6 — partial record: a single MMR dose recorded at the pre-school + // booster age with sequence 2P. No Dose 1 is present in the record. + { + uuid: 'mmr00006-v001-4000-8000-000000000001', + ageMonths: 48, + ageDays: 0, + given: true, + sequence: '2P' + } + ] + }, + { + uuid: 'mmr00007-0000-4000-8000-000000000007', + nhsn: '9990000079', + firstName: 'Gareth', + lastName: 'Greene', + dob: new Date('2011-05-12'), // Y10 in AY 2025/26 + doses: [ + // Case 7 — direct clashing duplicates from multiple feeds for the same + // clinical event. Three records for Dose 1, all sharing the same date. + { + uuid: 'mmr00007-v001-4000-8000-000000000001', + ageMonths: 12, + ageDays: 14, + given: true, + clinic_id: 'M84008' + }, + { + uuid: 'mmr00007-v002-4000-8000-000000000002', + ageMonths: 12, + ageDays: 14, + given: true, + canonicalVaccination_uuid: 'mmr00007-v001-4000-8000-000000000001' + }, + { + uuid: 'mmr00007-v006-4000-8000-000000000006', + ageMonths: 12, + ageDays: 14, + given: true, + clinic_id: 'M84008', + canonicalVaccination_uuid: 'mmr00007-v001-4000-8000-000000000001' + } + ] + } +] + +for (const seed of seededMmrPatients) { + const vaccination_uuids = [] + const patientSession_uuids = [] + + for (const dose of seed.doses) { + let patientSession_uuid + if (dose.sessionKey) { + const sessionId = `mmr-seed-${seed.uuid.slice(0, 8)}-${dose.sessionKey}` + const sessionDate = addDays( + addMonths(seed.dob, dose.ageMonths), + dose.ageDays || 0 + ) + const openAt = addDays(sessionDate, -42) + + if (!context.sessions[sessionId]) { + const session = new Session( + { + id: sessionId, + createdAt: openAt, + createdBy_uid: nurse.uid, + date: sessionDate, + openAt, + academicYear: getAcademicYear(sessionDate), + type: SessionType.School, + school_id: seededMmrSchoolId, + presetNames: [SessionPresetName.MMR], + registration: true + }, + context + ) + context.sessions[session.id] = session + } + + const patientSession = new PatientSession( + { + createdAt: openAt, + patient_uuid: seed.uuid, + programme_id: 'mmr', + session_id: sessionId + }, + context + ) + context.patientSessions[patientSession.uuid] = patientSession + patientSession_uuid = patientSession.uuid + patientSession_uuids.push(patientSession.uuid) + } + + const vaccination = buildSeededMmrVaccination({ + ...dose, + dob: seed.dob, + patient_uuid: seed.uuid, + patientSession_uuid + }) + context.vaccinations[vaccination.uuid] = vaccination + vaccination_uuids.push(vaccination.uuid) + } + + const patient = new Patient({ + uuid: seed.uuid, + nhsn: seed.nhsn, + firstName: seed.firstName, + lastName: seed.lastName, + dob: seed.dob, + school_id: seededMmrSchoolId, + address: { + addressLine1: '1 Test Street', + addressLevel1: 'Coventry', + postalCode: 'CV1 1AA' + }, + patientSession_uuids, + vaccination_uuids + }) + context.patients[patient.uuid] = patient +} + +// Bilal's Y9 Doubles + MMR catch-up session last academic year (AY 2024/25, +// summer term). All three vaccines were refused on the day. +const bilalUuid = 'mmr00002-0000-4000-8000-000000000002' +const bilalY9DoublesSessionId = 'mmr00002-doubles-y9-prior' +const bilalY9DoublesDate = new Date(2025, 5, 12) // 12 June 2025 +const bilalY9DoublesOpenAt = addDays(bilalY9DoublesDate, -42) + +context.sessions[bilalY9DoublesSessionId] = new Session( + { + id: bilalY9DoublesSessionId, + createdAt: bilalY9DoublesOpenAt, + createdBy_uid: nurse.uid, + date: bilalY9DoublesDate, + openAt: bilalY9DoublesOpenAt, + academicYear: getAcademicYear(bilalY9DoublesDate), + type: SessionType.School, + school_id: '135335', // Grace Academy Coventry + yearGroups: [9], + presetNames: [SessionPresetName.Doubles, SessionPresetName.MMR], + registration: true + }, + context +) + +const consultantPendingNote = + 'Parents asked their paediatric oncology consultant for advice given Bilal’s treatment history before consenting. They had not heard back by the day of the session, so refused consent for now. To be revisited once consultant input is received.' + +const bilalDoublesEntries = [ + { + uuid: 'mmr00002-doubles-y9-mmr', + programme_id: 'mmr', + vaccine_snomed: '13968211000001108', // M-M-RvaxPro + outcome: VaccinationOutcome.ConsentRefused, + note: consultantPendingNote + }, + { + uuid: 'mmr00002-doubles-y9-001', + programme_id: 'menacwy', + vaccine_snomed: '39779611000001104', // MenQuadfi + outcome: VaccinationOutcome.ConsentRefused, + note: consultantPendingNote + }, + { + uuid: 'mmr00002-doubles-y9-002', + programme_id: 'td-ipv', + vaccine_snomed: '7374311000001101', // Revaxis + outcome: VaccinationOutcome.ConsentRefused, + note: consultantPendingNote + } +] + +for (const entry of bilalDoublesEntries) { + const ps = new PatientSession( + { + createdAt: bilalY9DoublesOpenAt, + patient_uuid: bilalUuid, + programme_id: entry.programme_id, + session_id: bilalY9DoublesSessionId + }, + context + ) + context.patientSessions[ps.uuid] = ps + context.patients[bilalUuid].patientSession_uuids.push(ps.uuid) + + const vaccination = new Vaccination( + { + uuid: entry.uuid, + createdAt: bilalY9DoublesDate, + createdBy_uid: nurse.uid, + patient_uuid: bilalUuid, + patientSession_uuid: ps.uuid, + programme_id: entry.programme_id, + vaccine_snomed: entry.vaccine_snomed, + outcome: entry.outcome, + source: VaccinationSource.Service, + note: entry.note + }, + context + ) + context.vaccinations[vaccination.uuid] = vaccination + context.patients[bilalUuid].vaccination_uuids.push(vaccination.uuid) +} + +// Bilal's Y8 HPV session two academic years ago (AY 2023/24, spring term). +const bilalY8HpvSessionId = 'mmr00002-hpv-y8-prior' +const bilalY8HpvDate = new Date(2024, 2, 13) // 13 March 2024 +const bilalY8HpvOpenAt = addDays(bilalY8HpvDate, -42) + +context.sessions[bilalY8HpvSessionId] = new Session( + { + id: bilalY8HpvSessionId, + createdAt: bilalY8HpvOpenAt, + createdBy_uid: nurse.uid, + date: bilalY8HpvDate, + openAt: bilalY8HpvOpenAt, + academicYear: getAcademicYear(bilalY8HpvDate), + type: SessionType.School, + school_id: '135335', // Grace Academy Coventry + yearGroups: [8], + presetNames: [SessionPresetName.HPV], + registration: true + }, + context +) + +const bilalY8HpvNote = + 'Parents unsure whether the HPV vaccine was suitable for Bilal given his oncology history. Asked for more time to discuss with the consultant before consenting; consent not provided in time, so refused for this session.' + +{ + const ps = new PatientSession( + { + createdAt: bilalY8HpvOpenAt, + patient_uuid: bilalUuid, + programme_id: 'hpv', + session_id: bilalY8HpvSessionId + }, + context + ) + context.patientSessions[ps.uuid] = ps + context.patients[bilalUuid].patientSession_uuids.push(ps.uuid) + + const vaccination = new Vaccination( + { + uuid: 'mmr00002-hpv-y8-001', + createdAt: bilalY8HpvDate, + createdBy_uid: nurse.uid, + patient_uuid: bilalUuid, + patientSession_uuid: ps.uuid, + programme_id: 'hpv', + vaccine_snomed: '33493111000001108', // Gardasil 9 + outcome: VaccinationOutcome.ConsentRefused, + source: VaccinationSource.Service, + note: bilalY8HpvNote + }, + context + ) + context.vaccinations[vaccination.uuid] = vaccination + context.patients[bilalUuid].vaccination_uuids.push(vaccination.uuid) +} + +// Current catch-up session for the Y9/Y10 cohort. Held at a secondary +// school. Runs MMR catch-up co-located with the teenage Doubles boosters +// (MenACWY + Td/IPV). +const mmrCatchupSchoolId = '135335' // Grace Academy Coventry +const mmrCatchupSessionId = 'mmr-catchup-y9y10-current' +const mmrCatchupSessionDate = new Date(today().getFullYear(), 5, 17) // mid-June, summer term +const mmrCatchupOpenAt = addDays(mmrCatchupSessionDate, -42) + +context.sessions[mmrCatchupSessionId] = new Session( + { + id: mmrCatchupSessionId, + createdAt: mmrCatchupOpenAt, + createdBy_uid: nurse.uid, + date: mmrCatchupSessionDate, + openAt: mmrCatchupOpenAt, + academicYear: getAcademicYear(mmrCatchupSessionDate), + type: SessionType.School, + school_id: mmrCatchupSchoolId, + yearGroups: [9, 10], + presetNames: [SessionPresetName.MMR, SessionPresetName.Doubles], + registration: true + }, + context +) + +const mmrCohortUuids = [ + 'mmr00001-0000-4000-8000-000000000001', // Alice + 'mmr00002-0000-4000-8000-000000000002', // Bilal + 'mmr00003-0000-4000-8000-000000000003', // Chiamaka + 'mmr00004-0000-4000-8000-000000000004', // Dmitri + 'mmr00005-0000-4000-8000-000000000005', // Eshe + 'mmr00006-0000-4000-8000-000000000006', // Farah + 'mmr00007-0000-4000-8000-000000000007' // Gareth +] + +for (const patient_uuid of mmrCohortUuids) { + for (const programme_id of ['mmr', 'menacwy', 'td-ipv']) { + const patientSession = new PatientSession( + { + createdAt: mmrCatchupOpenAt, + patient_uuid, + programme_id, + session_id: mmrCatchupSessionId + }, + context + ) + context.patientSessions[patientSession.uuid] = patientSession + context.patients[patient_uuid].patientSession_uuids.push( + patientSession.uuid + ) + } +} + +// Ensure every cohort member has a parent with at least an email. +const dmitri = context.patients['mmr00004-0000-4000-8000-000000000004'] +for (const patient_uuid of mmrCohortUuids) { + const p = context.patients[patient_uuid] + if (!p.parent1) { + p.parent1 = generateParent(p.lastName, true) + } + if (!p.parent1.email) { + const firstName = p.parent1.fullName.split(' ')[0].toLowerCase() + const lastName = p.lastName.toLowerCase() + p.parent1.email = `${firstName}.${lastName}@example.com` + } + p.parent1.emailStatus = NotifyEmailStatus.Delivered +} +const dmitriParent = dmitri.parent1 + +// Dmitri's parent refuses MMR consent citing already vaccinated. +const dmitriConsent = new Consent( + { + uuid: 'mmr00004-r001-4000-8000-000000000001', + createdAt: addDays(today(), -3), + createdBy_uid: nurse.uid, + child: dmitri, + parent: dmitriParent, + decision: ReplyDecision.Refused, + refusalReason: ReplyRefusal.AlreadyVaccinated, + method: ReplyMethod.Website, + programme_id: 'mmr', + session_id: mmrCatchupSessionId + }, + context +) +dmitriConsent.linkToPatient(dmitri) +context.replies[dmitriConsent.uuid] = dmitriConsent + +function buildAllNoHealthAnswers(programme) { + const snomed = programme.vaccine_snomeds[0] + const vaccine = new Vaccine(context.vaccines[snomed], context) + const answers = {} + for (const key of Object.keys(vaccine.flatHealthQuestions)) { + answers[key] = { answer: 'No' } + } + return answers +} + +const programmeReplyIndex = { mmr: 1, menacwy: 2, 'td-ipv': 3 } +for (const patient_uuid of mmrCohortUuids) { + const patient = context.patients[patient_uuid] + const parent = patient.parent1 + const patientPrefix = patient_uuid.slice(0, 8) + + for (const programme_id of ['mmr', 'menacwy', 'td-ipv']) { + if (programme_id === 'mmr' && patient_uuid === dmitri.uuid) continue + + const session_id = mmrCatchupSessionId + const programme = context.programmes[programme_id] + const idx = programmeReplyIndex[programme_id] + const uuid = `${patientPrefix}-r${String(idx).padStart(3, '0')}-4000-8000-000000000001` + + const healthAnswers = buildAllNoHealthAnswers(programme) + + if (patient_uuid === bilalUuid && programme_id === 'mmr') { + healthAnswers.immuneSystem = { + answer: 'Yes', + details: + 'Bilal had chemotherapy for leukaemia at Birmingham Children’s Hospital, finishing treatment in 2016. He has been in remission since and was discharged from oncology follow-up. No current medication.' + } + } + + const consent = new Consent( + { + uuid, + createdAt: addDays(mmrCatchupOpenAt, 3 + idx), + createdBy_uid: nurse.uid, + child: patient, + parent, + decision: ReplyDecision.Given, + method: ReplyMethod.Website, + healthAnswers, + programme_id, + session_id + }, + context + ) + consent.linkToPatient(patient) + context.replies[consent.uuid] = consent + } +} + // Generate date files generateDataFile('.data/batches.json', context.batches) generateDataFile('.data/clinic-bookings.json', context.clinicBookings)