diff --git a/app/assets/stylesheets/components/_details.scss b/app/assets/stylesheets/components/_details.scss index ff808cf7a..a5bba751c 100644 --- a/app/assets/stylesheets/components/_details.scss +++ b/app/assets/stylesheets/components/_details.scss @@ -12,3 +12,21 @@ @include nhsuk-responsive-padding(4, "right"); } } + +.app-details--notify-message { + .nhsuk-details__text { + padding: nhsuk-spacing(4); + border: 1px solid $nhsuk-border-colour; + background-color: nhsuk-colour("white"); + font-family: Helvetica, Arial, sans-serif; + + .nhsuk-inset-text { + margin-top: 0; + border-color: $nhsuk-border-colour; + + @include nhsuk-responsive-padding(2, "bottom"); + @include nhsuk-responsive-padding(2, "top"); + @include nhsuk-responsive-margin(4, "bottom"); + } + } +} diff --git a/app/controllers/activity.js b/app/controllers/activity.js new file mode 100644 index 000000000..965c7380c --- /dev/null +++ b/app/controllers/activity.js @@ -0,0 +1,291 @@ +import activity from '../datasets/activity.js' +import { ArchiveRecordReason, AuditEventType, ScreenOutcome } from '../enums.js' +import { generateParent } from '../generators/parent.js' +import { + AuditEvent, + Gillick, + Patient, + Reply, + Session, + Vaccination +} from '../models.js' + +export const activityController = { + list(request, response) { + const { data } = request.session + + const auditEvent = (event) => new AuditEvent(event, data) + const createdBy_uid = Object.values(data.users)[0].uid + const gillickCompetent = new Gillick({ + q1: true, + q2: true, + q3: true, + q4: true, + q5: true + }) + const gillickNotCompetent = new Gillick({ + q1: true, + q2: true, + q3: true, + q4: true, + q5: false + }) + const patient = Patient.findAll(data).find( + ({ hasMissingNhsNumber, invalid }) => !hasMissingNhsNumber && !invalid + ) + const mergedPatient = Patient.findAll(data).find( + ({ uuid, hasMissingNhsNumber, invalid }) => + uuid !== patient.uuid && !hasMissingNhsNumber && !invalid + ) + const reply = Reply.findAll(data).find( + (reply) => !reply.selfConsent && reply.given + ) + const session = Session.findOne(Object.values(data.sessions)[0].id, data) + const vaccinationGiven = Vaccination.findAll(data).find( + (vaccination) => vaccination.given + ) + const vaccinationNotGiven = Vaccination.findAll(data).find( + (vaccination) => !vaccination.given + ) + + // Parent for use in Notify activities; force having both email and phone + const parent = generateParent(patient.lastName) + parent.email = + parent.email || + `${parent.fullName.replace(' ', '.').toLowerCase()}@example.com` + parent.tel = parent.tel || '07700 900000' + + const activityLog = [ + { + title: 'Attendance', + items: [ + auditEvent({ + name: activity.attendance.present(session), + createdBy_uid, + programme_ids: ['menacwy', 'td-ipv'] + }), + auditEvent({ + name: activity.attendance.absent(session), + createdBy_uid, + programme_ids: ['menacwy', 'td-ipv'] + }) + ] + }, + { + title: 'Consent', + items: [ + auditEvent({ + name: activity.consent.created(reply), + createdBy_uid, + programme_ids: ['flu'] + }), + auditEvent({ + name: activity.consent.updated(reply), + createdBy_uid, + programme_ids: ['flu'] + }), + auditEvent({ + name: activity.consent.matched(reply), + createdBy_uid, + programme_ids: ['flu'] + }), + auditEvent({ + name: activity.consent.invalid(reply), + createdBy_uid, + programme_ids: ['flu'] + }), + auditEvent({ + name: activity.consent.withdrawn(reply), + createdBy_uid, + programme_ids: ['flu'] + }) + ] + }, + { + title: 'Gillick', + items: [ + auditEvent({ + name: activity.gillick.created(gillickCompetent), + note: 'Child happy to proceed', + createdBy_uid, + programme_ids: ['hpv'] + }), + auditEvent({ + name: activity.gillick.created(gillickNotCompetent), + note: 'Child did not understand the side effects', + createdBy_uid, + programme_ids: ['hpv'] + }), + auditEvent({ + name: activity.gillick.updated(gillickCompetent), + note: 'Child now happy to proceed', + createdBy_uid, + programme_ids: ['hpv'] + }), + auditEvent({ + name: activity.gillick.updated(gillickNotCompetent), + note: 'Child is no longer happy to proceed', + createdBy_uid, + programme_ids: ['hpv'] + }) + ] + }, + { + title: 'Notes', + items: [ + auditEvent({ + name: activity.note.created(AuditEventType.SessionNote), + note: 'Mum phoned to say child will be arriving at school at 11am', + createdBy_uid, + programme_ids: ['flu'] + }), + auditEvent({ + name: activity.note.created(AuditEventType.RecordNote), + note: 'Child gave consent for HPV and flu vaccinations under Gillick competence and does not want their parents to be notified.', + createdBy_uid + }) + ] + }, + { + title: 'Notify', + items: [ + 'invite', + 'invite-reminder', + 'invite-clinic', + 'invite-clinic-reminder', + 'consent-given', + 'consent-given-changed-school', + 'consent-needs-triage', + 'consent-refused', + 'consent-unknown-contact', + 'triage-delay-vaccination', + 'triage-do-not-vaccinate', + 'triage-invite-to-clinic', + 'triage-vaccinate', + 'triage-vaccinate-second-dose', + 'vaccination-reminder', + 'vaccination-given', + 'vaccination-not-administered', + 'vaccination-already-had', + 'vaccination-deleted' + ].map((name) => + auditEvent({ + name: activity.notify[name](parent), + messageRecipient: parent, + messageTemplate: name, + patient_uuid: patient.uuid, + programme_ids: session.programme_ids, + session_id: session.id + }) + ) + }, + { + title: 'Patient', + items: [ + auditEvent({ + name: activity.patient.archived({ + archiveReason: ArchiveRecordReason.Other + }), + note: 'A brief note about why child record was archived.', + createdBy_uid + }), + auditEvent({ + name: activity.patient.expired, + note: `${patient.fullName} was vaccinated`, + createdBy_uid + }), + auditEvent({ + name: activity.patient.merged(mergedPatient, patient), + createdBy_uid + }) + ] + }, + { + title: 'Pre-screening', + items: [ + auditEvent({ + name: activity.preScreen.created, + note: 'A brief note about the pre-screening checks.', + createdBy_uid, + programme_ids: ['flu'] + }) + ] + }, + { + title: 'PSD', + items: [ + auditEvent({ + name: activity.psd.added, + createdBy_uid, + programme_ids: ['flu'] + }), + auditEvent({ + name: activity.psd.invalidated, + createdBy_uid, + programme_ids: ['flu'] + }) + ] + }, + { + title: 'Session', + items: [ + auditEvent({ + name: activity.session.added(session), + programme_ids: ['flu'] + }), + auditEvent({ + name: activity.session.removed(session), + createdBy_uid, + programme_ids: ['flu'] + }) + ] + }, + { + title: 'Triage', + items: [ + auditEvent({ + name: activity.triage.decision({ + outcome: ScreenOutcome.DelayVaccination + }), + note: 'A brief note about the triage decision.', + createdBy_uid, + programme_ids: ['flu'] + }) + ] + }, + { + title: 'Vaccination', + items: [ + auditEvent({ + name: activity.vaccination.recorded(vaccinationGiven), + note: 'A brief note about the vaccination session.', + createdBy_uid, + programme_ids: [vaccinationGiven.programme_id], + vaccination_uuid: vaccinationGiven.uuid + }), + auditEvent({ + name: activity.vaccination.recorded(vaccinationNotGiven), + note: 'A brief note about the vaccination session.', + createdBy_uid, + programme_ids: [vaccinationNotGiven.programme_id], + vaccination_uuid: vaccinationNotGiven.uuid + }), + auditEvent({ + name: activity.vaccination.added, + createdBy_uid, + programme_ids: [vaccinationGiven.programme_id], + vaccination_uuid: vaccinationGiven.uuid + }), + auditEvent({ + name: activity.vaccination.uploaded, + createdBy_uid, + programme_ids: [vaccinationGiven.programme_id], + vaccination_uuid: vaccinationGiven.uuid + }) + ] + } + ] + + response.render('activity/list', { activityLog }) + } +} diff --git a/app/controllers/consent.js b/app/controllers/consent.js index a2f044785..e4bd295f9 100644 --- a/app/controllers/consent.js +++ b/app/controllers/consent.js @@ -159,11 +159,10 @@ export const consentController = { ) // Add to session - patient.addToSession(patientSession) + patient.addToSession(patientSession.session) // Invite parent to give consent - const session = Session.findOne(patientSession.session_id, data) - patient.inviteToSession(session) + patient.requestConsent(patientSession) // Link consent with patient record consent.linkToPatient(patient) diff --git a/app/controllers/patient-session.js b/app/controllers/patient-session.js index ca5e830b9..8fc47ceb2 100644 --- a/app/controllers/patient-session.js +++ b/app/controllers/patient-session.js @@ -6,13 +6,11 @@ import { PreScreenQuestion, ProgrammeType, RegistrationOutcome, - ScreenOutcome, UserRole, VaccinationOutcome, VaccineMethod } from '../enums.js' import { - Gillick, Instruction, PatientSession, Programme, @@ -259,16 +257,11 @@ export const patientSessionController = { gillick.updatedAt = today() } - const name = __(`patientSession.gillick.${type}.success`) - request.flash('success', name) + gillick.createdBy_uid = account.uid - patientSession.assessGillick( - { - name, - createdBy_uid: account.uid - }, - new Gillick(gillick) - ) + request.flash('success', __(`patientSession.gillick.${type}.success`)) + + patientSession.assessGillick(gillick) // Clean up session data delete data.patientSession?.gillick @@ -304,8 +297,8 @@ export const patientSessionController = { const { account } = request.app.locals const { __, back, patient, patientSession } = response.locals - patient.inviteToSession({ - session: patientSession.session, + patient.requestConsent({ + patientSession, createdBy_uid: account.uid }) @@ -353,10 +346,6 @@ export const patientSessionController = { patientSession.recordTriage({ outcome: triage.outcome, outcomeAt_: triage.outcomeAt_, - name: - triage.outcome === ScreenOutcome.NeedsTriage - ? 'Triaged decision: Keep in triage' - : `Triaged decision: ${triage.outcome}`, note: triage.note, createdBy_uid: account.uid }) diff --git a/app/controllers/session.js b/app/controllers/session.js index b6f5f4c74..ab4a01116 100644 --- a/app/controllers/session.js +++ b/app/controllers/session.js @@ -713,7 +713,7 @@ export const sessionController = { patientSession.removeFromSession({ createdBy_uid: account.uid }) - patient.addToSession(patientSession) + patient.addToSession(patientSession.session) Patient.update(patientSession.patient_uuid, {}, data) } } diff --git a/app/datasets/activity.js b/app/datasets/activity.js new file mode 100644 index 000000000..e029b5898 --- /dev/null +++ b/app/datasets/activity.js @@ -0,0 +1,101 @@ +import { ScreenOutcome } from '../enums.js' +import { lowerCaseFirst } from '../utils/string.js' + +export default { + attendance: { + present: (session) => `Attended session at ${session.location.name}`, + absent: (session) => `Absent from session at ${session.location.name}` + }, + consent: { + created: ({ decision, parent }) => + `${decision} by ${parent.fullNameAndRelationship}`, + updated: ({ decision, parent }) => + `${decision} in updated response from ${parent.fullNameAndRelationship}`, + matched: ({ parent }) => + `Consent response from ${parent.fullNameAndRelationship} manually matched with child record`, + invalid: ({ parent }) => + `Consent response from ${parent.fullNameAndRelationship} marked as invalid`, + withdrawn: ({ parent }) => + `Consent response from ${parent.fullNameAndRelationship} withdrawn` + }, + gillick: { + created: (gillick) => gillick.competent, + updated: (gillick) => gillick.competent.replace('assessed', 'reassessed') + }, + note: { + created: (type) => `${type} added` + }, + notify: { + invite: (parent) => + `Consent request sent to ${parent.fullNameAndRelationship}`, + 'invite-reminder': (parent) => + `Reminder to give or refuse consent sent to ${parent.fullNameAndRelationship}`, + 'invite-clinic': (parent) => + `Invitation to book a clinic appointment sent to ${parent.fullNameAndRelationship}`, + 'invite-clinic-reminder': (parent) => + `Reminder to book a clinic appointment sent to ${parent.fullNameAndRelationship}`, + 'consent-given': (parent) => + `Confirmation of consent given sent to ${parent.fullNameAndRelationship}`, + 'consent-given-changed-school': (parent) => + `Confirmation of consent given (clinic booking needed) sent to ${parent.fullNameAndRelationship}`, + 'consent-needs-triage': (parent) => + `Confirmation of consent given (triage needed) sent to ${parent.fullNameAndRelationship}`, + 'consent-refused': (parent) => + `Confirmation of consent refused sent to ${parent.fullNameAndRelationship}`, + 'consent-unknown-contact': (parent) => + `Unknown parent contact details warning sent to ${parent.fullNameAndRelationship}`, + 'triage-delay-vaccination': (parent) => + `Confirmation of triage decision (delay vaccination) sent to ${parent.fullNameAndRelationship}`, + 'triage-do-not-vaccinate': (parent) => + `Confirmation of triage decision (unable to vaccinate) sent to ${parent.fullNameAndRelationship}`, + 'triage-invite-to-clinic': (parent) => + `Confirmation of triage decision (invite to clinic) sent to ${parent.fullNameAndRelationship}`, + 'triage-vaccinate': (parent) => + `Confirmation of triage decision (safe to vaccinate) sent to ${parent.fullNameAndRelationship}`, + 'triage-vaccinate-second-dose': (parent) => + `Confirmation of triage decision (2nd dose will be given in school) sent to ${parent.fullNameAndRelationship}`, + 'vaccination-reminder': (parent) => + `Session reminder sent to ${parent.fullNameAndRelationship}`, + 'vaccination-given': (parent) => + `Confirmation of vaccination sent to ${parent.fullNameAndRelationship}`, + 'vaccination-not-administered': (parent) => + `Confirmation of vaccination not given sent to ${parent.fullNameAndRelationship}`, + 'vaccination-already-had': (parent) => + `Confirmation of vaccination discovered since consent sent to ${parent.fullNameAndRelationship}`, + 'vaccination-deleted': (parent) => + `Apology for sending an incorrect message sent to ${parent.fullNameAndRelationship}` + }, + patient: { + archived: (archive) => `Record archived: ${archive.archiveReason}`, + expired: + 'Consent, health information, triage outcome and PSD status expired', + merged: (mergedPatient, patient) => + `The record for ${mergedPatient.fullName} (date of birth ${mergedPatient.formatted.dob}) was merged with the record for ${patient.fullName} (date of birth ${patient.formatted.dob}) because they have the same NHS number (${mergedPatient.formatted.nhsn}).`, + updated: (key, value) => `Updated \`${key}\` to **${value}**` + }, + preScreen: { + created: 'Completed pre-screening checks' + }, + psd: { + added: 'PSD added', + invalidated: 'PSD invalidated' + }, + session: { + added: (session) => `Added to the session at ${session.location.name}`, + removed: (session) => `Removed from the session at ${session.location.name}` + }, + triage: { + decision: (triage) => + triage.outcome === ScreenOutcome.NeedsTriage + ? 'Triage decision: keep in triage' + : `Triage decision: ${lowerCaseFirst(triage.outcome)}` + }, + vaccination: { + added: 'Vaccination record added manually', + recorded: (vaccination) => + vaccination.given + ? `Vaccinated with ${vaccination.vaccine.brand}` + : `Could not vaccinate: ${lowerCaseFirst(vaccination.outcome)}`, + uploaded: 'Vaccination record uploaded' + } +} diff --git a/app/generators/parent.js b/app/generators/parent.js index 40bc74ed2..c3148564e 100644 --- a/app/generators/parent.js +++ b/app/generators/parent.js @@ -42,7 +42,7 @@ export function generateParent(childLastName, isMum) { } // Contact details - const phoneNumber = '07### ######'.replace(/#+/g, (m) => + const phoneNumber = '077## 9#####'.replace(/#+/g, (m) => faker.string.numeric(m.length) ) const tel = faker.helpers.maybe(() => phoneNumber, { probability: 0.4 }) diff --git a/app/globals.js b/app/globals.js index 90dcc2453..e6e3ee427 100644 --- a/app/globals.js +++ b/app/globals.js @@ -6,6 +6,7 @@ import { PatientConsentStatus, PatientRefusedStatus } from './enums.js' +import { en } from './locales/en.js' import { Location, User } from './models.js' import { getSessionActivityCount } from './utils/session.js' import { @@ -102,17 +103,54 @@ export default () => { } globals.timelineItems = function (auditEvents) { - const { filters } = this.ctx.settings.nunjucksEnv + const { nunjucksEnv } = this.ctx.settings const timelineItems = [] for (const auditEvent of Object.values(auditEvents)) { + const details = [] + + // Show email message content if recipient given with email address + if (auditEvent.messageRecipient?.email) { + details.push({ + classes: 'app-details--notify-message', + summaryText: `Email sent to ${auditEvent.messageRecipient?.email}`, + html: formatMarkdown( + nunjucksEnv.render( + `emails/consent/${auditEvent.messageTemplate}.njk`, + auditEvent.messageData + ) + ) + }) + } + + // Show email message content if recipient given with telephone number + // and text message content provided + if ( + auditEvent.messageRecipient?.tel && + en.texts.consent[auditEvent.messageTemplate]?.text + ) { + details.push({ + classes: 'app-details--notify-message', + summaryText: `Message sent to ${auditEvent.messageRecipient?.tel}`, + html: formatMarkdown( + nunjucksEnv.renderString( + `${en.texts.consent[auditEvent.messageTemplate].text}`, + auditEvent.messageData + ) + ) + }) + } + timelineItems.push({ headingText: formatMarkdown(auditEvent.name), isPastItem: auditEvent.isPastEvent, - html: auditEvent.formatted?.note, - description: filters.safe( - auditEvent.formatted.programmes + auditEvent.formatted.createdAtAndBy - ) + html: + auditEvent.note && + `
${auditEvent.formatted?.note}
`, + description: nunjucksEnv.filters.safe( + auditEvent.formatted.programmes + auditEvent.description + ), + details }) } diff --git a/app/models/audit-event.js b/app/models/audit-event.js index d4bc359ec..88dde42d8 100644 --- a/app/models/audit-event.js +++ b/app/models/audit-event.js @@ -1,6 +1,14 @@ import { isBefore } from 'date-fns' -import { Programme, User } from '../models.js' +import { + Child, + Patient, + Programme, + Session, + Team, + User, + Vaccination +} from '../models.js' import { convertIsoDateToObject, convertObjectToIsoDate, @@ -25,11 +33,16 @@ import { * @property {string} name - Name * @property {string} [note] - Note * @property {boolean} [pinned] - Pinned + * @property {object} [messageRecipient] - Message recipient + * @property {string} [messageTemplate] - Message template * @property {AuditEventType} [type] - Audit event type * @property {string} [outcome] - Outcome for activity type * @property {Date} [outcomeAt] - Date outcome invalidates * @property {object} [outcomeAt_] - Date outcome invalidates (from `dateInput`) + * @property {string} [patient_uuid] - Patient UUID * @property {Array} [programme_ids] - Programme IDs + * @property {string} [session_id] - Session ID + * @property {string} [vaccination_uuid] - Vaccination UUID */ export class AuditEvent { constructor(options, context) { @@ -39,11 +52,17 @@ export class AuditEvent { this.name = options.name this.note = options.note this.pinned = stringToBoolean(options?.pinned) + this.messageRecipient = options?.messageRecipient + this.messageTemplate = options?.messageTemplate this.type = options?.type this.outcome = options?.outcome this.outcomeAt = options?.outcomeAt && new Date(options.outcomeAt) this.outcomeAt_ = options?.outcomeAt_ + this.patient_uuid = options?.patient_uuid this.programme_ids = options?.programme_ids + this.session_id = options?.session_id + this.team_id = options?.team_id || '001' + this.vaccination_uuid = options?.vaccination_uuid } /** @@ -61,6 +80,19 @@ export class AuditEvent { } } + /** + * Get data to pass to message template + * + * @returns {object} Message data + */ + get messageData() { + return { + child: new Child(this.patient, this.context), + session: this.session, + team: this.team + } + } + /** * Get date outcome invalidates for `dateInput` * @@ -90,6 +122,21 @@ export class AuditEvent { return isBefore(this.createdAt, today()) } + /** + * Get patient + * + * @returns {Patient|undefined} Patient + */ + get patient() { + try { + if (this.patient_uuid) { + return Patient.findOne(this.patient_uuid, this.context) + } + } catch (error) { + console.error('AuditEvent.patient', error.message) + } + } + /** * Get programmes event relates to * @@ -105,6 +152,47 @@ export class AuditEvent { return [] } + /** + * Get session + * + * @returns {Session|undefined} Session + */ + get session() { + try { + return Session.findOne(this.session_id, this.context) + } catch (error) { + console.error('AuditEvent.session', error.message) + } + } + + /** + * Get team + * + * @returns {Team} Team + */ + get team() { + try { + return Team.findOne(this.team_id, this.context) + } catch (error) { + console.error('AuditEvent.team', error.message) + } + } + + /** + * Get vaccination + * + * @returns {Vaccination|undefined} Vaccination + */ + get vaccination() { + try { + if (this.vaccination_uuid) { + return Vaccination.findOne(this.vaccination_uuid, this.context) + } + } catch (error) { + console.error('AuditEvent.vaccination', error.message) + } + } + get summary() { return { createdAtAndBy: this.createdBy @@ -116,29 +204,39 @@ export class AuditEvent { } } + /** + * Get description - used to show more detailed metadata + * + * @returns {string} Description + */ + get description() { + if (this.vaccination) { + return `Vaccination given ${this.vaccination.formatted.createdAt_date} by ${this.vaccination.formatted.createdBy}.
Record added to Mavis ${this.formatted.createdAt} by ${this.formatted.createdBy}.` + } else if (this.createdBy_uid) { + return [this.formatted.createdAt, this.formatted.createdBy].join(` · `) + } + + return this.formatted.createdAt + } + /** * Get formatted values * * @returns {object} Formatted values */ get formatted() { - const datetime = formatDate(this.createdAt, { - day: 'numeric', - month: 'long', - year: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true - }) - return { createdAt: formatDate(this.createdAt, { dateStyle: 'long' }), - createdAtAndBy: this.createdBy - ? [datetime, this.createdBy.fullName].join(` · `) - : datetime, - datetime, - note: - this.note && `
${formatMarkdown(this.note)}
`, + createdBy: this.createdBy_uid && this.createdBy.fullName, + datetime: formatDate(this.createdAt, { + day: 'numeric', + month: 'long', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }), + note: this.note && formatMarkdown(this.note), outcome: this.outcome && formatTag(getScreenOutcomeStatus(this.outcome)), outcomeAt: this.outcomeAt && formatDate(this.outcomeAt, { dateStyle: 'long' }), diff --git a/app/models/patient-programme.js b/app/models/patient-programme.js index d4411be83..669964d01 100644 --- a/app/models/patient-programme.js +++ b/app/models/patient-programme.js @@ -102,6 +102,7 @@ export class PatientProgramme { .filter(({ programme_ids }) => programme_ids?.some((id) => this.programme_id === id) ) + .sort((a, b) => getDateValueDifference(a.createdAt, b.createdAt)) } /** diff --git a/app/models/patient-session.js b/app/models/patient-session.js index 8f8475436..cf461998c 100644 --- a/app/models/patient-session.js +++ b/app/models/patient-session.js @@ -1,6 +1,7 @@ import { fakerEN_GB as faker } from '@faker-js/faker' import filters from '@x-govuk/govuk-prototype-filters' +import activity from '../datasets/activity.js' import { AcademicYear, AuditEventType, @@ -922,8 +923,9 @@ export class PatientSession { removeFromSession(event) { this.patient.patientSession_uuids = this.patient.patientSession_uuids.filter((uuid) => uuid !== this.uuid) + this.patient.addEvent({ - name: `Removed from the ${this.session.name.replace('Flu', 'flu')}`, + name: activity.session.removed(this.session), createdBy_uid: event.createdBy_uid, programme_ids: this.session.programme_ids }) @@ -932,15 +934,16 @@ export class PatientSession { /** * Assess Gillick competence * - * @param {object} event - Event * @param {Gillick} gillick - gillick */ - assessGillick(event, gillick) { + assessGillick(gillick) { this.patient.addEvent({ - name: event.name, + name: gillick.updatedAt + ? activity.gillick.updated(gillick) + : activity.gillick.created(gillick), note: gillick.note, createdAt: gillick.createdAt, - createdBy_uid: event.createdBy_uid, + createdBy_uid: gillick.createdBy_uid, programme_ids: this.session.programme_ids }) @@ -954,7 +957,7 @@ export class PatientSession { */ recordTriage(event) { this.patient.addEvent({ - name: event.name, + name: activity.triage.decision(event), note: event.note, outcome: event.outcome, outcomeAt_: event.outcomeAt_, @@ -962,6 +965,33 @@ export class PatientSession { createdBy_uid: event.createdBy_uid, programme_ids: [this.programme_id] }) + + let messageTemplate + switch (event.outcome) { + case ScreenOutcome.DelayVaccination: + messageTemplate = 'triage-delay-vaccination' + break + case ScreenOutcome.DoNotVaccinate: + messageTemplate = 'triage-do-not-vaccinate' + break + case ScreenOutcome.InviteToClinic: + messageTemplate = 'triage-invite-to-clinic' + break + default: + messageTemplate = 'triage-vaccinate' + } + + for (const parent of this.patient.parents) { + this.patient.addEvent({ + name: activity.notify[messageTemplate](parent), + messageRecipient: parent, + messageTemplate, + createdAt: event.createdAt, + patient_uuid: this.uuid, + programme_ids: [this.programme_id], + session_id: this.session.id + }) + } } /** @@ -973,7 +1003,7 @@ export class PatientSession { this.instruction_uuid = instruction.uuid this.patient.addEvent({ - name: 'PSD added', + name: activity.psd.added, createdAt: instruction.createdAt, createdBy_uid: instruction.createdBy_uid, programme_ids: [this.programme_id] @@ -989,19 +1019,11 @@ export class PatientSession { registerAttendance(event, register) { this.session.updateRegister(this.patient.uuid, register) - let name - switch (register) { - case RegistrationOutcome.Present: - name = `Registered as attending today’s session at ${this.session.location.name}` - break - case RegistrationOutcome.Absent: - name = `Registered as absent from today’s session at ${this.session.location.name}` - break - default: - } - this.patient.addEvent({ - name, + name: + register === RegistrationOutcome.Present + ? activity.attendance.present(this.session) + : activity.attendance.absent(this.session), createdAt: event.createdAt, createdBy_uid: event.createdBy_uid, programme_ids: this.session.programme_ids @@ -1015,7 +1037,7 @@ export class PatientSession { */ preScreen(event) { this.patient.addEvent({ - name: 'Completed pre-screening checks', + name: activity.preScreen.created, note: event.note, createdAt: event.createdAt, createdBy_uid: event.createdBy_uid, @@ -1030,8 +1052,7 @@ export class PatientSession { */ saveNote(event) { this.patient.addEvent({ - type: AuditEventType.SessionNote, - name: `${AuditEventType.SessionNote} added`, + name: activity.note.created(event.type), note: event.note, pinned: event.pinned, createdBy_uid: event.createdBy_uid, @@ -1040,17 +1061,21 @@ export class PatientSession { } /** - * Record sent reminder + * Send reminder * * @param {import('./audit-event.js').AuditEvent} event - Event * @param {import('./parent.js').Parent} parent - Parent */ sendReminder(event, parent) { this.patient.addEvent({ + name: activity.notify['vaccination-reminder'](parent), + messageRecipient: parent, + messageTemplate: 'vaccination-reminder', type: AuditEventType.Reminder, - name: `Reminder to give consent sent to ${parent.fullName}`, createdBy_uid: event.createdBy_uid, - programme_ids: this.session.programme_ids + patient_uuid: this.patient_uuid, + programme_ids: this.session.programme_ids, + session_id: this.session.id }) } } diff --git a/app/models/patient.js b/app/models/patient.js index 82392c6ba..2a2a0b93c 100644 --- a/app/models/patient.js +++ b/app/models/patient.js @@ -1,9 +1,15 @@ import { fakerEN_GB as faker } from '@faker-js/faker' import _ from 'lodash' +import activity from '../datasets/activity.js' import programmesData from '../datasets/programmes.js' import schools from '../datasets/schools.js' -import { AuditEventType, NoticeType } from '../enums.js' +import { + AuditEventType, + NoticeType, + NotifyEmailStatus, + VaccinationOutcome +} from '../enums.js' import { AuditEvent, Child, @@ -26,7 +32,6 @@ import { formatOther, formatParent, formatWithSecondaryText, - sentenceCaseProgrammeName, stringToBoolean } from '../utils/string.js' @@ -237,6 +242,7 @@ export class Patient extends Child { .filter(({ type }) => [AuditEventType.Record, AuditEventType.RecordNote].includes(type) ) + .sort((a, b) => getDateValueDifference(a.createdAt, b.createdAt)) } /** @@ -549,7 +555,7 @@ export class Patient extends Child { if (Object.keys(context.patients).length === 1) { for (const [key, value] of Object.entries(updates)) { updatedPatient.addEvent({ - name: `Updated \`${key}\` to **${value}**`, + name: activity.patient.updated(key, value), type: AuditEventType.Record, createdAt: updatedPatient.updatedAt }) @@ -590,7 +596,7 @@ export class Patient extends Child { const archivedPatient = Patient.update(uuid, archive, context) archivedPatient.addEvent({ - name: `Record archived: ${archive.archiveReason}`, + name: activity.patient.archived(archive), note: archive.archiveReasonOther, type: AuditEventType.Record, createdBy_uid: archive.createdBy_uid @@ -602,26 +608,59 @@ export class Patient extends Child { /** * Add patient to session * - * @param {import('./patient-session.js').PatientSession} patientSession - Patient session - */ - addToSession(patientSession) { - this.patientSession_uuids.push(patientSession.uuid) - } - - /** - * Invite parent to give consent - * * @param {import('./session.js').Session} session - Session */ - inviteToSession(session) { + addToSession(session) { this.addEvent({ - name: `Added to the ${sentenceCaseProgrammeName(session.name)}`, + name: activity.session.added(session), createdAt: session.openAt, createdBy_uid: session.createdBy_uid, programme_ids: session.programme_ids }) } + /** + * Invite parent to book a clinic appointment + * + * @param {import('./session.js').Session} session - Clinic session + */ + inviteToClinic(session) { + for (const parent of this.parents) { + this.addEvent({ + name: activity.notify['invite-clinic'](parent), + messageRecipient: parent, + messageTemplate: 'invite-clinic', + createdAt: session.openAt, + patient_uuid: this.uuid, + programme_ids: session.programme_ids, + session_id: session.id + }) + } + } + + /** + * Invite parent to give consent + * + * @param {import('./patient-session.js').PatientSession} patientSession - Patient session + */ + requestConsent(patientSession) { + this.patientSession_uuids.push(patientSession.uuid) + + for (const parent of this.parents) { + if (parent.email && parent.emailStatus === NotifyEmailStatus.Delivered) { + this.addEvent({ + name: activity.notify.invite(parent), + messageRecipient: parent, + messageTemplate: 'invite', + createdAt: patientSession.session.openAt, + patient_uuid: this.uuid, + programme_ids: patientSession.session.programme_ids, + session_id: patientSession.session.id + }) + } + } + } + /** * Record reply * @@ -632,18 +671,15 @@ export class Patient extends Child { return } - const { decision, fullName, invalid, relationship, uuid } = reply - const isNew = !this.replies[uuid] - const parent = new Parent({ fullName, relationship }) - const formattedParent = formatParent(parent, false) + const isNew = !this.replies[reply.uuid] - let name = `${decision} by ${formattedParent}` - if (invalid) { - name = `${decision} by ${formattedParent} marked as invalid` + let name + if (reply.invalid) { + name = activity.consent.invalid(reply) } else if (isNew) { - name = `${decision} in response from ${formattedParent}` + name = activity.consent.created(reply) } else { - name = `${decision} in updated response from ${formattedParent}` + name = activity.consent.updated(reply) } this.reply_uuids.push(reply.uuid) @@ -663,22 +699,56 @@ export class Patient extends Child { recordVaccination(vaccination) { this.vaccination_uuids.push(vaccination.uuid) - let name - if (vaccination.given) { - name = vaccination.updatedAt - ? `Vaccination record for ${vaccination.formatted.vaccine_snomed} updated` - : `Vaccinated with ${vaccination.formatted.vaccine_snomed}` - } else { - name = `Unable to vaccinate: ${vaccination.outcome}` - } - this.addEvent({ - name, + name: activity.vaccination.recorded(vaccination), note: vaccination.note, createdAt: vaccination.updatedAt || vaccination.createdAt, createdBy_uid: vaccination.createdBy_uid, programme_ids: [vaccination.programme_id] }) + + let messageTemplate + switch (vaccination.outcome) { + case VaccinationOutcome.Vaccinated: + case VaccinationOutcome.PartVaccinated: + messageTemplate = 'vaccination-given' + break + case VaccinationOutcome.AlreadyVaccinated: + messageTemplate = 'vaccination-already-had' + break + case VaccinationOutcome.Absent: + case VaccinationOutcome.DoNotVaccinate: + case VaccinationOutcome.Refused: + case VaccinationOutcome.Unwell: + messageTemplate = 'vaccination-not-administered' + break + default: + messageTemplate = 'vaccination-deleted' + } + + for (const parent of this.parents) { + this.addEvent({ + name: activity.notify['vaccination-reminder'](parent), + messageRecipient: parent, + messageTemplate: 'vaccination-reminder', + createdAt: removeDays(vaccination.createdAt, 7), + patient_uuid: this.uuid, + programme_ids: [vaccination.programme_id], + session_id: vaccination.session.id, + vaccination_uuid: vaccination.uuid + }) + + this.addEvent({ + name: activity.notify[messageTemplate](parent), + messageRecipient: parent, + messageTemplate, + createdAt: vaccination.updatedAt || vaccination.createdAt, + patient_uuid: this.uuid, + programme_ids: [vaccination.programme_id], + session_id: vaccination.session.id, + vaccination_uuid: vaccination.uuid + }) + } } /** @@ -688,9 +758,9 @@ export class Patient extends Child { */ saveNote(event) { this.addEvent({ - type: AuditEventType.RecordNote, - name: `${AuditEventType.RecordNote} added`, + name: activity.note, note: event.note, + type: AuditEventType.Record, createdBy_uid: event.createdBy_uid }) } diff --git a/app/models/reply.js b/app/models/reply.js index 144d2f5c9..b54bd4639 100644 --- a/app/models/reply.js +++ b/app/models/reply.js @@ -447,9 +447,9 @@ export class Reply { * @static */ static findAll(context) { - return Object.values(context.replies) - .map((reply) => new Reply(reply, context)) - .filter((reply) => !reply?.patient_uuid) + return Object.values(context.replies).map( + (reply) => new Reply(reply, context) + ) } /** diff --git a/app/routes.js b/app/routes.js index 34c1cb2a6..d6c706e8e 100644 --- a/app/routes.js +++ b/app/routes.js @@ -12,6 +12,7 @@ import { referrer } from './middleware/referrer.js' 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 { batchRoutes } from './routes/batch.js' import { clinicRoutes } from './routes/clinic.js' import { consentRoutes } from './routes/consent.js' @@ -44,6 +45,7 @@ router.use(referrer) router.use('/', homeRoutes) router.use('/account', accountRoutes) +router.use('/activity', activityRoutes) router.use('/consents', consentRoutes) router.use('/give-or-refuse-consent', parentRoutes) router.use('/moves', moveRoutes) diff --git a/app/routes/activity.js b/app/routes/activity.js new file mode 100644 index 000000000..15505c74c --- /dev/null +++ b/app/routes/activity.js @@ -0,0 +1,9 @@ +import express from 'express' + +import { activityController as activity } from '../controllers/activity.js' + +const router = express.Router({ strict: true }) + +router.get('/', activity.list) + +export const activityRoutes = router diff --git a/app/views/_layouts/default.njk b/app/views/_layouts/default.njk index 5df220f82..152f7d682 100644 --- a/app/views/_layouts/default.njk +++ b/app/views/_layouts/default.njk @@ -123,6 +123,9 @@ items: [{ text: "Homepage", href: "/" + }, { + text: "Activity log items", + href: "/activity" }, { text: "CIS2 users", href: "/users" diff --git a/app/views/_macros/event.njk b/app/views/_macros/event.njk index 9da6fbd19..b1e551f33 100644 --- a/app/views/_macros/event.njk +++ b/app/views/_macros/event.njk @@ -17,6 +17,6 @@ {% if params.auditEvent.formatted.programmes and params.showProgrammes %} {{ params.auditEvent.formatted.programmes | safe }}  {% endif %} - {{ params.auditEvent.formatted.createdAtAndBy | safe }} + {{ params.auditEvent.description | safe }}

{%- endmacro %} diff --git a/app/views/_macros/timeline.njk b/app/views/_macros/timeline.njk index b14fda61f..1309b2920 100644 --- a/app/views/_macros/timeline.njk +++ b/app/views/_macros/timeline.njk @@ -1,3 +1,4 @@ +{%- from "nhsuk/components/details/macro.njk" import details -%} {% macro _timelineItem(params, item) %} {% set headingLevel = params.headingLevel if params.headingLevel else 3 %} {% set headingBold = "nhsuk-u-font-weight-bold" if item.active else "" %} @@ -23,6 +24,13 @@ {% elif item.text %}

{{ item.text }}

{% endif %} + {% for detail in item.details %} + {{ details({ + classes: detail.classes, + summaryText: detail.summaryText, + html: detail.html | safe | trim + }) }} + {% endfor %} {% endmacro %} diff --git a/app/views/activity/list.njk b/app/views/activity/list.njk new file mode 100644 index 000000000..e61425a04 --- /dev/null +++ b/app/views/activity/list.njk @@ -0,0 +1,24 @@ +{% extends "_layouts/default.njk" %} + +{% set title = "Activity log items" %} + +{% block content %} +
+
+ {{ appHeading({ + size: "xl", + title: title + }) }} + + {% for group in activityLog %} + {{ appHeading({ + title: group.title + }) }} + + {{ appTimeline({ + items: timelineItems(group.items) + }) }} + {% endfor %} +
+
+{% endblock %} diff --git a/lib/create-data.js b/lib/create-data.js index 04cfcbf2c..91aad7b82 100644 --- a/lib/create-data.js +++ b/lib/create-data.js @@ -233,10 +233,11 @@ for (let session of Object.values(context.sessions)) { ) // Add patient to session - patient.addToSession(patientSession) + patient.addToSession(patientSession.session) + + // 2️⃣🅰️ REQUEST CONSENT + patient.requestConsent(patientSession) - // 2️⃣🅰️ INVITE parent to give consent - patient.inviteToSession(session) context.patientSessions[patientSession.uuid] = patientSession } } @@ -263,10 +264,11 @@ for (let session of Object.values(context.sessions)) { ) // Add patient to session - patient.addToSession(patientSession) + patient.addToSession(patientSession.session) + + // 2️⃣🅱️ INVITE home-schooled/school unknown patient to clinic + patient.requestConsent(patientSession) - // 2️⃣🅱️ INVITE home-schooled/school unknown patient to clinic session - patient.inviteToSession(session) context.patientSessions[patientSession.uuid] = patientSession } } @@ -381,7 +383,6 @@ for (const patientSession of Object.values(context.patientSessions)) { // 4️⃣ SCREEN with triage outcome (initial) patientSession.recordTriage({ outcome, - name: `Triaged decision: ${outcome}`, note, createdAt: response.createdAt, createdBy_uid: nurse.uid @@ -416,14 +417,10 @@ for (const patientSession of Object.values(context.patientSessions)) { if (session.isCompleted) { // Ensure any outstanding triage has been completed if (patientSession.screen === ScreenOutcome.NeedsTriage) { - const outcome = ScreenOutcome.Vaccinate - const note = 'Spoke to GP, safe to vaccinate.' - // 4️⃣ SCREEN with triage outcome (final) patientSession.recordTriage({ - outcome, - name: `Triaged decision: ${outcome}`, - note, + outcome: ScreenOutcome.Vaccinate, + note: 'Spoke to GP, safe to vaccinate.', createdAt: removeDays(session.date, 2), createdBy_uid: nurse.uid }) @@ -483,7 +480,7 @@ for (const programme of Object.values(context.programmes)) { const programmeClinicSession = Object.values(context.sessions) .filter(({ programme_ids }) => programme_ids.includes(programme.id)) - .filter(({ type }) => type === SessionType.Clinic) + .find(({ type }) => type === SessionType.Clinic) // Move patients without outcome in a completed school session to a clinic for (const session of programmeSchoolSessions) { @@ -498,12 +495,11 @@ for (const programme of Object.values(context.programmes)) { for (let patient of sessionPatients) { patient = new Patient(patient, context) - // Add patient to session + // Add patient to community clinic patient.addToSession(programmeClinicSession) - // 2️⃣ INVITE patient to community clinic - // TODO: Requires support for multiple patient sessions - patient.inviteToSession(programmeClinicSession) + // 2️⃣ INVITE TO BOOK CLINIC APPOINTMENT + patient.inviteToClinic(programmeClinicSession) } } }