215 lines
7.9 KiB
JavaScript
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 };
|
|
},
|
|
});
|
|
}
|