VIrtual PR 4 Changes for move patients
This commit is contained in:
parent
2241886ca7
commit
a0b2020286
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ const onboardedPatientSchema = new mongoose.Schema(
|
|||
onboardedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
assignedEducator: { type: mongoose.Schema.Types.ObjectId, ref: 'User', 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: {
|
||||
type: String,
|
||||
enum: ['pending', 'confirmed', 'rejected'],
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { migratePatient } from '../services/matching.js';
|
|||
import { notifyPaymentReview } from '../services/notify.js';
|
||||
import { attendanceProgress, ensureSessionRecords, patientProgramStats } from '../services/programSessions.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);
|
||||
function textField(payload, key) {
|
||||
|
|
@ -208,11 +208,10 @@ function canReview(request) {
|
|||
}
|
||||
|
||||
async function findAssignableCohort(cohortId, currentCohortId) {
|
||||
const today = ymdToDate(toYmd(new Date()));
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -287,7 +286,7 @@ export function onboardingRoutes(server) {
|
|||
return {
|
||||
items: items.map((item) => {
|
||||
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,
|
||||
page: Number(page),
|
||||
|
|
@ -307,10 +306,9 @@ export function onboardingRoutes(server) {
|
|||
path: '/api/onboarding/available-cohorts',
|
||||
options: { pre: [{ method: requirePermission('onboarding', 'view') }] },
|
||||
handler: async (request) => {
|
||||
const today = ymdToDate(toYmd(new Date()));
|
||||
const language = String(request.query.language || '').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;
|
||||
const items = await Cohort.find(filter)
|
||||
.populate('virtualPrt', 'firstName lastName email')
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { toCohortSessionDto } from '../utils/serialize.js';
|
|||
import {
|
||||
generateMeetsForDate,
|
||||
listSessions,
|
||||
patientSessionNumbers,
|
||||
pushSession,
|
||||
saveSessionRecords,
|
||||
} from '../services/programSessions.js';
|
||||
|
|
@ -104,7 +105,10 @@ export function programSessionRoutes(server) {
|
|||
pre: [{ method: requirePermission('program_sessions', 'view') }],
|
||||
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({
|
||||
|
|
@ -194,7 +198,7 @@ export function programSessionRoutes(server) {
|
|||
const saved = await saveSessionRecords(session, request.payload.records, { completed: request.payload.completed });
|
||||
await saved.populate('records.patient', 'name mobile');
|
||||
await saved.populate({ path: 'cohort', populate: { path: 'virtualPrt', select: 'firstName lastName email' } });
|
||||
return toCohortSessionDto(saved);
|
||||
return toCohortSessionDto(saved, await patientSessionNumbers([saved]));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,14 @@ function sessionMark(session, patientId) {
|
|||
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) {
|
||||
server.route({
|
||||
method: 'GET',
|
||||
|
|
@ -60,7 +68,7 @@ export function reportRoutes(server) {
|
|||
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 progress = stats.get(String(patient._id)) || { attended: 0, total: 0, completed: 0, assigned: 0, firstDate: '', vitals: [] };
|
||||
const pii = readOnboardedPii(patient);
|
||||
return {
|
||||
id: String(patient._id),
|
||||
|
|
@ -73,6 +81,9 @@ export function reportRoutes(server) {
|
|||
prt: patient.assignedEducator
|
||||
? { id: String(patient.assignedEducator._id), firstName: patient.assignedEducator.firstName, lastName: patient.assignedEducator.lastName }
|
||||
: null,
|
||||
firstSession: progress.firstDate || '',
|
||||
completed: progress.completed || 0,
|
||||
assigned: progress.assigned || 0,
|
||||
attended: progress.attended,
|
||||
total: progress.total,
|
||||
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]);
|
||||
}
|
||||
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 {
|
||||
const patients = await listOnboardedForReports({ virtualPrtId: scopedPrt(request), diseaseType: request.query.diseaseType || undefined });
|
||||
const cohortIds = [...new Set(patients.map((patient) => patient.cohort?._id).filter(Boolean))];
|
||||
|
|
@ -214,12 +288,15 @@ export function reportRoutes(server) {
|
|||
list.push(session);
|
||||
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 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) => {
|
||||
const pii = readOnboardedPii(patient);
|
||||
const progress = stats.get(String(patient._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)));
|
||||
while (marks.length < widest) marks.push(''); // shorter cohorts leave the extra session cells blank
|
||||
return [
|
||||
|
|
@ -229,6 +306,9 @@ export function reportRoutes(server) {
|
|||
patient.cohort?.name || '',
|
||||
patient.assignedEducator ? `${patient.assignedEducator.firstName} ${patient.assignedEducator.lastName}` : '',
|
||||
patient.cohort?.startDate ? toYmd(patient.cohort.startDate) : '',
|
||||
progress.firstDate || '',
|
||||
progress.completed || 0,
|
||||
progress.assigned || 0,
|
||||
...marks,
|
||||
];
|
||||
})];
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { matchingRoutes } from './routes/matching.js';
|
|||
import { programSessionRoutes } from './routes/programSessions.js';
|
||||
import { reportRoutes } from './routes/reports.js';
|
||||
import { startMeetingJobs } from './jobs/meetings.js';
|
||||
import { startCohortExtensionJobs } from './jobs/cohortExtension.js';
|
||||
import { startWhatsappWorker } from './services/whatsappQueue.js';
|
||||
import { startMatchingJobs } from './jobs/matching.js';
|
||||
import { startRetentionJobs } from './jobs/retention.js';
|
||||
|
|
@ -107,6 +108,7 @@ export async function createServer() {
|
|||
auditRoutes(server);
|
||||
whatsappRoutes(server);
|
||||
startMeetingJobs();
|
||||
startCohortExtensionJobs();
|
||||
startWhatsappWorker();
|
||||
startMatchingJobs().catch((err) => console.error('[matching] schedule failed', err));
|
||||
startRetentionJobs().catch((err) => console.error('[retention] schedule failed', err));
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { Cohort } from '../models/Cohort.js';
|
||||
import { CohortSession } from '../models/CohortSession.js';
|
||||
import { OnboardedPatient } from '../models/OnboardedPatient.js';
|
||||
import { addPatientToWhatsapp, removePatientFromWhatsapp, syncWhatsappMembership } from './whatsapp.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';
|
||||
|
||||
async function safeWhatsapp(label, fn) {
|
||||
|
|
@ -23,9 +25,17 @@ export async function syncCohortPatients(cohort, previousPatientIds = []) {
|
|||
{ $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));
|
||||
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 });
|
||||
if (other) {
|
||||
await OnboardedPatient.updateOne(
|
||||
|
|
@ -52,13 +62,19 @@ export async function syncCohortPatients(cohort, previousPatientIds = []) {
|
|||
export async function addPatientToCohort(cohort, patientId) {
|
||||
const id = String(patientId);
|
||||
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);
|
||||
await cohort.save();
|
||||
}
|
||||
await OnboardedPatient.updateOne(
|
||||
{ _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));
|
||||
}
|
||||
|
|
@ -66,6 +82,10 @@ export async function addPatientToCohort(cohort, patientId) {
|
|||
export async function removePatientFromCohort(cohortId, patientId) {
|
||||
if (!cohortId || !patientId) return;
|
||||
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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,27 @@ function manualSessionCount(cohort) {
|
|||
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.
|
||||
export async function persistCohortSessions(cohort) {
|
||||
const settings = await getAppSettings();
|
||||
|
|
@ -223,21 +244,32 @@ export async function rescheduleOpenSessions(cohort) {
|
|||
return sessions;
|
||||
}
|
||||
|
||||
// Books each patient onto the upcoming sessions they still have left. Finished sessions are not changed.
|
||||
export async function ensureSessionRecords(cohort) {
|
||||
const patients = patientIds(cohort).map(String);
|
||||
if (!patients.length) return;
|
||||
const sessions = await CohortSession.find({ cohort: cohort._id, status: { $in: ['scheduled', 'completed'] } });
|
||||
for (const session of sessions) {
|
||||
const have = new Set((session.records || []).map((record) => String(record.patient)));
|
||||
let dirty = false;
|
||||
for (const id of patients) {
|
||||
if (!have.has(id)) {
|
||||
const settings = await getAppSettings();
|
||||
const length = await cohortProgramLength(cohort, settings);
|
||||
const finished = await finishedSessionCounts(patients);
|
||||
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) {
|
||||
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: {} });
|
||||
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.
|
||||
|
|
@ -285,6 +317,37 @@ export async function nextAppendDate(cohort) {
|
|||
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) {
|
||||
const coverId = session.coverPrt?._id || session.coverPrt;
|
||||
if (coverId) {
|
||||
|
|
@ -322,6 +385,7 @@ export async function pushSession(session, reason, { notifyAdmins = false } = {}
|
|||
htmlLink: session.htmlLink || '',
|
||||
records: recordsFor(patientIds(cohort)),
|
||||
});
|
||||
await ensureSessionRecords(cohort);
|
||||
|
||||
const admins = notifyAdmins ? await adminUsers() : [];
|
||||
await notifySessionPushed({
|
||||
|
|
@ -621,10 +685,35 @@ export async function attendanceProgress(patientId) {
|
|||
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 } = {}) {
|
||||
const numbers = await patientSessionNumbers([session]);
|
||||
const byPatient = new Map((session.records || []).map((record) => [String(record.patient._id || record.patient), record]));
|
||||
for (const incoming of records) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
if (incoming.assessment && (session.index === 15 || session.index === 30)) {
|
||||
if (incoming.assessment && [15, 30].includes(numbers.get(`${session._id}:${id}`))) {
|
||||
const previous = current.assessment || {};
|
||||
current.assessment = {
|
||||
fev1: incoming.assessment.fev1 ?? previous.fev1 ?? '',
|
||||
|
|
@ -726,13 +815,15 @@ export async function moveSessionEvent(session, nextDate) {
|
|||
export async function patientProgramStats(patientIds) {
|
||||
const ids = patientIds.map((id) => String(id));
|
||||
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 record of session.records || []) {
|
||||
const id = String(record.patient);
|
||||
const row = byPatient.get(id);
|
||||
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.pre?.spo2 != null || record.post?.spo2 != null || record.pre?.pulse != null) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ export function toOnboardedPatientDto(item) {
|
|||
diseaseType: item.diseaseType || '',
|
||||
preferredWeekday: item.preferredWeekday ?? null,
|
||||
sessionDate: item.sessionDate || null,
|
||||
cohortJoinedOn: item.cohortJoinedOn || '',
|
||||
slot: item.slot || '',
|
||||
matchingStatus: item.matchingStatus || 'unassigned',
|
||||
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;
|
||||
const cohort = session.cohort && typeof session.cohort === 'object' ? session.cohort : null;
|
||||
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) },
|
||||
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 },
|
||||
post: { bp: record.post?.bp || '', spo2: record.post?.spo2 ?? null, pulse: record.post?.pulse ?? null },
|
||||
assessment: {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export function rosterFromSession(session) {
|
|||
patientId: record.patient?.id,
|
||||
name: record.patient?.name || 'Patient',
|
||||
attended: Boolean(record.attended),
|
||||
sessionNo: record.patientSessionNo ?? null,
|
||||
pre: { ...emptyVitals(), ...record.pre, spo2: record.pre?.spo2 ?? '', pulse: record.pre?.pulse ?? '' },
|
||||
post: { ...emptyVitals(), ...record.post, spo2: record.post?.spo2 ?? '', pulse: record.post?.pulse ?? '' },
|
||||
assessment: { ...emptyAssessment(), ...record.assessment },
|
||||
|
|
@ -101,7 +102,7 @@ export default function SessionVitalsModal({ roster, onChange, onClose, onSaved
|
|||
<Modal show={Boolean(roster)} onHide={onClose} size="lg">
|
||||
<ModalHeader closeButton>
|
||||
<h5 className="modal-title">
|
||||
Attendance and vitals{isMilestone(roster?.index) ? ` · session ${roster.index}` : ''}
|
||||
Attendance and vitals{roster?.index ? ` · cohort session ${roster.index}` : ''}
|
||||
</h5>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
|
|
@ -109,7 +110,7 @@ export default function SessionVitalsModal({ roster, onChange, onClose, onSaved
|
|||
{(roster?.records || []).length ? (roster.records || []).map((record, index) => (
|
||||
<div key={record.patientId || index} className="border rounded p-3 mb-3">
|
||||
<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
|
||||
type="switch"
|
||||
label="Attended"
|
||||
|
|
@ -149,7 +150,7 @@ export default function SessionVitalsModal({ roster, onChange, onClose, onSaved
|
|||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
{isMilestone(roster?.index) ? (
|
||||
{isMilestone(record.sessionNo) ? (
|
||||
<Row className="g-2 mt-2">
|
||||
{ASSESSMENT_FIELDS.map(([key, label]) => (
|
||||
<Col md={4} key={key}>
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ export default function OnboardingPage() {
|
|||
columnHelper.accessor('mobile', { header: 'Mobile' }),
|
||||
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) => 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('paymentStatus', { header: 'Payment' }),
|
||||
columnHelper.accessor((r) => r.progress ? `${r.progress.attended}/${r.progress.total}` : '—', { id: 'progress', header: 'Sessions' }),
|
||||
|
|
|
|||
|
|
@ -20,8 +20,13 @@ export default function ProgramSessionsPage() {
|
|||
load().catch((err) => setError(err.message));
|
||||
}, []);
|
||||
|
||||
const openRoster = (session) => {
|
||||
setRoster(rosterFromSession(session));
|
||||
const openRoster = async (session) => {
|
||||
setError('');
|
||||
try {
|
||||
setRoster(rosterFromSession(await api(`/api/cohort-sessions/${session.id}`)));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export default function ReportsPage() {
|
|||
<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('utilization')}>Export utilization CSV</Button>
|
||||
<Button variant="outline-primary" onClick={() => download('vitals')}>Export vitals CSV</Button>
|
||||
</div>
|
||||
<Row className="g-3">
|
||||
<Col lg={7}>
|
||||
|
|
@ -49,17 +50,20 @@ export default function ReportsPage() {
|
|||
<CardBody>
|
||||
<h5 className="mb-3">Patient progress</h5>
|
||||
<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>
|
||||
{patients.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>{item.name}<div className="small text-muted">{item.diseaseType}</div></td>
|
||||
<td>{item.cohort?.name || '—'}</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>
|
||||
</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>
|
||||
</Table>
|
||||
</CardBody>
|
||||
|
|
|
|||
Loading…
Reference in New Issue