import Joi from 'joi'; import { credentials, requirePermission } from '../plugins/auth.js'; import { Cohort } from '../models/Cohort.js'; import { CohortSession } from '../models/CohortSession.js'; import { ReassignmentEvent } from '../models/ReassignmentEvent.js'; import { Role } from '../models/Role.js'; import { User } from '../models/User.js'; import { listOnboardedForReports, patientProgramStats, prtCapacity, countLiveCohorts } from '../services/programSessions.js'; import { getAppSettings } from '../services/settings.js'; import { toYmd } from '../services/schedule.js'; import { readOnboardedPii } from '../services/cryptoPii.js'; import { writeAudit } from '../services/audit.js'; const objectId = Joi.string().hex().length(24); function scopedPrt(request) { const creds = credentials(request); if (creds.role?.systemKey === 'educator') return String(creds.user._id); return request.query.prt || undefined; } function csvEscape(value) { const text = value == null ? '' : String(value); if (/[",\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`; return text; } function toCsv(rows) { return rows.map((row) => row.map(csvEscape).join(',')).join('\n'); } // P when that patient attended the meeting, A when they did not. function sessionMark(session, patientId) { const record = (session.records || []).find((item) => String(item.patient?._id || item.patient) === patientId); return record?.attended ? 'P' : 'A'; } export function reportRoutes(server) { server.route({ method: 'GET', path: '/api/reports/patients', options: { pre: [{ method: requirePermission('reports', 'view') }], validate: { query: Joi.object({ prt: objectId, diseaseType: Joi.string().allow('', null), cohortId: objectId, from: Joi.string().pattern(/^\d{4}-\d{2}-\d{2}$/), to: Joi.string().pattern(/^\d{4}-\d{2}-\d{2}$/), }), }, }, handler: async (request) => { const patients = await listOnboardedForReports({ virtualPrtId: scopedPrt(request), diseaseType: request.query.diseaseType || undefined, cohortId: request.query.cohortId, }); const stats = await patientProgramStats(patients.map((item) => item._id)); return { items: patients.map((patient) => { const progress = stats.get(String(patient._id)) || { attended: 0, total: 0, vitals: [] }; const pii = readOnboardedPii(patient); return { id: String(patient._id), name: pii.name, mobile: pii.mobile, diseaseType: patient.diseaseType || '', slot: patient.slot || '', language: patient.sessionLanguage || '', cohort: patient.cohort ? { id: String(patient.cohort._id), name: patient.cohort.name } : null, prt: patient.assignedEducator ? { id: String(patient.assignedEducator._id), firstName: patient.assignedEducator.firstName, lastName: patient.assignedEducator.lastName } : null, attended: progress.attended, total: progress.total, completion: progress.total ? `${progress.attended}/${progress.total}` : '0/0', vitals: progress.vitals, }; }), }; }, }); server.route({ method: 'GET', path: '/api/reports/utilization', options: { pre: [{ method: requirePermission('reports', 'view') }], }, handler: async (request) => { const settings = await getAppSettings(); const role = await Role.findOne({ systemKey: 'educator' }); const prtFilter = { role: role?._id, isActive: true }; const scoped = scopedPrt(request); if (scoped) prtFilter._id = scoped; const prts = role ? await User.find(prtFilter).sort({ firstName: 1 }) : []; const cancelled = await CohortSession.countDocuments({ status: 'cancelled' }); const items = []; for (const prt of prts) { const used = await countLiveCohorts(prt._id); const max = prtCapacity(prt, settings); const sessions = await CohortSession.countDocuments({ status: { $in: ['scheduled', 'completed'] } }); const ownCohorts = await Cohort.find({ virtualPrt: prt._id }).select('_id'); const ownIds = ownCohorts.map((item) => item._id); const run = await CohortSession.countDocuments({ cohort: { $in: ownIds }, status: { $in: ['scheduled', 'completed'] } }); const cancelledOwn = await CohortSession.countDocuments({ cohort: { $in: ownIds }, status: 'cancelled' }); items.push({ id: String(prt._id), name: `${prt.firstName} ${prt.lastName}`, used, max, idle: Math.max(0, max - used), sessions: run, cancelled: cancelledOwn, }); } return { items, cancelled, maxPatientsPerCohort: settings.maxPatientsPerCohort }; }, }); server.route({ method: 'GET', path: '/api/reports/productivity', options: { pre: [{ method: requirePermission('reports', 'view') }], validate: { query: Joi.object({ prt: objectId, from: Joi.string().pattern(/^\d{4}-\d{2}-\d{2}$/), to: Joi.string().pattern(/^\d{4}-\d{2}-\d{2}$/), }), }, }, handler: async (request) => { const dateFilter = {}; if (request.query.from) dateFilter.$gte = request.query.from; if (request.query.to) dateFilter.$lte = request.query.to; const sessionFilter = { status: 'completed' }; if (Object.keys(dateFilter).length) sessionFilter.date = dateFilter; const sessions = await CohortSession.find(sessionFilter) .populate({ path: 'cohort', populate: { path: 'virtualPrt', select: 'firstName lastName' } }); const scoped = scopedPrt(request); const filtered = sessions.filter((session) => { if (!scoped) return true; return String(session.cohort?.virtualPrt?._id || session.cohort?.virtualPrt) === String(scoped); }); const reassignments = await ReassignmentEvent.find({}) .populate('cohort', 'name') .populate('fromPrt', 'firstName lastName') .populate('toPrt', 'firstName lastName') .sort({ createdAt: -1 }) .limit(50); return { sessionsRun: filtered.length, attendance: filtered.reduce((sum, session) => ( sum + (session.records || []).filter((record) => record.attended).length ), 0), possibleAttendance: filtered.reduce((sum, session) => sum + (session.records || []).length, 0), reassignments: reassignments.map((event) => ({ id: String(event._id), reason: event.reason, status: event.status, date: toYmd(event.createdAt), cohort: event.cohort?.name || '', from: event.fromPrt ? `${event.fromPrt.firstName} ${event.fromPrt.lastName}` : '', to: event.toPrt ? `${event.toPrt.firstName} ${event.toPrt.lastName}` : '', })), }; }, }); server.route({ method: 'GET', path: '/api/reports/export', options: { pre: [{ method: requirePermission('reports', 'view') }], }, handler: async (request, h) => { await writeAudit(request, { action: 'report.export', resourceType: 'report', resourceId: request.query.type || 'patients' }); const type = String(request.query.type || 'patients'); let rows = []; if (type === 'utilization') { const settings = await getAppSettings(); const role = await Role.findOne({ systemKey: 'educator' }); const prtFilter = { role: role?._id, isActive: true }; const scoped = scopedPrt(request); if (scoped) prtFilter._id = scoped; const prts = role ? await User.find(prtFilter).sort({ firstName: 1 }) : []; const items = []; for (const prt of prts) { const used = await countLiveCohorts(prt._id); const max = prtCapacity(prt, settings); const ownCohorts = await Cohort.find({ virtualPrt: prt._id }).select('_id'); const ownIds = ownCohorts.map((item) => item._id); const run = await CohortSession.countDocuments({ cohort: { $in: ownIds }, status: { $in: ['scheduled', 'completed'] } }); const cancelledOwn = await CohortSession.countDocuments({ cohort: { $in: ownIds }, status: 'cancelled' }); items.push([`${prt.firstName} ${prt.lastName}`, used, max, Math.max(0, max - used), run, cancelledOwn]); } rows = [['PRT', 'Used', 'Max', 'Idle', 'Sessions', 'Cancelled'], ...items]; } else { const patients = await listOnboardedForReports({ virtualPrtId: scopedPrt(request), diseaseType: request.query.diseaseType || undefined }); const cohortIds = [...new Set(patients.map((patient) => patient.cohort?._id).filter(Boolean))]; const sessions = cohortIds.length ? await CohortSession.find({ cohort: { $in: cohortIds }, status: 'completed' }) .select('cohort index date records.patient records.attended') .sort({ index: 1, date: 1 }) : []; const byCohort = new Map(); for (const session of sessions) { const id = String(session.cohort); const list = byCohort.get(id) || []; list.push(session); byCohort.set(id, list); } const widest = [...byCohort.values()].reduce((max, list) => Math.max(max, list.length), 0); const headers = ['Name', 'Mobile', 'Disease', 'Cohort', 'PRT', 'Start date', ...Array.from({ length: widest }, (_, index) => `Session ${index + 1}`)]; rows = [headers, ...patients.map((patient) => { const pii = readOnboardedPii(patient); const cohortId = patient.cohort?._id ? String(patient.cohort._id) : ''; const meetings = cohortId ? (byCohort.get(cohortId) || []) : []; const marks = meetings.map((session) => sessionMark(session, String(patient._id))); while (marks.length < widest) marks.push(''); // shorter cohorts leave the extra session cells blank return [ pii.name, pii.mobile, patient.diseaseType || '', patient.cohort?.name || '', patient.assignedEducator ? `${patient.assignedEducator.firstName} ${patient.assignedEducator.lastName}` : '', patient.cohort?.startDate ? toYmd(patient.cohort.startDate) : '', ...marks, ]; })]; } return h.response(toCsv(rows)) .type('text/csv') .header('content-disposition', `attachment; filename="${type}-report.csv"`); }, }); }