// 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<typeof callOf>, 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 }] : [];
}),
})),
];