VirtualPR/backend/src/routes/reports.js

242 lines
10 KiB
JavaScript

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