242 lines
10 KiB
JavaScript
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"`);
|
|
},
|
|
});
|
|
}
|