// 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<typeof createStore>;
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 = <T>(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<string, Session>(),
patients: new Map(data.patients.map((p) => [p.base.id, p])),
invitations: new Map<string, Seed["invitations"][number]>(data.invitations.map((i) => [i.id, i])),
records: new Map(data.records.map((r) => [r.id, r])),
extractions: new Map<string, StoredExtraction>(),
latestExtraction: new Map<string, string>(),
checks: new Map<string, T.CheckRun>(),
patientChecks: new Map<string, string[]>(),
calls: new Map<string, CallRecord>(),
uploads: new Map<string, { patientId: string; fileName: string; contentType: string; sizeBytes: number; data: Buffer | null }>(),
accessRequests: [] as unknown[],
};
seedCalls(store, now);
return store;
}
type Base = { calls: Map<string, CallRecord>; patients: Map<string, StoredPatient> };
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);
}