VIrtual PR 4 Changes for move patients

This commit is contained in:
Abhijit 2026-11-07 22:09:53 +05:30
parent 2241886ca7
commit a0b2020286
13 changed files with 285 additions and 36 deletions

View File

@ -0,0 +1,28 @@
import cron from 'node-cron';
import { env } from '../config/env.js';
import { appendTodaySessions } from '../services/programSessions.js';
import { todayYmd } from '../services/schedule.js';
// Adds today's session to open cohorts whose planned sessions are used up.
export async function runCohortExtension(dateYmd) {
const date = dateYmd || todayYmd(env.APP_TIMEZONE);
const added = await appendTodaySessions(date);
console.log(`[cohort-extension] ${date}: added=${added}`);
return added;
}
export function startCohortExtensionJobs() {
cron.schedule('30 5 * * *', async () => {
try {
await runCohortExtension();
} catch (err) {
console.error('[cohort-extension] cron failed', err);
}
}, {
timezone: env.APP_TIMEZONE,
name: 'cohort-extension',
noOverlap: true,
});
console.log(`[cohort-extension] scheduled daily 05:30 in ${env.APP_TIMEZONE}`);
runCohortExtension().catch((err) => console.error('[cohort-extension] startup run failed', err));
}

View File

@ -34,6 +34,7 @@ const onboardedPatientSchema = new mongoose.Schema(
onboardedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, onboardedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
assignedEducator: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null }, assignedEducator: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null },
cohort: { type: mongoose.Schema.Types.ObjectId, ref: 'Cohort', default: null }, cohort: { type: mongoose.Schema.Types.ObjectId, ref: 'Cohort', default: null },
cohortJoinedOn: { type: String, default: '' }, // date the patient was added to the current cohort
reviewStatus: { reviewStatus: {
type: String, type: String,
enum: ['pending', 'confirmed', 'rejected'], enum: ['pending', 'confirmed', 'rejected'],

View File

@ -17,7 +17,7 @@ import { migratePatient } from '../services/matching.js';
import { notifyPaymentReview } from '../services/notify.js'; import { notifyPaymentReview } from '../services/notify.js';
import { attendanceProgress, ensureSessionRecords, patientProgramStats } from '../services/programSessions.js'; import { attendanceProgress, ensureSessionRecords, patientProgramStats } from '../services/programSessions.js';
import { assertConfiguredSlot, getAppSettings, slotLabel } from '../services/settings.js'; import { assertConfiguredSlot, getAppSettings, slotLabel } from '../services/settings.js';
import { toYmd, weekdayOf, ymdToDate } from '../services/schedule.js'; import { toYmd, weekdayOf } from '../services/schedule.js';
const objectId = Joi.string().hex().length(24); const objectId = Joi.string().hex().length(24);
function textField(payload, key) { function textField(payload, key) {
@ -208,11 +208,10 @@ function canReview(request) {
} }
async function findAssignableCohort(cohortId, currentCohortId) { async function findAssignableCohort(cohortId, currentCohortId) {
const today = ymdToDate(toYmd(new Date()));
const cohort = await Cohort.findById(cohortId); const cohort = await Cohort.findById(cohortId);
if (!cohort) throw Boom.badRequest('Select a future cohort.'); if (!cohort) throw Boom.badRequest('Select an ongoing or upcoming cohort.');
const isCurrent = currentCohortId && String(cohort._id) === String(currentCohortId); const isCurrent = currentCohortId && String(cohort._id) === String(currentCohortId);
if (!isCurrent && cohort.startDate < today) throw Boom.badRequest('Select a future cohort.'); if (!isCurrent && cohort.status === 'completed') throw Boom.badRequest('Select an ongoing or upcoming cohort.');
return cohort; return cohort;
} }
@ -287,7 +286,7 @@ export function onboardingRoutes(server) {
return { return {
items: items.map((item) => { items: items.map((item) => {
const progress = stats.get(String(item._id)) || { attended: 0, total: 0 }; const progress = stats.get(String(item._id)) || { attended: 0, total: 0 };
return { ...toOnboardedPatientDto(item), progress: { attended: progress.attended, total: progress.total } }; return { ...toOnboardedPatientDto(item), progress: { attended: progress.attended, total: progress.total, firstDate: progress.firstDate || '' } };
}), }),
total, total,
page: Number(page), page: Number(page),
@ -307,10 +306,9 @@ export function onboardingRoutes(server) {
path: '/api/onboarding/available-cohorts', path: '/api/onboarding/available-cohorts',
options: { pre: [{ method: requirePermission('onboarding', 'view') }] }, options: { pre: [{ method: requirePermission('onboarding', 'view') }] },
handler: async (request) => { handler: async (request) => {
const today = ymdToDate(toYmd(new Date()));
const language = String(request.query.language || '').trim(); const language = String(request.query.language || '').trim();
const include = String(request.query.include || '').trim(); const include = String(request.query.include || '').trim();
const filter = { startDate: { $gte: today }, status: { $nin: ['proposed', 'completed'] } }; const filter = { status: { $nin: ['proposed', 'completed'] } };
if (language) filter.language = language; if (language) filter.language = language;
const items = await Cohort.find(filter) const items = await Cohort.find(filter)
.populate('virtualPrt', 'firstName lastName email') .populate('virtualPrt', 'firstName lastName email')

View File

@ -7,6 +7,7 @@ import { toCohortSessionDto } from '../utils/serialize.js';
import { import {
generateMeetsForDate, generateMeetsForDate,
listSessions, listSessions,
patientSessionNumbers,
pushSession, pushSession,
saveSessionRecords, saveSessionRecords,
} from '../services/programSessions.js'; } from '../services/programSessions.js';
@ -104,7 +105,10 @@ export function programSessionRoutes(server) {
pre: [{ method: requirePermission('program_sessions', 'view') }], pre: [{ method: requirePermission('program_sessions', 'view') }],
validate: { params: Joi.object({ id: objectId.required() }) }, validate: { params: Joi.object({ id: objectId.required() }) },
}, },
handler: async (request) => toCohortSessionDto(await findScopedSession(request)), handler: async (request) => {
const session = await findScopedSession(request);
return toCohortSessionDto(session, await patientSessionNumbers([session]));
},
}); });
server.route({ server.route({
@ -194,7 +198,7 @@ export function programSessionRoutes(server) {
const saved = await saveSessionRecords(session, request.payload.records, { completed: request.payload.completed }); const saved = await saveSessionRecords(session, request.payload.records, { completed: request.payload.completed });
await saved.populate('records.patient', 'name mobile'); await saved.populate('records.patient', 'name mobile');
await saved.populate({ path: 'cohort', populate: { path: 'virtualPrt', select: 'firstName lastName email' } }); await saved.populate({ path: 'cohort', populate: { path: 'virtualPrt', select: 'firstName lastName email' } });
return toCohortSessionDto(saved); return toCohortSessionDto(saved, await patientSessionNumbers([saved]));
}, },
}); });

View File

@ -35,6 +35,14 @@ function sessionMark(session, patientId) {
return record?.attended ? 'P' : 'A'; return record?.attended ? 'P' : 'A';
} }
function sessionRecord(session, patientId) {
return (session.records || []).find((item) => String(item.patient?._id || item.patient) === patientId);
}
function vitalValue(value) {
return value == null || value === '' ? '' : value;
}
export function reportRoutes(server) { export function reportRoutes(server) {
server.route({ server.route({
method: 'GET', method: 'GET',
@ -60,7 +68,7 @@ export function reportRoutes(server) {
const stats = await patientProgramStats(patients.map((item) => item._id)); const stats = await patientProgramStats(patients.map((item) => item._id));
return { return {
items: patients.map((patient) => { items: patients.map((patient) => {
const progress = stats.get(String(patient._id)) || { attended: 0, total: 0, vitals: [] }; const progress = stats.get(String(patient._id)) || { attended: 0, total: 0, completed: 0, assigned: 0, firstDate: '', vitals: [] };
const pii = readOnboardedPii(patient); const pii = readOnboardedPii(patient);
return { return {
id: String(patient._id), id: String(patient._id),
@ -73,6 +81,9 @@ export function reportRoutes(server) {
prt: patient.assignedEducator prt: patient.assignedEducator
? { id: String(patient.assignedEducator._id), firstName: patient.assignedEducator.firstName, lastName: patient.assignedEducator.lastName } ? { id: String(patient.assignedEducator._id), firstName: patient.assignedEducator.firstName, lastName: patient.assignedEducator.lastName }
: null, : null,
firstSession: progress.firstDate || '',
completed: progress.completed || 0,
assigned: progress.assigned || 0,
attended: progress.attended, attended: progress.attended,
total: progress.total, total: progress.total,
completion: progress.total ? `${progress.attended}/${progress.total}` : '0/0', completion: progress.total ? `${progress.attended}/${progress.total}` : '0/0',
@ -199,6 +210,69 @@ export function reportRoutes(server) {
items.push([`${prt.firstName} ${prt.lastName}`, used, max, Math.max(0, max - used), run, cancelledOwn]); items.push([`${prt.firstName} ${prt.lastName}`, used, max, Math.max(0, max - used), run, cancelledOwn]);
} }
rows = [['PRT', 'Used', 'Max', 'Idle', 'Sessions', 'Cancelled'], ...items]; rows = [['PRT', 'Used', 'Max', 'Idle', 'Sessions', 'Cancelled'], ...items];
} else if (type === 'vitals') {
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 records.pre records.post')
.sort({ date: 1, index: 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 included = patients.filter((patient) => {
const cohortId = patient.cohort?._id ? String(patient.cohort._id) : '';
const meetings = cohortId ? (byCohort.get(cohortId) || []) : [];
return meetings.some((session) => sessionRecord(session, String(patient._id)));
});
const groups = included.map((patient) => {
const cohortId = patient.cohort?._id ? String(patient.cohort._id) : '';
const meetings = (cohortId ? (byCohort.get(cohortId) || []) : [])
.filter((session) => sessionRecord(session, String(patient._id)));
return { patient, meetings };
});
const blank = ['', '', '', '', '', ''];
const lines = [];
for (const { patient, meetings } of groups) {
const pii = readOnboardedPii(patient);
const identity = [
pii.name,
pii.mobile,
patient.diseaseType || '',
patient.cohort?.name || '',
patient.cohort?.startDate ? toYmd(patient.cohort.startDate) : '',
];
for (const session of meetings) {
const record = sessionRecord(session, String(patient._id));
const pre = record?.pre || {};
const post = record?.post || {};
lines.push({
date: session.date,
name: pii.name || '',
row: [
...identity,
`${session.date} (${sessionMark(session, String(patient._id))})`,
vitalValue(pre.bp),
vitalValue(pre.spo2),
vitalValue(pre.pulse),
vitalValue(post.bp),
vitalValue(post.spo2),
vitalValue(post.pulse),
],
});
}
}
lines.sort((a, b) => a.date.localeCompare(b.date) || a.name.localeCompare(b.name));
rows = [
['Name', 'Mobile', 'Disease', 'Cohort', 'Start date', 'Session date', 'Pre', '', '', 'Post', '', ''],
[...blank, 'BP', 'SpO2', 'Pulse', 'BP', 'SpO2', 'Pulse'],
...lines.map((line) => line.row),
];
} else { } else {
const patients = await listOnboardedForReports({ virtualPrtId: scopedPrt(request), diseaseType: request.query.diseaseType || undefined }); 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 cohortIds = [...new Set(patients.map((patient) => patient.cohort?._id).filter(Boolean))];
@ -214,12 +288,15 @@ export function reportRoutes(server) {
list.push(session); list.push(session);
byCohort.set(id, list); byCohort.set(id, list);
} }
const stats = await patientProgramStats(patients.map((item) => item._id));
const widest = [...byCohort.values()].reduce((max, list) => Math.max(max, list.length), 0); 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}`)]; const headers = ['Name', 'Mobile', 'Disease', 'Cohort', 'PRT', 'Start date', 'First session', 'Completed', 'Assigned', ...Array.from({ length: widest }, (_, index) => `Session ${index + 1}`)];
rows = [headers, ...patients.map((patient) => { rows = [headers, ...patients.map((patient) => {
const pii = readOnboardedPii(patient); const pii = readOnboardedPii(patient);
const progress = stats.get(String(patient._id)) || {};
const cohortId = patient.cohort?._id ? String(patient.cohort._id) : ''; const cohortId = patient.cohort?._id ? String(patient.cohort._id) : '';
const meetings = cohortId ? (byCohort.get(cohortId) || []) : []; const meetings = (cohortId ? (byCohort.get(cohortId) || []) : [])
.filter((session) => sessionRecord(session, String(patient._id)));
const marks = meetings.map((session) => sessionMark(session, String(patient._id))); const marks = meetings.map((session) => sessionMark(session, String(patient._id)));
while (marks.length < widest) marks.push(''); // shorter cohorts leave the extra session cells blank while (marks.length < widest) marks.push(''); // shorter cohorts leave the extra session cells blank
return [ return [
@ -229,6 +306,9 @@ export function reportRoutes(server) {
patient.cohort?.name || '', patient.cohort?.name || '',
patient.assignedEducator ? `${patient.assignedEducator.firstName} ${patient.assignedEducator.lastName}` : '', patient.assignedEducator ? `${patient.assignedEducator.firstName} ${patient.assignedEducator.lastName}` : '',
patient.cohort?.startDate ? toYmd(patient.cohort.startDate) : '', patient.cohort?.startDate ? toYmd(patient.cohort.startDate) : '',
progress.firstDate || '',
progress.completed || 0,
progress.assigned || 0,
...marks, ...marks,
]; ];
})]; })];

View File

@ -21,6 +21,7 @@ import { matchingRoutes } from './routes/matching.js';
import { programSessionRoutes } from './routes/programSessions.js'; import { programSessionRoutes } from './routes/programSessions.js';
import { reportRoutes } from './routes/reports.js'; import { reportRoutes } from './routes/reports.js';
import { startMeetingJobs } from './jobs/meetings.js'; import { startMeetingJobs } from './jobs/meetings.js';
import { startCohortExtensionJobs } from './jobs/cohortExtension.js';
import { startWhatsappWorker } from './services/whatsappQueue.js'; import { startWhatsappWorker } from './services/whatsappQueue.js';
import { startMatchingJobs } from './jobs/matching.js'; import { startMatchingJobs } from './jobs/matching.js';
import { startRetentionJobs } from './jobs/retention.js'; import { startRetentionJobs } from './jobs/retention.js';
@ -107,6 +108,7 @@ export async function createServer() {
auditRoutes(server); auditRoutes(server);
whatsappRoutes(server); whatsappRoutes(server);
startMeetingJobs(); startMeetingJobs();
startCohortExtensionJobs();
startWhatsappWorker(); startWhatsappWorker();
startMatchingJobs().catch((err) => console.error('[matching] schedule failed', err)); startMatchingJobs().catch((err) => console.error('[matching] schedule failed', err));
startRetentionJobs().catch((err) => console.error('[retention] schedule failed', err)); startRetentionJobs().catch((err) => console.error('[retention] schedule failed', err));

View File

@ -1,8 +1,10 @@
import { Cohort } from '../models/Cohort.js'; import { Cohort } from '../models/Cohort.js';
import { CohortSession } from '../models/CohortSession.js';
import { OnboardedPatient } from '../models/OnboardedPatient.js'; import { OnboardedPatient } from '../models/OnboardedPatient.js';
import { addPatientToWhatsapp, removePatientFromWhatsapp, syncWhatsappMembership } from './whatsapp.js'; import { addPatientToWhatsapp, removePatientFromWhatsapp, syncWhatsappMembership } from './whatsapp.js';
import { applyOnboardedPii, hashMobile, isMobileNumber } from './cryptoPii.js'; import { applyOnboardedPii, hashMobile, isMobileNumber } from './cryptoPii.js';
import { toYmd, weekdayOf } from './schedule.js'; import { env } from '../config/env.js';
import { toYmd, todayYmd, weekdayOf } from './schedule.js';
import Boom from '@hapi/boom'; import Boom from '@hapi/boom';
async function safeWhatsapp(label, fn) { async function safeWhatsapp(label, fn) {
@ -23,9 +25,17 @@ export async function syncCohortPatients(cohort, previousPatientIds = []) {
{ $set: { assignedEducator: cohort.virtualPrt, cohort: cohort._id, matchingStatus: 'assigned' } }, { $set: { assignedEducator: cohort.virtualPrt, cohort: cohort._id, matchingStatus: 'assigned' } },
); );
} }
const added = nextIds.filter((id) => !prevIds.includes(id));
if (added.length) {
await OnboardedPatient.updateMany({ _id: { $in: added } }, { $set: { cohortJoinedOn: todayYmd(env.APP_TIMEZONE) } });
}
const removed = prevIds.filter((id) => !nextIds.includes(id)); const removed = prevIds.filter((id) => !nextIds.includes(id));
for (const id of removed) { for (const id of removed) {
await CohortSession.updateMany(
{ cohort: cohort._id, status: 'scheduled', date: { $gte: todayYmd(env.APP_TIMEZONE) } },
{ $pull: { records: { patient: id } } },
);
const other = await Cohort.findOne({ _id: { $ne: cohort._id }, patients: id }); const other = await Cohort.findOne({ _id: { $ne: cohort._id }, patients: id });
if (other) { if (other) {
await OnboardedPatient.updateOne( await OnboardedPatient.updateOne(
@ -52,13 +62,19 @@ export async function syncCohortPatients(cohort, previousPatientIds = []) {
export async function addPatientToCohort(cohort, patientId) { export async function addPatientToCohort(cohort, patientId) {
const id = String(patientId); const id = String(patientId);
const current = (cohort.patients || []).map((item) => String(item._id || item)); const current = (cohort.patients || []).map((item) => String(item._id || item));
if (!current.includes(id)) { const joined = !current.includes(id);
if (joined) {
cohort.patients.push(patientId); cohort.patients.push(patientId);
await cohort.save(); await cohort.save();
} }
await OnboardedPatient.updateOne( await OnboardedPatient.updateOne(
{ _id: patientId }, { _id: patientId },
{ $set: { assignedEducator: cohort.virtualPrt, cohort: cohort._id, matchingStatus: 'assigned' } }, { $set: {
assignedEducator: cohort.virtualPrt,
cohort: cohort._id,
matchingStatus: 'assigned',
...(joined ? { cohortJoinedOn: todayYmd(env.APP_TIMEZONE) } : {}),
} },
); );
await safeWhatsapp('add patient', () => addPatientToWhatsapp(cohort, patientId)); await safeWhatsapp('add patient', () => addPatientToWhatsapp(cohort, patientId));
} }
@ -66,6 +82,10 @@ export async function addPatientToCohort(cohort, patientId) {
export async function removePatientFromCohort(cohortId, patientId) { export async function removePatientFromCohort(cohortId, patientId) {
if (!cohortId || !patientId) return; if (!cohortId || !patientId) return;
await Cohort.updateOne({ _id: cohortId }, { $pull: { patients: patientId } }); await Cohort.updateOne({ _id: cohortId }, { $pull: { patients: patientId } });
await CohortSession.updateMany(
{ cohort: cohortId, status: 'scheduled', date: { $gte: todayYmd(env.APP_TIMEZONE) } },
{ $pull: { records: { patient: patientId } } },
);
await safeWhatsapp('remove patient', () => removePatientFromWhatsapp(cohortId, patientId)); await safeWhatsapp('remove patient', () => removePatientFromWhatsapp(cohortId, patientId));
} }

View File

@ -69,6 +69,27 @@ function manualSessionCount(cohort) {
return cohort.sixDayWeek ? 60 : 30; return cohort.sixDayWeek ? 60 : 30;
} }
// Program length for a cohort: its schedule count, otherwise the settings count, or 60 for a six-day manual cohort.
export async function cohortProgramLength(cohort, settings) {
const ownCount = cohort ? await scheduleSessionCount(cohort.scheduleId) : null;
return Math.min(Math.max(Number(manualSessionCount(cohort) || ownCount || settings?.upcomingSessionCount) || 30, 1), 60);
}
// Completed sessions this patient was on, across every cohort.
async function finishedSessionCounts(patientIds) {
const ids = patientIds.map(String);
const counts = new Map(ids.map((id) => [id, 0]));
if (!ids.length) return counts;
const sessions = await CohortSession.find({ 'records.patient': { $in: ids }, status: 'completed' }).select('records.patient');
for (const session of sessions) {
for (const record of session.records || []) {
const id = String(record.patient);
if (counts.has(id)) counts.set(id, counts.get(id) + 1);
}
}
return counts;
}
// Creates the session list once. If rows exist, only adds dates when the list is short. // Creates the session list once. If rows exist, only adds dates when the list is short.
export async function persistCohortSessions(cohort) { export async function persistCohortSessions(cohort) {
const settings = await getAppSettings(); const settings = await getAppSettings();
@ -223,21 +244,32 @@ export async function rescheduleOpenSessions(cohort) {
return sessions; return sessions;
} }
// Books each patient onto the upcoming sessions they still have left. Finished sessions are not changed.
export async function ensureSessionRecords(cohort) { export async function ensureSessionRecords(cohort) {
const patients = patientIds(cohort).map(String); const patients = patientIds(cohort).map(String);
if (!patients.length) return; if (!patients.length) return;
const sessions = await CohortSession.find({ cohort: cohort._id, status: { $in: ['scheduled', 'completed'] } }); const settings = await getAppSettings();
for (const session of sessions) { const length = await cohortProgramLength(cohort, settings);
const have = new Set((session.records || []).map((record) => String(record.patient))); const finished = await finishedSessionCounts(patients);
let dirty = false; const today = todayYmd(env.APP_TIMEZONE);
const sessions = await CohortSession.find({ cohort: cohort._id, status: 'scheduled', date: { $gte: today } }).sort({ date: 1, index: 1 });
const dirty = new Set();
for (const id of patients) { for (const id of patients) {
if (!have.has(id)) { const remaining = Math.max(0, length - (finished.get(id) || 0));
const keep = new Set(sessions.slice(0, remaining).map((session) => String(session._id)));
for (const session of sessions) {
const index = (session.records || []).findIndex((record) => String(record.patient) === id);
const should = keep.has(String(session._id));
if (should && index < 0) {
session.records.push({ patient: id, attended: false, pre: {}, post: {} }); session.records.push({ patient: id, attended: false, pre: {}, post: {} });
dirty = true; dirty.add(session);
} else if (!should && index >= 0) {
session.records.splice(index, 1);
dirty.add(session);
} }
} }
if (dirty) await session.save();
} }
for (const session of dirty) await session.save();
} }
// Next date for an extra schedule, skipping unticked days, Sunday, holiday, and leave. // Next date for an extra schedule, skipping unticked days, Sunday, holiday, and leave.
@ -285,6 +317,37 @@ export async function nextAppendDate(cohort) {
return cursor; return cursor;
} }
// Adds today's session to open cohorts with no future session left, when today is their next meeting day.
export async function appendTodaySessions(date) {
const cohorts = await Cohort.find(liveCohortFilter());
let added = 0;
for (const cohort of cohorts) {
if (await CohortSession.exists({ cohort: cohort._id, date })) continue;
if (await CohortSession.exists({ cohort: cohort._id, status: 'scheduled', date: { $gt: date } })) continue;
if (await nextAppendDate(cohort) !== date) continue;
const ids = patientIds(cohort).map(String);
const length = await cohortProgramLength(cohort, await getAppSettings());
const finished = await finishedSessionCounts(ids);
if (!ids.some((id) => (finished.get(id) || 0) < length)) continue; // everyone already finished their sessions
const last = await CohortSession.findOne({ cohort: cohort._id }).sort({ index: -1 });
const link = await CohortSession.findOne({ cohort: cohort._id, meetUrl: { $ne: '' } }) || {};
await CohortSession.create({
cohort: cohort._id,
index: (last?.index || 0) + 1,
date,
slot: cohort.slot,
status: 'scheduled',
googleEventId: link.googleEventId || '',
meetUrl: link.meetUrl || '',
htmlLink: link.htmlLink || '',
records: [],
});
await ensureSessionRecords(cohort);
added += 1;
}
return added;
}
async function sessionHost(session, cohort) { async function sessionHost(session, cohort) {
const coverId = session.coverPrt?._id || session.coverPrt; const coverId = session.coverPrt?._id || session.coverPrt;
if (coverId) { if (coverId) {
@ -322,6 +385,7 @@ export async function pushSession(session, reason, { notifyAdmins = false } = {}
htmlLink: session.htmlLink || '', htmlLink: session.htmlLink || '',
records: recordsFor(patientIds(cohort)), records: recordsFor(patientIds(cohort)),
}); });
await ensureSessionRecords(cohort);
const admins = notifyAdmins ? await adminUsers() : []; const admins = notifyAdmins ? await adminUsers() : [];
await notifySessionPushed({ await notifySessionPushed({
@ -621,10 +685,35 @@ export async function attendanceProgress(patientId) {
attended += 1; attended += 1;
} }
} }
return { attended, total: sessions.length }; const patient = await OnboardedPatient.findById(patientId).populate('cohort', 'createdAs sixDayWeek scheduleId');
const settings = await getAppSettings();
const total = await cohortProgramLength(patient?.cohort, settings);
return { attended, total };
}
// Each patient's own session number on each session, counted by date across all their cohorts.
export async function patientSessionNumbers(sessions) {
const ids = [...new Set(sessions.flatMap((session) => (session.records || []).map((record) => String(record.patient?._id || record.patient))))];
const numbers = new Map();
if (!ids.length) return numbers;
const all = await CohortSession.find({ 'records.patient': { $in: ids }, status: { $ne: 'cancelled' } })
.select('_id date index records.patient')
.sort({ date: 1, index: 1 });
const seen = new Map();
for (const session of all) {
for (const record of session.records || []) {
const id = String(record.patient);
if (!ids.includes(id)) continue;
const next = (seen.get(id) || 0) + 1;
seen.set(id, next);
numbers.set(`${session._id}:${id}`, next);
}
}
return numbers;
} }
export async function saveSessionRecords(session, records = [], { completed } = {}) { export async function saveSessionRecords(session, records = [], { completed } = {}) {
const numbers = await patientSessionNumbers([session]);
const byPatient = new Map((session.records || []).map((record) => [String(record.patient._id || record.patient), record])); const byPatient = new Map((session.records || []).map((record) => [String(record.patient._id || record.patient), record]));
for (const incoming of records) { for (const incoming of records) {
const id = String(incoming.patientId || incoming.patient); const id = String(incoming.patientId || incoming.patient);
@ -644,7 +733,7 @@ export async function saveSessionRecords(session, records = [], { completed } =
pulse: incoming.post.pulse ?? current.post?.pulse ?? null, pulse: incoming.post.pulse ?? current.post?.pulse ?? null,
}; };
} }
if (incoming.assessment && (session.index === 15 || session.index === 30)) { if (incoming.assessment && [15, 30].includes(numbers.get(`${session._id}:${id}`))) {
const previous = current.assessment || {}; const previous = current.assessment || {};
current.assessment = { current.assessment = {
fev1: incoming.assessment.fev1 ?? previous.fev1 ?? '', fev1: incoming.assessment.fev1 ?? previous.fev1 ?? '',
@ -726,13 +815,15 @@ export async function moveSessionEvent(session, nextDate) {
export async function patientProgramStats(patientIds) { export async function patientProgramStats(patientIds) {
const ids = patientIds.map((id) => String(id)); const ids = patientIds.map((id) => String(id));
const sessions = await CohortSession.find({ 'records.patient': { $in: ids }, status: { $in: ['scheduled', 'completed'] } }); const sessions = await CohortSession.find({ 'records.patient': { $in: ids }, status: { $in: ['scheduled', 'completed'] } });
const byPatient = new Map(ids.map((id) => [id, { attended: 0, total: 0, vitals: [] }])); const byPatient = new Map(ids.map((id) => [id, { attended: 0, total: 0, completed: 0, assigned: 0, firstDate: '', vitals: [] }]));
for (const session of sessions) { for (const session of sessions) {
for (const record of session.records || []) { for (const record of session.records || []) {
const id = String(record.patient); const id = String(record.patient);
const row = byPatient.get(id); const row = byPatient.get(id);
if (!row) continue; if (!row) continue;
row.total += 1; if (session.status === 'completed') row.completed += 1;
else row.assigned += 1;
if (!row.firstDate || session.date < row.firstDate) row.firstDate = session.date;
if (record.attended) row.attended += 1; if (record.attended) row.attended += 1;
if (record.pre?.spo2 != null || record.post?.spo2 != null || record.pre?.pulse != null) { if (record.pre?.spo2 != null || record.post?.spo2 != null || record.pre?.pulse != null) {
row.vitals.push({ row.vitals.push({
@ -743,6 +834,19 @@ export async function patientProgramStats(patientIds) {
} }
} }
} }
const settings = await getAppSettings();
const people = await OnboardedPatient.find({ _id: { $in: ids } }).populate('cohort', 'createdAs sixDayWeek scheduleId');
const lengths = new Map();
for (const person of people) {
const id = String(person._id);
const row = byPatient.get(id);
if (!row) continue;
const key = person.cohort ? String(person.cohort._id) : '';
if (!lengths.has(key)) lengths.set(key, await cohortProgramLength(person.cohort, settings));
const total = lengths.get(key);
row.total = total;
row.assigned = Math.max(0, total - row.completed);
}
return byPatient; return byPatient;
} }

View File

@ -184,6 +184,7 @@ export function toOnboardedPatientDto(item) {
diseaseType: item.diseaseType || '', diseaseType: item.diseaseType || '',
preferredWeekday: item.preferredWeekday ?? null, preferredWeekday: item.preferredWeekday ?? null,
sessionDate: item.sessionDate || null, sessionDate: item.sessionDate || null,
cohortJoinedOn: item.cohortJoinedOn || '',
slot: item.slot || '', slot: item.slot || '',
matchingStatus: item.matchingStatus || 'unassigned', matchingStatus: item.matchingStatus || 'unassigned',
rejectReason: item.rejectReason || '', rejectReason: item.rejectReason || '',
@ -310,7 +311,7 @@ export function toLeaveDto(leave) {
}; };
} }
export function toCohortSessionDto(session) { export function toCohortSessionDto(session, numbers = null) {
if (!session) return null; if (!session) return null;
const cohort = session.cohort && typeof session.cohort === 'object' ? session.cohort : null; const cohort = session.cohort && typeof session.cohort === 'object' ? session.cohort : null;
return { return {
@ -343,6 +344,7 @@ export function toCohortSessionDto(session) {
? { id: String(record.patient._id), name: readOnboardedPii(record.patient).name, mobile: readOnboardedPii(record.patient).mobile } ? { id: String(record.patient._id), name: readOnboardedPii(record.patient).name, mobile: readOnboardedPii(record.patient).mobile }
: { id: String(record.patient) }, : { id: String(record.patient) },
attended: Boolean(record.attended), attended: Boolean(record.attended),
patientSessionNo: numbers instanceof Map ? (numbers.get(`${session._id}:${String(record.patient?._id || record.patient)}`) ?? null) : null, // .map() passes an index here
pre: { bp: record.pre?.bp || '', spo2: record.pre?.spo2 ?? null, pulse: record.pre?.pulse ?? null }, pre: { bp: record.pre?.bp || '', spo2: record.pre?.spo2 ?? null, pulse: record.pre?.pulse ?? null },
post: { bp: record.post?.bp || '', spo2: record.post?.spo2 ?? null, pulse: record.post?.pulse ?? null }, post: { bp: record.post?.bp || '', spo2: record.post?.spo2 ?? null, pulse: record.post?.pulse ?? null },
assessment: { assessment: {

View File

@ -46,6 +46,7 @@ export function rosterFromSession(session) {
patientId: record.patient?.id, patientId: record.patient?.id,
name: record.patient?.name || 'Patient', name: record.patient?.name || 'Patient',
attended: Boolean(record.attended), attended: Boolean(record.attended),
sessionNo: record.patientSessionNo ?? null,
pre: { ...emptyVitals(), ...record.pre, spo2: record.pre?.spo2 ?? '', pulse: record.pre?.pulse ?? '' }, pre: { ...emptyVitals(), ...record.pre, spo2: record.pre?.spo2 ?? '', pulse: record.pre?.pulse ?? '' },
post: { ...emptyVitals(), ...record.post, spo2: record.post?.spo2 ?? '', pulse: record.post?.pulse ?? '' }, post: { ...emptyVitals(), ...record.post, spo2: record.post?.spo2 ?? '', pulse: record.post?.pulse ?? '' },
assessment: { ...emptyAssessment(), ...record.assessment }, assessment: { ...emptyAssessment(), ...record.assessment },
@ -101,7 +102,7 @@ export default function SessionVitalsModal({ roster, onChange, onClose, onSaved
<Modal show={Boolean(roster)} onHide={onClose} size="lg"> <Modal show={Boolean(roster)} onHide={onClose} size="lg">
<ModalHeader closeButton> <ModalHeader closeButton>
<h5 className="modal-title"> <h5 className="modal-title">
Attendance and vitals{isMilestone(roster?.index) ? ` · session ${roster.index}` : ''} Attendance and vitals{roster?.index ? ` · cohort session ${roster.index}` : ''}
</h5> </h5>
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
@ -109,7 +110,7 @@ export default function SessionVitalsModal({ roster, onChange, onClose, onSaved
{(roster?.records || []).length ? (roster.records || []).map((record, index) => ( {(roster?.records || []).length ? (roster.records || []).map((record, index) => (
<div key={record.patientId || index} className="border rounded p-3 mb-3"> <div key={record.patientId || index} className="border rounded p-3 mb-3">
<div className="d-flex justify-content-between align-items-center mb-2"> <div className="d-flex justify-content-between align-items-center mb-2">
<strong>{record.name}</strong> <strong>{record.name}{record.sessionNo ? <span className="text-muted small ms-2">session {record.sessionNo}</span> : null}</strong>
<FormCheck <FormCheck
type="switch" type="switch"
label="Attended" label="Attended"
@ -149,7 +150,7 @@ export default function SessionVitalsModal({ roster, onChange, onClose, onSaved
</Col> </Col>
))} ))}
</Row> </Row>
{isMilestone(roster?.index) ? ( {isMilestone(record.sessionNo) ? (
<Row className="g-2 mt-2"> <Row className="g-2 mt-2">
{ASSESSMENT_FIELDS.map(([key, label]) => ( {ASSESSMENT_FIELDS.map(([key, label]) => (
<Col md={4} key={key}> <Col md={4} key={key}>

View File

@ -124,7 +124,7 @@ export default function OnboardingPage() {
columnHelper.accessor('mobile', { header: 'Mobile' }), columnHelper.accessor('mobile', { header: 'Mobile' }),
columnHelper.accessor((r) => r.cohort?.name || (r.matchingStatus === 'held' ? 'Held' : 'Pool'), { id: 'cohort', header: 'Cohort' }), columnHelper.accessor((r) => r.cohort?.name || (r.matchingStatus === 'held' ? 'Held' : 'Pool'), { id: 'cohort', header: 'Cohort' }),
columnHelper.accessor((r) => r.diseaseType || '—', { id: 'diseaseType', header: 'Category' }), columnHelper.accessor((r) => r.diseaseType || '—', { id: 'diseaseType', header: 'Category' }),
columnHelper.accessor((r) => formatDate(r.sessionDate), { id: 'sessionDate', header: 'Start date' }), columnHelper.accessor((r) => formatDate(r.progress?.firstDate || r.sessionDate), { id: 'sessionDate', header: 'Start date' }),
columnHelper.accessor('slot', { header: 'Slot' }), columnHelper.accessor('slot', { header: 'Slot' }),
columnHelper.accessor('paymentStatus', { header: 'Payment' }), columnHelper.accessor('paymentStatus', { header: 'Payment' }),
columnHelper.accessor((r) => r.progress ? `${r.progress.attended}/${r.progress.total}` : '—', { id: 'progress', header: 'Sessions' }), columnHelper.accessor((r) => r.progress ? `${r.progress.attended}/${r.progress.total}` : '—', { id: 'progress', header: 'Sessions' }),

View File

@ -20,8 +20,13 @@ export default function ProgramSessionsPage() {
load().catch((err) => setError(err.message)); load().catch((err) => setError(err.message));
}, []); }, []);
const openRoster = (session) => { const openRoster = async (session) => {
setRoster(rosterFromSession(session)); setError('');
try {
setRoster(rosterFromSession(await api(`/api/cohort-sessions/${session.id}`)));
} catch (err) {
setError(err.message);
}
}; };
return ( return (

View File

@ -42,6 +42,7 @@ export default function ReportsPage() {
<div className="d-flex gap-2 mb-3"> <div className="d-flex gap-2 mb-3">
<Button variant="outline-primary" onClick={() => download('patients')}>Export patients CSV</Button> <Button variant="outline-primary" onClick={() => download('patients')}>Export patients CSV</Button>
<Button variant="outline-primary" onClick={() => download('utilization')}>Export utilization CSV</Button> <Button variant="outline-primary" onClick={() => download('utilization')}>Export utilization CSV</Button>
<Button variant="outline-primary" onClick={() => download('vitals')}>Export vitals CSV</Button>
</div> </div>
<Row className="g-3"> <Row className="g-3">
<Col lg={7}> <Col lg={7}>
@ -49,17 +50,20 @@ export default function ReportsPage() {
<CardBody> <CardBody>
<h5 className="mb-3">Patient progress</h5> <h5 className="mb-3">Patient progress</h5>
<Table responsive size="sm" className="mb-0"> <Table responsive size="sm" className="mb-0">
<thead><tr><th>Patient</th><th>Cohort</th><th>PRT</th><th>Attendance</th></tr></thead> <thead><tr><th>Patient</th><th>Cohort</th><th>PRT</th><th>First session</th><th>Completed</th><th>Assigned</th><th>Attendance</th></tr></thead>
<tbody> <tbody>
{patients.map((item) => ( {patients.map((item) => (
<tr key={item.id}> <tr key={item.id}>
<td>{item.name}<div className="small text-muted">{item.diseaseType}</div></td> <td>{item.name}<div className="small text-muted">{item.diseaseType}</div></td>
<td>{item.cohort?.name || '—'}</td> <td>{item.cohort?.name || '—'}</td>
<td>{item.prt ? `${item.prt.firstName} ${item.prt.lastName}` : '—'}</td> <td>{item.prt ? `${item.prt.firstName} ${item.prt.lastName}` : '—'}</td>
<td>{item.firstSession || '—'}</td>
<td>{item.completed}</td>
<td>{item.assigned}</td>
<td>{item.completion}</td> <td>{item.completion}</td>
</tr> </tr>
))} ))}
{!patients.length ? <tr><td colSpan={4} className="text-muted">No confirmed patients.</td></tr> : null} {!patients.length ? <tr><td colSpan={7} className="text-muted">No confirmed patients.</td></tr> : null}
</tbody> </tbody>
</Table> </Table>
</CardBody> </CardBody>