// Fixture routes for Yasmar check-ins: the plan, question checks, requests, live events, // acknowledgement and Add to check (YCI-1 to YCI-6, YCI-23 to YCI-29). import type * as T from "../../src/api/types"; import { firstName, pronouns } from "../../src/logic/format"; import { precheckQuestion } from "../../src/logic/questions"; import { urgentFindings } from "../../src/logic/urgent"; import { allowedWindows, canStartNow, clampExpiry, zoneOffset } from "../../src/logic/windows"; import { emit, newCall, play, script, type CallRecord } from "./calls"; import { nextId, type StoredPatient } from "./data"; import { clinicianName, HttpError, invalid, need, route, text, type Ctx, type Route } from "./http"; import { planTemplate } from "./plan"; import { blankCall, callsFor, eligibilityFor, logActivity, patientDetail, unreadNotes, type Store } from "./store"; import { wireCheckIn, wirePlan } from "./wire"; const patientOf = (ctx: Ctx): StoredPatient => need(ctx.store.patients.get(decodeURIComponent(ctx.params[0])), "That patient"); const callOf = (ctx: Ctx): CallRecord => need(ctx.store.calls.get(decodeURIComponent(ctx.params[0])), "That check-in"); function latestCheck(store: Store, p: StoredPatient, checkId: string | null): T.CheckRun | null { if (checkId) return store.checks.get(checkId) ?? null; return p.base.lastCheckId ? store.checks.get(p.base.lastCheckId) ?? null : null; } function template(store: Store, p: StoredPatient, checkId: string | null) { const detail = patientDetail(store, p); const e = eligibilityFor(store, p); const used = [...store.calls.values()].filter((c) => Date.now() - Date.parse(c.snapshot.requestedAt) < 30 * 86_400_000).length; return planTemplate({ ...detail, doses: detail.doses }, store.clinician, store.clinic, latestCheck(store, p, checkId), store.options.alwaysOpen, e.allowed ? { ok: true, code: null, message: null } : { ok: false, code: e.code, message: e.message }, used); } // What Yasmar says, as the backend stores it on the check-in (YCI-2). const scriptOf = (store: Store, p: StoredPatient) => { const t = template(store, p, null); return { opening: t.opening[0], identity: t.identity, consent: t.consent, feeling: t.feeling, alwaysLast: t.alwaysLast, closing: t.closings.map((c) => c.text), protocolVersion: t.protocolVersion }; }; const snapshotOf = (store: Store, c: CallRecord) => { const p = store.patients.get(c.snapshot.patientId); return wireCheckIn(c.snapshot, p ? scriptOf(store, p) : { opening: "", identity: [], consent: "", feeling: "", alwaysLast: "", closing: [], protocolVersion: "" }); }; export const listItem = (c: CallRecord): T.CheckInListItem => ({ id: c.snapshot.id, status: c.snapshot.status, statusAt: c.snapshot.statusAt, requestedAt: c.snapshot.requestedAt, urgent: c.snapshot.urgent, noteCount: c.snapshot.notes.length, unreadNotes: unreadNotes(c), summaryReady: c.snapshot.summary !== null, }); /** Adds one note to the call's record (one record per call, as the backend's batch route does). */ function addOneToCheck(ctx: Ctx, call: ReturnType, noteId: string) { const note = need(call.snapshot.notes.find((n) => n.id === noteId), "That note"); const p = need(ctx.store.patients.get(call.snapshot.patientId), "That patient"); const recordId = `rec-call-${call.snapshot.id}`; const time = new Date(note.at).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: "Africa/Lagos" }); const line = `Check-in note (${firstName(p.base.name)}, ${time}): ${note.text}.`; const existing = ctx.store.records.get(recordId); if (note.addedToCheckAt) return { note, recordId }; if (existing) { existing.text = `${existing.text}\n${line}`; existing.lineCount += 1; } else ctx.store.records.set(recordId, { id: recordId, patientId: p.base.id, title: `Check-in call ยท ${new Date(call.snapshot.requestedAt).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })}`, type: "clinic_note", place: "Yasmar check-in", recordDate: call.snapshot.requestedAt.slice(0, 10), addedBy: clinicianName(ctx.store), addedAt: new Date().toISOString(), origin: "check_in", fileName: null, contentType: "text/plain", sizeBytes: line.length, pages: null, reading: "read", readingError: null, readingErrorCode: null, heldBack: null, clarification: null, lineCount: 1, text: line, }); const updated = { ...note, addedToCheckAt: new Date().toISOString() }; emit(call, { type: "note_updated", at: updated.addedToCheckAt, note: updated }); p.newSinceLastCheck = true; const added = call.snapshot.notes.filter((n) => n.addedToCheckAt).length; p.base.newInformation = { since: updated.addedToCheckAt, items: [`${added} ${added === 1 ? "note" : "notes"} from the check-in call`] }; logActivity(p, clinicianName(ctx.store), "clinician", `Dr Okafor added a check-in note to the check: "${note.text}".`); return { note: updated, recordId }; } const spokenDate = (iso: string | null) => { if (!iso) return "I'm not sure."; const d = new Date(`${iso}T12:00:00Z`); return d.toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" }); }; export const checkInRoutes: Route[] = [ // As the backend: the plan reads the latest check; findings to link go in the create body. route("GET", "/patients/:id/check-ins/plan", "patients", (ctx) => { const p = patientOf(ctx); const t = template(ctx.store, p, null); return wirePlan(t, t.eligibility.ok); }), route("POST", "/check-ins/questions/validate", "patients", (ctx) => { const p = need(ctx.store.patients.get(text(ctx.body?.patientId, 80)), "That patient"); return precheckQuestion(String(ctx.body?.text ?? ""), p.base.firstName, pronouns(p.base.pronoun).object); }), route("GET", "/patients/:id/check-ins", "patients", (ctx) => ({ checkIns: callsFor(ctx.store, patientOf(ctx).base.id) .sort((a, b) => b.snapshot.requestedAt.localeCompare(a.snapshot.requestedAt)) .map(listItem), })), route("POST", "/patients/:id/check-ins", "patients", (ctx) => { const p = patientOf(ctx); const eligibility = eligibilityFor(ctx.store, p); if (!eligibility.allowed) throw eligibility.code === "limit_reached" ? new HttpError(429, "LIMIT_REACHED", eligibility.message, { eligibility: eligibility.code }) : new HttpError(409, "CHECK_IN_NOT_ALLOWED", eligibility.message, { eligibility: eligibility.code }); const plan = template(ctx.store, p, text(ctx.body?.checkId, 80) || null); const questions: T.PlanQuestion[] = Array.isArray(ctx.body?.questions) ? ctx.body.questions : []; const own = questions.filter((q) => q.origin === "own"); if (own.length > plan.maxOwnQuestions) throw invalid(`Add up to ${plan.maxOwnQuestions} questions of your own.`); for (const q of questions) { if (q.origin === "bank" && !plan.suggestions.some((s) => s.bankId === q.bankId && s.text === q.text)) throw invalid("A suggested question changed. Open the plan again."); if (q.origin === "own") { const result = precheckQuestion(q.text, p.base.firstName, pronouns(p.base.pronoun).object); if (!result.ok) throw new HttpError(400, "QUESTION_REJECTED", result.reason, { questionId: q.id }); } } const check = latestCheck(ctx.store, p, text(ctx.body?.checkId, 80) || null); const linked: string[] = Array.isArray(ctx.body?.linkedFindingIds) ? ctx.body.linkedFindingIds : []; const findings = check?.assessment.contextual.findings ?? []; if (linked.some((id) => !findings.some((f) => f.id === id))) throw new HttpError(400, "FINDING_NOT_FOUND", "A linked finding is not on that check."); if (urgentFindings(findings).length && !ctx.body?.urgentCallConfirmedAt) throw new HttpError(409, "URGENT_CALL_FIRST", `This finding is for urgent review. Call ${p.base.firstName} first, then confirm you will speak to ${p.base.firstName} today.`, { phone: p.base.phone }); const now = new Date(); const offsets = { patient: zoneOffset(plan.window.patientTimeZone, now), clinic: zoneOffset(plan.window.clinicTimeZone, now) }; const windows = allowedWindows(plan.window, now, offsets); const window: T.CheckInWindowInput = ctx.body?.window?.kind === "window" ? ctx.body.window : { kind: "now" }; let startAt = now.getTime(); if (window.kind === "now") { if (!canStartNow(windows, now)) throw new HttpError(409, "NO_WINDOW", `Yasmar cannot ring ${p.base.firstName} now. Choose a time inside the allowed window.`); } else { startAt = Date.parse(window.start); const end = Date.parse(window.end); if (!windows.some((w) => w.start.getTime() <= startAt && end <= w.end.getTime() && startAt < end)) throw invalid("That time is outside the allowed window. Choose one of the times offered."); } const expiresInHours = clampExpiry(Number(ctx.body?.expiresInHours), plan.window.maxExpiryHours); const closingIds: string[] = Array.isArray(ctx.body?.closingIds) ? ctx.body.closingIds : ["sent"]; const id = nextId("ci"); const call = newCall( blankCall(id, p, now.toISOString(), { questions, closingIds, window, expiresAt: new Date(now.getTime() + expiresInHours * 3_600_000).toISOString(), linkedFindingIds: linked, linkedTiers: findings.filter((f) => linked.includes(f.id)).map((f) => f.tier), readToday: ctx.body?.readToday === true, }), findings.filter((f) => linked.includes(f.id)).map((f) => f.tier), ); ctx.store.calls.set(id, call); const variant = ctx.store.nextVariant ?? p.variant; ctx.store.nextVariant = null; const steps = script({ variant, patient: { name: p.base.name, firstName: p.base.firstName, dateOfBirthSpoken: spokenDate(p.base.dateOfBirth), preferredTitle: p.base.preferredTitle }, plan, questions, closings: closingIds, emergency: ctx.store.clinic.emergencyNumbers.map((e) => e.number).join(" or "), clinicPhone: ctx.store.clinic.phone, doctor: ctx.store.clinician.spokenName, }).slice(1); // The request itself is the "sent" status. play(call, steps, ctx.store.options.speed, startAt); logActivity(p, clinicianName(ctx.store), "clinician", `Dr Okafor asked Yasmar to check in with ${p.base.firstName} (${questions.length} questions).`); return snapshotOf(ctx.store, call); }, 201), route("GET", "/check-ins/:id", "patients", (ctx) => snapshotOf(ctx.store, callOf(ctx))), route("GET", "/check-ins/:id/updates", "patients", (ctx) => { const call = callOf(ctx); const after = Number(ctx.url.searchParams.get("after") ?? 0); return { events: call.events.filter((e) => e.id > after), lastEventId: call.snapshot.lastEventId }; }), route("POST", "/check-ins/:id/read", "patients", (ctx) => { const call = callOf(ctx); call.readAt = new Date().toISOString(); return { readAt: call.readAt }; }), route("POST", "/check-ins/:id/cancel", "patients", (ctx) => { const call = callOf(ctx); if (!["sent", "ringing", "later", "missed", "ended_early"].includes(call.snapshot.status)) throw new HttpError(409, "CHECK_IN_NOT_OPEN", call.snapshot.status === "in_progress" ? "The call is in progress." : "This check-in has already ended."); call.timers.forEach(clearTimeout); emit(call, { type: "status", at: new Date().toISOString(), status: "cancelled", detail: "cancelled_by_clinician", later: null }); return snapshotOf(ctx.store, call); }), route("POST", "/check-ins/:id/notes/:note/acknowledge", "patients", (ctx) => { const call = callOf(ctx); const note = need(call.snapshot.notes.find((n) => n.id === decodeURIComponent(ctx.params[1])), "That note"); if (!call.snapshot.urgent) throw new HttpError(409, "NOTHING_URGENT", "This check-in has no urgent note."); // As the backend: the alert is the check-in's, so every urgent note is acknowledged at once. const at = new Date().toISOString(); for (const n of call.snapshot.notes.filter((x) => x.urgent && !x.acknowledgedAt)) emit(call, { type: "note_updated", at, note: { ...n, acknowledgedAt: at, acknowledgedBy: clinicianName(ctx.store) } }); call.snapshot = { ...call.snapshot, urgentAcknowledgedAt: call.snapshot.urgentAcknowledgedAt ?? at }; const p = ctx.store.patients.get(call.snapshot.patientId); if (p) logActivity(p, clinicianName(ctx.store), "clinician", "Dr Okafor acknowledged an urgent check-in note."); return call.snapshot.notes.find((n) => n.id === note.id)!; }), route("POST", "/check-ins/:id/notes/:note/add-to-check", "patients", (ctx) => addOneToCheck(ctx, callOf(ctx), decodeURIComponent(ctx.params[1]))), // As the backend's batch route: one call record holds every chosen note. route("POST", "/check-ins/:id/add-to-check", "patients", (ctx) => { const call = callOf(ctx); const ids = ((ctx.body ?? {}) as { noteIds?: unknown }).noteIds; if (!Array.isArray(ids) || !ids.length) throw new HttpError(400, "VALIDATION_ERROR", "Choose at least one note."); const added = ids.map((noteId) => addOneToCheck(ctx, call, String(noteId))); return { recordId: added[0].recordId, notes: added.map((a) => a.note), note: added[0].note }; }), // As the backend: one alert per check-in with an urgent note nobody has acknowledged; its id is the check-in's. route("GET", "/alerts", "patients", ({ store }) => ({ alerts: [...store.calls.values()].flatMap((c) => { const n = c.snapshot.notes.find((x) => x.urgent && !x.acknowledgedAt); return n ? [{ id: c.snapshot.id, checkInId: c.snapshot.id, patientId: c.snapshot.patientId, patientName: c.snapshot.patientName, noteId: n.id, text: n.text, at: n.at, acknowledgedAt: null }] : []; }), })), ];