// In-memory state for the fixture server. Nothing is written to disk. import { createHmac } from "node:crypto"; import type * as T from "../../src/api/types"; import { checkInEligibility } from "../../src/logic/eligibility"; import { emit, finishedCall, newCall, type CallRecord, type Variant } from "./calls"; import type { StoredExtraction } from "./clinical"; import { clinicianSeed, clinicSeed, FIXTURE_TOTP_CODE, FIXTURE_TOTP_SECRET, iso, seed, type Seed, type StoredPatient, type StoredRecord } from "./data"; export type Session = { token: string; email: string; stepUp: boolean; locked: boolean; createdAt: number; lastActiveAt: number }; export type Options = { alwaysOpen: boolean; speed: number; noSse: boolean }; export type Store = ReturnType; const DAY = 86_400_000; // FIXTURE_RENAME="Mariam=Bola" renames a fictional person in the stored data too, so records agree (demo film). const renames = (process.env.FIXTURE_RENAME ?? "").split(",").filter((r) => r.includes("=")).map((r) => r.split("=") as [string, string]); export const renamedDeep = (value: T): T => { if (typeof value === "string") return renames.reduce((t, [from, to]) => t.replaceAll(from, to), value as string) as T; if (Array.isArray(value)) return value.map(renamedDeep) as T; if (value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, renamedDeep(v)])) as T; return value; }; export function createStore(options: Options, now = Date.now()) { const data = renames.length ? renamedDeep(seed(now)) : seed(now); const store = { options, clinician: clinicianSeed(), clinic: clinicSeed(options.alwaysOpen), totpEnrolled: false, totpRecoveryCodes: [] as string[], nextVariant: null as Variant | null, sessions: new Map(), patients: new Map(data.patients.map((p) => [p.base.id, p])), invitations: new Map(data.invitations.map((i) => [i.id, i])), records: new Map(data.records.map((r) => [r.id, r])), extractions: new Map(), latestExtraction: new Map(), checks: new Map(), patientChecks: new Map(), calls: new Map(), uploads: new Map(), accessRequests: [] as unknown[], }; seedCalls(store, now); return store; } type Base = { calls: Map; patients: Map }; export function blankCall(id: string, patient: StoredPatient, requestedAt: string, plan: T.CheckIn["plan"]): T.CheckIn { return { id, patientId: patient.base.id, patientName: patient.base.name, requestedBy: "Dr Emeka Okafor", requestedAt, plan, status: "sent", statusAt: requestedAt, history: [{ status: "sent", at: requestedAt, detail: null }], later: null, window: { start: requestedAt, end: plan.expiresAt }, urgent: false, urgentAcknowledgedAt: null, notes: [], summary: null, transcript: [], alerts: [], lastEventId: 0, }; } const emptyPlan = (expiresAt: string): T.CheckIn["plan"] => ({ questions: [], closingIds: ["sent"], window: { kind: "now" }, expiresAt, linkedFindingIds: [], linkedTiers: [], readToday: false, }); /** Ngozi's call this morning left an urgent note nobody has acknowledged; Femi's has unread notes. */ function seedCalls(store: Base, now: number) { const ngozi = store.patients.get("p-ngozi")!; const start = now - 70 * 60_000; const a = newCall(blankCall("ci-ngozi-1", ngozi, iso(start), emptyPlan(iso(start + DAY))), []); finishedCall( a, start, [ { speaker: "yasmar", text: "How have you been feeling?" }, { speaker: "patient", text: "I fainted at the market yesterday. Today I feel dizzy when I stand." }, { speaker: "yasmar", text: "That could be serious. Please call 767 or 112 now, or go to the nearest hospital. Is someone with you?" }, { speaker: "patient", text: "My sister is with me." }, ], [ { kind: "red_flag", text: "Fainted at the market yesterday; dizzy when standing today", quote: "I fainted at the market yesterday. Today I feel dizzy when I stand.", line: 1, urgent: true }, { kind: "symptom", text: "Her sister is with her", quote: "My sister is with me.", line: 3 }, ], ); store.calls.set(a.snapshot.id, a); const femi = store.patients.get("p-femi")!; const yesterday = now - 26 * 3_600_000; const b = newCall(blankCall("ci-femi-1", femi, iso(yesterday), emptyPlan(iso(yesterday + DAY))), []); finishedCall( b, yesterday, [ { speaker: "yasmar", text: "Many people miss doses. Did you miss any this week?" }, { speaker: "patient", text: "Three evenings. I work late." }, { speaker: "yasmar", text: "Anything else Doctor Okafor should know?" }, { speaker: "patient", text: "Can I take the Metformin with agbo?" }, ], [ { kind: "how_taken", text: "Missed 3 evening Metformin doses this week; works late", quote: "Three evenings. I work late.", line: 1 }, { kind: "patient_question", text: "Femi asks: can he take the Metformin with agbo?", quote: "Can I take the Metformin with agbo?", line: 3 }, ], ); store.calls.set(b.snapshot.id, b); // Femi's older check-ins, more than a week back, each ended a different way (YCI-28). const past: [number, T.CheckInStatus[], string | null][] = [ [9, ["ringing", "declined"], null], [12, ["ringing", "missed", "not_reached"], "expired"], [16, ["cancelled"], "cancelled_by_clinician"], [20, ["ringing", "in_progress", "identity_not_confirmed"], null], ]; for (const [daysAgo, path, detail] of past) { const at = now - daysAgo * DAY; const call = newCall(blankCall(`ci-femi-${daysAgo}d`, femi, iso(at), emptyPlan(iso(at + DAY))), []); path.forEach((status, i) => emit(call, { type: "status", at: iso(at + (i + 1) * 40_000), status, detail: i === path.length - 1 ? detail : null, later: null }), ); store.calls.set(call.snapshot.id, call); } } const ended: T.CheckInStatus[] = ["completed", "declined", "not_reached", "identity_not_confirmed", "cancelled"]; const lagosDate = (ms: number) => new Date(ms + 60 * 60_000).toISOString().slice(0, 10); export function callsFor(store: Store, patientId: string) { return [...store.calls.values()].filter((c) => c.snapshot.patientId === patientId); } export function unreadNotes(c: CallRecord) { return c.snapshot.notes.filter((n) => !c.readAt || n.at > c.readAt).length; } /** The live patient view: counts, attention and limits are worked out on every read. */ export function patientDetail(store: Store, p: StoredPatient, now = Date.now()): T.PatientDetail { const live = !!p.base.sharing && p.base.sharing.status !== "stopped"; const scopes = live ? p.base.sharing!.scopes : []; const calls = callsFor(store, p.base.id).filter((c) => c.snapshot.status !== "cancelled"); // As the backend: this clinician's latest check-in in any state, or a shared daily one. const lastCall = [...calls].sort((a, b) => a.snapshot.requestedAt.localeCompare(b.snapshot.requestedAt)).at(-1); const candidates: T.PatientSummary["lastCheckIn"][] = [ lastCall ? { at: lastCall.snapshot.statusAt, kind: "yasmar_call", status: lastCall.snapshot.status } : null, scopes.includes("check_ins") && p.lastDaily ? { at: p.lastDaily, kind: "daily", status: null } : null, ]; const lastCheckIn = candidates.filter(Boolean).sort((a, b) => b!.at.localeCompare(a!.at))[0] ?? null; const usedToday = calls.filter((c) => lagosDate(Date.parse(c.snapshot.requestedAt)) === lagosDate(now)).length; const usedThisWeek = p.usedThisWeekBefore + calls.filter((c) => now - Date.parse(c.snapshot.requestedAt) < 7 * DAY).length; const tomorrow8 = Date.parse(`${lagosDate(now + DAY)}T07:00:00Z`); return { ...p.base, medicines: scopes.includes("medicines") && p.shared ? { count: p.shared.medicines.medicines.filter((m) => m.status === "current").length } : null, doses: scopes.includes("doses") && p.shared ? p.shared.doses.week : null, lastCheckIn, attention: { urgent: calls.some((c) => c.snapshot.notes.some((n) => n.urgent && !n.acknowledgedAt)), unreadNotes: calls.reduce((sum, c) => sum + unreadNotes(c), 0), newSinceLastCheck: p.newSinceLastCheck, }, checkInLimits: { usedToday, usedThisWeek, maxPerDay: 1, maxPerWeek: 3, nextPossibleAt: usedToday >= 1 ? iso(tomorrow8) : usedThisWeek >= 3 ? iso(now + 2 * DAY) : null, }, activeCheckInId: calls.find((c) => !ended.includes(c.snapshot.status))?.snapshot.id ?? null, }; } export const summaryOf = (d: T.PatientDetail): T.PatientSummary => ({ id: d.id, kind: d.kind, name: d.name, age: d.age, pronoun: d.pronoun, sharing: d.sharing, medicines: d.medicines, doses: d.doses, lastCheckIn: d.lastCheckIn, attention: d.attention, lastActivityAt: d.lastActivityAt, }); export const eligibilityFor = (store: Store, p: StoredPatient) => checkInEligibility(patientDetail(store, p)); export function recordsFor(store: Store, patientId: string): StoredRecord[] { return [...store.records.values()].filter((r) => r.patientId === patientId).sort((a, b) => a.addedAt.localeCompare(b.addedAt)); } export function logActivity(p: StoredPatient, actor: string, actorKind: T.ActivityEvent["actorKind"], action: string) { p.activity.push({ id: `act-${Date.now().toString(36)}-${p.activity.length}`, at: new Date().toISOString(), actor, actorKind, action }); p.base.lastActivityAt = new Date().toISOString(); } function base32(secret: string): Buffer { const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; let bits = ""; for (const c of secret.replace(/=+$/, "")) bits += alphabet.indexOf(c).toString(2).padStart(5, "0"); const bytes = []; for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.slice(i, i + 8), 2)); return Buffer.from(bytes); } function totp(counter: number): string { const buf = Buffer.alloc(8); buf.writeBigUInt64BE(BigInt(counter)); const mac = createHmac("sha1", base32(FIXTURE_TOTP_SECRET)).update(buf).digest(); const offset = mac[mac.length - 1] & 0xf; return String((mac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000).padStart(6, "0"); } /** The fixture accepts a real authenticator code for its secret, or its fixed code. */ export function totpValid(code: string, now = Date.now()): boolean { const step = Math.floor(now / 30_000); return code === FIXTURE_TOTP_CODE || [-1, 0, 1].some((d) => totp(step + d) === code); }