Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/controllers/vaccination.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions app/datasets/vaccines.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
]
}
}
42 changes: 42 additions & 0 deletions app/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
},
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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'
},
Expand Down
44 changes: 41 additions & 3 deletions app/models/patient-programme.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -137,7 +139,7 @@ export class PatientProgramme {
*/
get lastPatientSession() {
if (this.patientSessions?.length > 0) {
return this.patientSessions.at(-1)
return this.patientSessions.at(0)
}
}

Expand Down Expand Up @@ -287,6 +289,17 @@ export class PatientProgramme {
)
}

/**
* Get canonical vaccination outcomes (excluding duplicates)
*
* @returns {Array<import('./vaccination.js').Vaccination>|undefined} Vaccinations
*/
get canonicalVaccinationOutcomes() {
return this.vaccinationOutcomes?.filter(
(vaccination) => !vaccination.canonicalVaccination_uuid
)
}

/**
* Get last vaccination outcome
*
Expand Down Expand Up @@ -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)
*
Expand Down
18 changes: 15 additions & 3 deletions app/models/patient-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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()}).`
Expand Down
81 changes: 81 additions & 0 deletions app/models/vaccination.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Vaccination>} 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<Vaccination>} Duplicate set
*/
get duplicateSet() {
if (this.isDuplicate) return [this]
return [this, ...this.duplicates]
}

/**
* Get status of sync with NHS England API
*
Expand Down Expand Up @@ -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<Vaccination>} Vaccinations
* @static
*/
static findAllCanonical(context) {
return Vaccination.findAll(context).filter(
(vaccination) => !vaccination.canonicalVaccination_uuid
)
}

/**
* Find one
*
Expand Down
2 changes: 2 additions & 0 deletions app/routes/vaccination.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading