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 }; }, }); }