VirtualPR/backend/src/routes/patients.js

215 lines
7.9 KiB
JavaScript

import Joi from 'joi';
import Boom from '@hapi/boom';
import { requirePermission, credentials, patientScopeFilter } from '../plugins/auth.js';
import { Patient } from '../models/Patient.js';
import { Session } from '../models/Session.js';
import { User } from '../models/User.js';
import { toPatientDto, toSessionDto } from '../utils/serialize.js';
import { isMobileNumber } from '../services/cryptoPii.js';
import { importWorkbook, payloadToBuffer } from '../services/importPatients.js';
const objectId = Joi.string().hex().length(24);
const patientPayload = Joi.object({
sourcePatientId: Joi.string().trim(),
name: Joi.string().trim().min(1).required(),
mobile: Joi.string().allow('', null),
age: Joi.number().allow(null),
gender: Joi.string().allow('', null),
lungCondition: Joi.string().allow('', null),
otherHealthCondition: Joi.string().allow('', null),
language: Joi.string().allow('', null),
sessionLanguage: Joi.string().allow('', null),
consentType: Joi.string().allow('', null),
isNewOrOld: Joi.string().allow('', null),
doctor: Joi.object({
sourceId: Joi.string().allow('', null),
name: Joi.string().allow('', null),
uin: Joi.string().allow('', null),
}),
assignedEducator: objectId.allow(null, ''),
});
function applyPatientFields(patient, body, canAssign) {
patient.name = body.name;
if (body.mobile && !isMobileNumber(body.mobile)) throw Boom.badRequest('Mobile must be 10 digits.');
patient.mobile = body.mobile ? String(body.mobile).trim() : '';
patient.age = body.age ?? null;
patient.gender = body.gender || '';
patient.lungCondition = body.lungCondition || '';
patient.otherHealthCondition = body.otherHealthCondition || '';
patient.sessionLanguage = body.sessionLanguage || body.language || '';
patient.consentType = body.consentType || '';
patient.isNewOrOld = body.isNewOrOld || '';
if (body.doctor) {
patient.doctor = {
sourceId: body.doctor.sourceId || '',
name: body.doctor.name || '',
uin: body.doctor.uin || '',
};
}
if (canAssign && 'assignedEducator' in body) {
patient.assignedEducator = body.assignedEducator || null;
}
}
export function patientRoutes(server) {
server.route({
method: 'GET',
path: '/api/patients',
options: { pre: [{ method: requirePermission('patients', 'view') }] },
handler: async (request) => {
const {
search = '',
gender,
lungCondition,
educator,
unassigned,
page = 1,
limit = 20,
} = request.query;
const filter = { ...patientScopeFilter(request) };
if (search) {
const rx = new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
filter.$or = [{ name: rx }, { mobile: rx }, { sourcePatientId: rx }];
}
if (gender) filter.gender = gender;
if (lungCondition) filter.lungCondition = lungCondition;
if (educator) filter.assignedEducator = educator;
if (unassigned === 'true' || unassigned === true) filter.assignedEducator = null;
const skip = (Number(page) - 1) * Number(limit);
const [items, total] = await Promise.all([
Patient.find(filter).populate('assignedEducator', 'firstName lastName email').sort({ lastVisitDate: -1, createdAt: -1 }).skip(skip).limit(Number(limit)),
Patient.countDocuments(filter),
]);
return { items: items.map(toPatientDto), total, page: Number(page), limit: Number(limit) };
},
});
server.route({
method: 'GET',
path: '/api/patients/filters',
options: { pre: [{ method: requirePermission('patients', 'view') }] },
handler: async (request) => {
const scope = patientScopeFilter(request);
const [genders, conditions] = await Promise.all([
Patient.distinct('gender', scope),
Patient.distinct('lungCondition', scope),
]);
return {
genders: genders.filter(Boolean),
lungConditions: conditions.filter(Boolean),
};
},
});
server.route({
method: 'POST',
path: '/api/patients/import',
options: {
pre: [{ method: requirePermission('patients', 'import') }],
payload: {
output: 'data',
parse: true,
multipart: true,
maxBytes: 25 * 1024 * 1024,
allow: 'multipart/form-data',
timeout: false,
},
},
handler: async (request) => {
const file = request.payload?.file || request.payload?.upload;
if (!file) throw Boom.badRequest('Upload an .xlsx file in the "file" field');
const buffer = await payloadToBuffer(file);
if (!buffer?.length) throw Boom.badRequest('Empty file');
const result = await importWorkbook(buffer);
if (!result.ok) throw Boom.badRequest(result.error);
return result;
},
});
server.route({
method: 'GET',
path: '/api/patients/{id}',
options: {
pre: [{ method: requirePermission('patients', 'view') }],
validate: { params: Joi.object({ id: objectId.required() }) },
},
handler: async (request) => {
const patient = await Patient.findOne({ _id: request.params.id, ...patientScopeFilter(request) })
.populate('assignedEducator', 'firstName lastName email');
if (!patient) throw Boom.notFound('Patient not found');
const sessions = await Session.find({ patient: patient._id }).sort({ visitDate: -1, createdAt: -1 });
return { ...toPatientDto(patient), sessions: sessions.map(toSessionDto) };
},
});
server.route({
method: 'POST',
path: '/api/patients',
options: {
pre: [{ method: requirePermission('patients', 'add') }],
validate: { payload: patientPayload },
},
handler: async (request) => {
const creds = credentials(request);
const body = request.payload;
const sourcePatientId = body.sourcePatientId || `MANUAL-${Date.now()}`;
if (await Patient.findOne({ sourcePatientId })) {
throw Boom.conflict('Patient ID already exists');
}
const patient = new Patient({ sourcePatientId });
const canAssign = creds.isSuperAdmin || (creds.modules?.patients || []).includes('edit');
applyPatientFields(patient, body, canAssign && creds.scopes?.patients !== 'assigned');
if (creds.scopes?.patients === 'assigned') {
patient.assignedEducator = creds.user._id;
}
await patient.save();
await patient.populate('assignedEducator', 'firstName lastName email');
return toPatientDto(patient);
},
});
server.route({
method: 'PATCH',
path: '/api/patients/{id}',
options: {
pre: [{ method: requirePermission('patients', 'edit') }],
validate: {
params: Joi.object({ id: objectId.required() }),
payload: patientPayload,
},
},
handler: async (request) => {
const creds = credentials(request);
const patient = await Patient.findOne({ _id: request.params.id, ...patientScopeFilter(request) });
if (!patient) throw Boom.notFound('Patient not found');
const canAssign = creds.isSuperAdmin || creds.scopes?.patients !== 'assigned';
applyPatientFields(patient, request.payload, canAssign);
if (request.payload.assignedEducator && canAssign) {
const educator = await User.findById(request.payload.assignedEducator).populate('role');
if (!educator) throw Boom.badRequest('Invalid PRT');
}
await patient.save();
await patient.populate('assignedEducator', 'firstName lastName email');
return toPatientDto(patient);
},
});
server.route({
method: 'DELETE',
path: '/api/patients/{id}',
options: {
pre: [{ method: requirePermission('patients', 'delete') }],
validate: { params: Joi.object({ id: objectId.required() }) },
},
handler: async (request) => {
const patient = await Patient.findOne({ _id: request.params.id, ...patientScopeFilter(request) });
if (!patient) throw Boom.notFound('Patient not found');
await Session.deleteMany({ patient: patient._id });
await patient.deleteOne();
return { ok: true };
},
});
}