marmar-context / web / context / test / fixture / routes-checkins.ts
routes-checkins.ts
Raw
// 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 }] : [];
    }),
  })),
];