diff --git a/app/controllers/pds-record.js b/app/controllers/pds-record.js new file mode 100644 index 000000000..9d37cf104 --- /dev/null +++ b/app/controllers/pds-record.js @@ -0,0 +1,162 @@ +import wizard from '@x-govuk/govuk-prototype-wizard' +import _ from 'lodash' + +import { generateChild } from '../generators/child.js' +import { Patient, PDSRecord } from '../models.js' +import { getResults, getPagination } from '../utils/pagination.js' + +export const pdsRecordController = { + redirect(request, response) { + response.redirect('/patients') + }, + + start(request, response) { + const { data } = request.session + + if (request.body.nhsn) { + const child = generateChild() + const pdsRecord = new PDSRecord({ ...child }, data) + + // Add entered NHS number + pdsRecord.nhsn = request.body.nhsn.replaceAll(' ', '') + + // Add PDS record to wizard data + PDSRecord.create(pdsRecord, data.wizard) + + response.redirect(`/pds/${pdsRecord.uuid}/new/result`) + } else { + response.redirect(`/pds/new/search`) + } + }, + + read(request, response, next, pdsRecord_uuid) { + const { data } = request.session + + response.locals.pdsRecord = PDSRecord.findOne(pdsRecord_uuid, data) + + next() + }, + + update(request, response) { + const { pdsRecord_uuid } = request.params + const { data } = request.session + const { __ } = response.locals + + // Update session data + const pdsRecord = PDSRecord.update( + pdsRecord_uuid, + data.wizard.pdsRecords[pdsRecord_uuid], + data.wizard + ) + + // Create patient record + let patient = new Patient(pdsRecord, data) + patient = Patient.create(patient, data) + + // Clean up session data + delete data.hasNhsNumber + delete data.nhs + delete data.school_id + delete data.pdsRecord + delete data.wizard + + request.flash('success', __(`pdsRecord.new.success`, { patient })) + + response.redirect(patient.uri) + }, + + readAll(request, response, next) { + const { q } = request.query + const { data } = request.session + + const pdsRecords = PDSRecord.findAll(data) + + // Sort + let results = _.sortBy(pdsRecords, 'lastName') + + // Query + if (q) { + results = results.filter((pdsRecord) => + pdsRecord.tokenized.includes(String(q).toLowerCase()) + ) + } + + // Results + response.locals.pdsRecords = pdsRecords + response.locals.results = getResults(results, request.query) + response.locals.pages = getPagination(results, request.query) + + // Clean up session data + delete data.q + + next() + }, + + readForm(request, response, next) { + const { pdsRecord_uuid } = request.params + const { data, referrer } = request.session + + // Setup wizard if not already setup + let pdsRecord = PDSRecord.findOne(pdsRecord_uuid, data.wizard) + if (!pdsRecord) { + pdsRecord = PDSRecord.create(response.locals.pdsRecord, data.wizard) + } + response.locals.pdsRecord = new PDSRecord(pdsRecord, data) + + const journey = { + ['/']: {}, + ['/new/start']: { + [`/${pdsRecord_uuid}/new/result`]: { + data: 'hasNhsNumber', + value: 'true' + }, + ['/new/search']: { + data: 'hasNhsNumber', + value: 'false' + } + }, + ['/new/search']: {}, + ['/new/results']: {}, + [`/${pdsRecord_uuid}/new/result`]: { + [`/${pdsRecord_uuid}/new/school`]: { + data: 'add', + value: 'true' + }, + ['/new/search']: { + data: 'add', + value: 'false' + } + }, + [`/${pdsRecord_uuid}/new/school`]: {} + } + + response.locals.paths = { + ...wizard(journey, request), + ...(referrer && { back: referrer }) + } + + next() + }, + + showForm(request, response) { + let { view } = request.params + + response.render(`pds/form/${view}`) + }, + + updateForm(request, response, next) { + const { pdsRecord_uuid } = request.params + const { data } = request.session + const { paths } = response.locals + + if (request.body.school_id && !request.body.pdsRecord?.school_id) { + request.body.pdsRecord = { + school_id: request.body.school_id + } + } + + PDSRecord.update(pdsRecord_uuid, request.body.pdsRecord, data.wizard) + + return paths?.next ? response.redirect(paths.next) : next() + } +} diff --git a/app/data.js b/app/data.js index 1b64ad7a7..74103569a 100644 --- a/app/data.js +++ b/app/data.js @@ -8,6 +8,7 @@ import moves from '../.data/moves.json' with { type: 'json' } import notices from '../.data/notices.json' with { type: 'json' } import patients from '../.data/patients.json' with { type: 'json' } import patientSessions from '../.data/patient-sessions.json' with { type: 'json' } +import pdsRecords from '../.data/pds-records.json' with { type: 'json' } import programmes from '../.data/programmes.json' with { type: 'json' } import replies from '../.data/replies.json' with { type: 'json' } import schools from '../.data/schools.json' with { type: 'json' } @@ -44,6 +45,7 @@ const data = { notices, patients, patientSessions, + pdsRecords, programmes, replies, schools, diff --git a/app/generators/pds-record.js b/app/generators/pds-record.js new file mode 100644 index 000000000..9df0d6f88 --- /dev/null +++ b/app/generators/pds-record.js @@ -0,0 +1,40 @@ +import { fakerEN_GB as faker } from '@faker-js/faker' + +import { PDSRecord } from '../models.js' + +import { generateChild } from './child.js' +import { generateParent } from './parent.js' + +/** + * Generate fake PDS record + * + * @returns {PDSRecord} PDS record + */ +export function generatePDSRecord() { + const child = generateChild() + + // Parents + const parent1 = generateParent(child.lastName, true) + + // PDS records provide only a subset of parent data + delete parent1.sms + delete parent1.contactPreference + delete parent1.contactPreferenceDetails + + let parent2 + const addSecondParent = faker.datatype.boolean(0.5) + if (addSecondParent) { + parent2 = generateParent(child.lastName) + + // PDS records provide only a subset of parent data + delete parent2.sms + delete parent2.contactPreference + delete parent2.contactPreferenceDetails + } + + return new PDSRecord({ + ...child, + parent1, + parent2 + }) +} diff --git a/app/locales/en.js b/app/locales/en.js index ad7984b8a..9a09bd0ea 100644 --- a/app/locales/en.js +++ b/app/locales/en.js @@ -1637,6 +1637,97 @@ export const en = { } } }, + pdsRecord: { + label: 'Child record', + new: { + success: + '{{patient.fullName}} has been added to your list of children in Mavis' + }, + start: { + label: 'Add a new child', + title: 'Do you have the child’s NHS number?' + }, + search: { + title: 'Search for a child' + }, + results: + '{count, plural, =0 {No children matching your search criteria were found} one {Showing {from} to {to} of {count} record} other {Showing {from} to {to} of {count} children}}', + result: { + title: 'Check and confirm %s’s details' + }, + school: { + title: 'Do you know which school %s goes to?', + yes: { + label: 'Yes' + }, + unknown: { + label: 'No' + }, + 'home-educated': { + label: 'They are home-educated' + } + }, + school_id: { + label: 'School URN', + title: 'Select a school' + }, + nhsn: { + label: 'NHS number', + title: 'Enter the child’s NHS number', + hint: 'For example, 485 777 3456' + }, + fullName: { + label: 'Full name' + }, + firstName: { + label: 'First name' + }, + lastName: { + label: 'Last name' + }, + dob: { + label: 'Date of birth', + hint: 'For example, 27 3 2012' + }, + dobWithAge: { + label: 'Date of birth' + }, + dod: { + label: 'Date of death' + }, + gender: { + label: 'Gender' + }, + adjustments: { + label: 'Reasonable adjustments' + }, + impairments: { + label: 'Impairments' + }, + address: { + label: 'Address', + title: 'What is the child’s home address?' + }, + postalCode: { + label: 'Postcode' + }, + gpSurgery: { + label: 'GP surgery', + title: 'Who is the child’s GP?' + }, + parents: { + label: 'Parents or guardians' + }, + add: { + label: 'Do you want to add this child?', + yes: { + label: 'Yes' + }, + no: { + label: 'No – search for another child' + } + } + }, programme: { label: 'Programme', list: { diff --git a/app/models.js b/app/models.js index ae04ea91a..5e8cd0096 100644 --- a/app/models.js +++ b/app/models.js @@ -21,6 +21,7 @@ export * from './models/parent.js' export * from './models/patient-programme.js' export * from './models/patient-session.js' export * from './models/patient.js' +export * from './models/pds-record.js' export * from './models/programme.js' export * from './models/school.js' export * from './models/session.js' diff --git a/app/models/patient.js b/app/models/patient.js index cf4e233ca..8f2a3dbf4 100644 --- a/app/models/patient.js +++ b/app/models/patient.js @@ -40,10 +40,10 @@ import { * @augments Child * @param {object} options - Options * @param {object} [context] - Global context - * @property {string} uuid - UUID - * @property {string} nhsn - NHS number - * @property {boolean} invalid - Flagged as invalid - * @property {boolean} sensitive - Flagged as sensitive + * @property {string} [uuid] - UUID + * @property {string} [nhsn] - NHS number + * @property {boolean} [invalid] - Flagged as invalid + * @property {boolean} [sensitive] - Flagged as sensitive * @property {object} [address] - Address * @property {Parent} [parent1] - Parent 1 * @property {Parent} [parent2] - Parent 2 diff --git a/app/models/pds-record.js b/app/models/pds-record.js new file mode 100644 index 000000000..24df875d0 --- /dev/null +++ b/app/models/pds-record.js @@ -0,0 +1,211 @@ +import { fakerEN_GB as faker } from '@faker-js/faker' +import _ from 'lodash' + +import { Child, Parent } from '../models.js' +import { tokenize } from '../utils/object.js' +import { + formatList, + formatNhsNumber, + formatParent, + formatWithSecondaryText, + stringToBoolean +} from '../utils/string.js' + +/** + * @class PDS record + * @augments Child + * @param {object} options - Options + * @param {object} [context] - Global context + * @property {string} [uuid] - UUID + * @property {string} [nhsn] - NHS number + * @property {boolean} [invalid] - Flagged as invalid + * @property {boolean} [sensitive] - Flagged as sensitive + * @property {object} [address] - Address + * @property {Parent} [parent1] - Parent 1 + * @property {Parent} [parent2] - Parent 2 + */ +export class PDSRecord extends Child { + constructor(options, context) { + super(options, context) + + const invalid = stringToBoolean(options?.invalid) + const sensitive = stringToBoolean(options?.sensitive) + + this.uuid = options?.uuid || faker.string.uuid() + this.nhsn = + options?.nhsn || + '999#######'.replace(/#+/g, (m) => faker.string.numeric(m.length)) + this.invalid = invalid + this.sensitive = sensitive + this.address = !sensitive && options?.address ? options.address : undefined + this.parent1 = + !sensitive && options?.parent1 ? new Parent(options.parent1) : undefined + this.parent2 = + !sensitive && options?.parent2 ? new Parent(options.parent2) : undefined + this.school_id = null + } + + /** + * Has no parental contact details + * + * @returns {boolean} Has no parental details + */ + get hasNoContactDetails() { + return ( + !this.parent1?.email && + !this.parent1?.tel && + !this.parent2?.email && + !this.parent2?.tel + ) + } + + /** + * Get full name, formatted as LASTNAME, Firstname + * + * @returns {string} Full name + */ + get fullName() { + return [this.lastName.toUpperCase(), this.firstName].join(', ') + } + + /** + * Get parents (from record and replies) + * + * @returns {Array} Parents + */ + get parents() { + const parents = new Map() + + if (this.parent1) { + parents.set(this.parent1.uuid, new Parent(this.parent1)) + } + + if (this.parent2) { + parents.set(this.parent2.uuid, new Parent(this.parent2)) + } + + return [...parents.values()] + } + + /** + * Get tokenised values (to use in search queries) + * + * @returns {string} Tokens + */ + get tokenized() { + const parentTokens = [] + for (const parent of this.parents) { + parentTokens.push(tokenize(parent, ['fullName', 'tel', 'email'])) + } + + const childTokens = tokenize(this, ['nhsn', 'fullName', 'postalCode']) + + return [childTokens, parentTokens].join(' ') + } + + /** + * Get formatted values + * + * @returns {object} Formatted values + */ + get formatted() { + const formattedNhsn = formatNhsNumber(this.nhsn, this.invalid) + const formattedParents = this.parents.map((parent) => formatParent(parent)) + + return { + ...super.formatted, + fullNameAndNhsn: formatWithSecondaryText(this.fullName, formattedNhsn), + nhsn: formattedNhsn, + parent1: this.parent1 && formatParent(this.parent1), + parent2: this.parent2 && formatParent(this.parent2), + parents: formatList(formattedParents) + } + } + + /** + * Get namespace + * + * @returns {string} Namespace + */ + get ns() { + return 'pdsRecord' + } + + /** + * Get URI + * + * @returns {string} URI + */ + get uri() { + return `/pds/${this.uuid}/new/result` + } + + /** + * Find all + * + * @param {object} context - Context + * @returns {Array|undefined} PDS records + * @static + */ + static findAll(context) { + return Object.values(context.pdsRecords).map( + (pdsRecord) => new PDSRecord(pdsRecord, context) + ) + } + + /** + * Find one + * + * @param {string} uuid - PDS record UUID + * @param {object} context - Context + * @returns {PDSRecord|undefined} PDS record + * @static + */ + static findOne(uuid, context) { + if (context?.pdsRecords?.[uuid]) { + return new PDSRecord(context.pdsRecords[uuid], context) + } + } + + /** + * Create + * + * @param {PDSRecord} pdsRecord - PDS record + * @param {object} context - Context + * @returns {PDSRecord} Created PDS record + * @static + */ + static create(pdsRecord, context) { + const createdRecord = new PDSRecord(pdsRecord) + + // Update context + context.pdsRecords = context.pdsRecords || {} + context.pdsRecords[createdRecord.uuid] = createdRecord + + return createdRecord + } + + /** + * Update + * + * @param {string} uuid - PDS record UUID + * @param {object} updates - Updates + * @param {object} context - Context + * @returns {PDSRecord} Updated PDS record + * @static + */ + static update(uuid, updates, context) { + const updatedPdsRecord = _.merge(PDSRecord.findOne(uuid, context), updates) + + // Remove patient context + delete updatedPdsRecord.context + + // Delete original PDS record (with previous UUID) + delete context.pdsRecords[uuid] + + // Update context + context.pdsRecords[updatedPdsRecord.uuid] = updatedPdsRecord + + return updatedPdsRecord + } +} diff --git a/app/routes.js b/app/routes.js index d2d0e5b47..04b35890a 100644 --- a/app/routes.js +++ b/app/routes.js @@ -28,6 +28,7 @@ import { noticeRoutes } from './routes/notice.js' import { parentRoutes } from './routes/parent.js' import { patientSessionRoutes } from './routes/patient-session.js' import { patientRoutes } from './routes/patient.js' +import { pdsRecordRoutes } from './routes/pds-record.js' import { programmeRoutes } from './routes/programme.js' import { replyRoutes } from './routes/reply.js' import { schoolRoutes } from './routes/school.js' @@ -62,6 +63,7 @@ router.use('/notices', noticeRoutes) router.use('/teams', teamRoutes) router.use('/teams/:team_id/clinics', clinicRoutes) router.use('/patients', patientRoutes) +router.use('/pds', pdsRecordRoutes) router.use('/reports/download', downloadRoutes) router.use('/reports', programmeRoutes) router.use('/reports/:programme_id/vaccinations', vaccinationRoutes) diff --git a/app/routes/pds-record.js b/app/routes/pds-record.js new file mode 100644 index 000000000..ca0910195 --- /dev/null +++ b/app/routes/pds-record.js @@ -0,0 +1,24 @@ +import express from 'express' + +import { pdsRecordController as pdsRecord } from '../controllers/pds-record.js' + +const router = express.Router({ strict: true, mergeParams: true }) + +router.get('/', pdsRecord.redirect) + +router.param('pdsRecord_uuid', pdsRecord.read) + +router.get('/new/results', pdsRecord.readAll) + +router.post('/new/start', pdsRecord.start) +router.post( + '/:pdsRecord_uuid/new/school', + pdsRecord.updateForm, + pdsRecord.update +) + +router.all(['/new/:view', '/:pdsRecord_uuid/new/:view'], pdsRecord.readForm) +router.get(['/new/:view', '/:pdsRecord_uuid/new/:view'], pdsRecord.showForm) +router.post(['/new/:view', '/:pdsRecord_uuid/new/:view'], pdsRecord.updateForm) + +export const pdsRecordRoutes = router diff --git a/app/views/patient/list.njk b/app/views/patient/list.njk index f3b4a8041..44f4d2cef 100644 --- a/app/views/patient/list.njk +++ b/app/views/patient/list.njk @@ -13,6 +13,11 @@ title: title }) }} + {{ actionLink({ + text: __("pdsRecord.start.label"), + href: "/pds/new/start" + }) }} +
{{ appPatientSearch({ diff --git a/app/views/pds/form/result.njk b/app/views/pds/form/result.njk new file mode 100644 index 000000000..ffaf0546e --- /dev/null +++ b/app/views/pds/form/result.njk @@ -0,0 +1,46 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("pdsRecord.result.title", pdsRecord.fullName) %} + +{% block form %} + {{ appHeading({ + title: title + }) }} + + {{ summaryList({ + card: { + heading: __("pdsRecord.label"), + headingSize: "m" + }, + rows: summaryRows(pdsRecord, { + nhsn: {}, + fullName: {}, + preferredNames: {}, + dob: { value: patient.dobWithAge }, + dod: {}, + gender: {}, + impairments: {}, + adjustments: {}, + address: {}, + gpSurgery: {}, + parents: {} + }) + }) }} + + {{ radios({ + fieldset: { + legend: { + text: __("pdsRecord.add.label"), + size: "m" + } + }, + items: [{ + text: __("pdsRecord.add.yes.label"), + value: true + }, { + text: __("pdsRecord.add.no.label"), + value: false + }], + decorate: "add" + }) }} +{% endblock %} diff --git a/app/views/pds/form/results.njk b/app/views/pds/form/results.njk new file mode 100644 index 000000000..46caa026b --- /dev/null +++ b/app/views/pds/form/results.njk @@ -0,0 +1,38 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("search.results") %} +{% set hideConfirmButton = true %} + +{% block form %} + {{ appHeading({ + title: title, + summary: __mf("pdsRecord.results", { + from: results.from, + to: results.to, + count: results.count + }) | safe + }) }} + + {% for pdsRecord in results.page %} + {{ summaryList({ + card: { + classes: "app-card--compact", + heading: pdsRecord.fullAndPreferredNames | highlightQuery(data.q), + headingSize: "s", + headingLevel: 4, + href: pdsRecord.uri, + clickable: true + }, + rows: summaryRows(pdsRecord, { + nhsn: { + value: pdsRecord.formatted.nhsn | highlightQuery(data.q) + }, + dob: {}, + gender: {}, + address: {} + }) + }) }} + {% endfor %} + + {{ pagination(pages) }} +{% endblock %} diff --git a/app/views/pds/form/school.njk b/app/views/pds/form/school.njk new file mode 100644 index 000000000..bfa18ddab --- /dev/null +++ b/app/views/pds/form/school.njk @@ -0,0 +1,37 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("pdsRecord.school.title", pdsRecord.firstName) %} + +{% block form %} + {{ radios({ + fieldset: { + legend: { + html: appHeading({ + caption: pdsRecord.fullName, + title: title + }) + } + }, + items: [{ + text: __("pdsRecord.school.yes.label"), + value: "urn", + conditional: { + html: select({ + label: { text: __("pdsRecord.school_id.title") }, + items: locationItems(data.schools, pdsRecord.urn), + decorate: "pdsRecord.school_id", + attributes: { + "data-module": "app-autocomplete" + } + }) + } + }, { + text: __("pdsRecord.school.unknown.label"), + value: "888888" + }, { + text: __("pdsRecord.school.home-educated.label"), + value: "999999" + }], + decorate: "school_id" + }) }} +{% endblock %} diff --git a/app/views/pds/form/search.njk b/app/views/pds/form/search.njk new file mode 100644 index 000000000..baaa7a0bd --- /dev/null +++ b/app/views/pds/form/search.njk @@ -0,0 +1,34 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("pdsRecord.search.title") %} + +{% block form %} + {{ appHeading({ + title: title + }) }} + + {{ input({ + label: { text: __("pdsRecord.firstName.label") }, + decorate: "firstName" + }) }} + + {{ input({ + label: { text: __("pdsRecord.lastName.label") }, + decorate: "lastName" + }) }} + + {{ dateInput({ + fieldset: { + legend: { text: __("pdsRecord.dob.label") } + }, + hint: { text: __("pdsRecord.dob.hint") }, + decorate: "dob_" + }) }} + + {{ input({ + label: { text: __("location.postalCode.label") }, + autocomplete: "postal-code", + width: 10, + decorate: "postalCode" + }) }} +{% endblock %} diff --git a/app/views/pds/form/start.njk b/app/views/pds/form/start.njk new file mode 100644 index 000000000..f19dcb94b --- /dev/null +++ b/app/views/pds/form/start.njk @@ -0,0 +1,31 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("pdsRecord.start.title") %} + +{% block form %} + {{ radios({ + fieldset: { + legend: { + text: title, + size: "l" + } + }, + items: [{ + text: "Yes", + value: true, + conditional: { + html: input({ + label: { text: __("pdsRecord.nhsn.title") }, + hint: { text: __("pdsRecord.nhsn.hint") }, + code: true, + width: 10, + name: "nhsn" + }) + } + }, { + text: "No", + value: false + }], + decorate: "hasNhsNumber" + }) }} +{% endblock %} diff --git a/lib/create-data.js b/lib/create-data.js index ad257443e..bbe47ae18 100644 --- a/lib/create-data.js +++ b/lib/create-data.js @@ -37,6 +37,7 @@ import { generateInstruction } from '../app/generators/instruction.js' import { generateNotice } from '../app/generators/notice.js' import { generateParent } from '../app/generators/parent.js' import { generatePatient } from '../app/generators/patient.js' +import { generatePDSRecord } from '../app/generators/pds-record.js' import { generateSession } from '../app/generators/session.js' import { generateTeam } from '../app/generators/team.js' import { generateUpload } from '../app/generators/upload.js' @@ -136,6 +137,13 @@ Array.from([...range(0, totalPatients)]).forEach(() => { context.patients[patient.uuid] = patient }) +// PDS records +context.pdsRecords = {} +Array.from([...range(0, 20)]).forEach(() => { + const pdsRecord = generatePDSRecord() + context.pdsRecords[pdsRecord.uuid] = pdsRecord +}) + // Programmes context.programmes = {} for (const programme of Object.values(programmesData)) { @@ -709,6 +717,7 @@ generateDataFile('.data/moves.json', context.moves) generateDataFile('.data/notices.json', context.notices) generateDataFile('.data/patients.json', context.patients) generateDataFile('.data/patient-sessions.json', context.patientSessions) +generateDataFile('.data/pds-records.json', context.pdsRecords) generateDataFile('.data/programmes.json', context.programmes) generateDataFile('.data/replies.json', context.replies) generateDataFile('.data/schools.json', context.schools)