// Fixture routes for invitations, patients, shared data, records, reading and checks.
import { randomUUID } from "node:crypto";
import type * as T from "../../src/api/types";
import { modelProvider, transcribe } from "../../server/model";
import { exportLines, printableHtml, readRecords, runCheckRun, toSources, type StoredExtraction } from "./clinical";
import { connectMariam, iso, nextId, type StoredPatient, type StoredRecord } from "./data";
import { clinicianName, HttpError, invalid, need, route, text, type Ctx, type Route } from "./http";
import { logActivity, patientDetail, recordsFor, renamedDeep, type Store } from "./store";
import { fingerprint } from "../../server/context";
import { wireCheck, wireDetail, wireExtraction, wireInvitation, wireItem, wireRecord, wireTile } from "./wire";
const MB = 1024 * 1024;
const ACCEPT: Record<string, string> = {
"text/plain": "TXT",
"application/pdf": "PDF",
"image/jpeg": "JPG",
"image/png": "PNG",
"image/heic": "HEIC",
};
const patientOf = (ctx: Ctx): StoredPatient => need(ctx.store.patients.get(decodeURIComponent(ctx.params[0])), "That patient");
const isStale = (store: Store, ex: StoredExtraction) => fingerprint(toSources(recordsFor(store, ex.patientId))) !== ex.fingerprint;
const publicExtraction = (store: Store, ex: StoredExtraction) => wireExtraction(ex, isStale(store, ex));
const lastCheckAt = (store: Store, p: StoredPatient) => (p.base.lastCheckId ? store.checks.get(p.base.lastCheckId)?.ranAt ?? null : null);
const latestCheck = (store: Store, check: T.CheckRun) => (store.patientChecks.get(check.patientId) ?? []).at(-1) === check.id;
// The backend refuses (409 PATIENT_NOT_VISIBLE / 403) shared reads for a patient that is not connected to this clinician.
const sharedPatient = (ctx: Ctx) => {
const p = patientOf(ctx);
if (p.base.kind !== "connected") throw new HttpError(404, "PATIENT_NOT_VISIBLE", "This patient is not shared with you.");
return p;
};
function shared(ctx: Ctx, section: T.SharedSection | null) {
const p = sharedPatient(ctx);
const s = p.base.sharing;
if (!s || s.status === "stopped" || !p.shared) throw new HttpError(403, "SHARING_STOPPED", "This patient stopped sharing with you.");
if (s.status === "paused") throw new HttpError(403, "SHARING_PAUSED", "This patient paused sharing. New information is hidden until they resume.");
if (section && !s.scopes.includes(section)) throw new HttpError(403, "SCOPE_NOT_SHARED", "This patient does not share this with you.");
// As the backend: the previous look at this section, then this one is recorded.
const lastLookedAt = section ? p.lastLooked[`shared.${section}`] ?? null : null;
if (section) p.lastLooked[`shared.${section}`] = new Date().toISOString();
return { p, asOf: new Date().toISOString(), lastLookedAt };
}
function addRecord(store: Store, p: StoredPatient, fields: Omit<StoredRecord, "id" | "patientId" | "addedAt" | "addedBy">): StoredRecord {
const rec: StoredRecord = { ...fields, id: nextId("rec"), patientId: p.base.id, addedAt: new Date().toISOString(), addedBy: clinicianName(store) };
store.records.set(rec.id, rec);
return rec;
}
export const patientRoutes: Route[] = [
route("GET", "/invitations", "patients", ({ store }) => ({
invitations: [...store.invitations.values()].map(({ token: _t, connect: _c, ...inv }) => wireInvitation(inv)),
})),
route("POST", "/invitations/:id/accept", "patients", (ctx) => {
const inv = need(ctx.store.invitations.get(ctx.params[0]), "That invitation");
if (ctx.body?.confirmedMyPatient !== true) throw invalid("Check the highlighted fields.", [{ path: "confirmedMyPatient", message: "Invalid input: expected true" }]);
const link = typeof ctx.body.linkToPatientId === "string" ? ctx.body.linkToPatientId : null;
let patient: StoredPatient;
if (inv.id === "inv-mariam") {
const existing = ctx.store.patients.get("p-mariam")!;
if (link === "p-mariam") patient = existing;
else {
patient = { ...existing, base: { ...existing.base, id: "p-mariam-app" }, activity: [] };
ctx.store.patients.set(patient.base.id, patient);
}
const appRecord = renamedDeep(connectMariam(patient, Date.now()));
appRecord.id = `rec-${patient.base.id}-app`;
appRecord.patientId = patient.base.id;
ctx.store.records.set(appRecord.id, appRecord);
patient.newSinceLastCheck = true;
patient.base.newInformation = { since: new Date().toISOString(), items: ["Medicines, allergies and doses from MARMAR Health"] };
} else {
patient = inv.connect();
ctx.store.patients.set(patient.base.id, patient);
}
const by = inv.withPerson ? `${inv.patientName.split(" ")[0]} and ${inv.withPerson.name}` : inv.patientName.split(" ")[0];
logActivity(patient, by, "patient", `${by} invited Dr Okafor from MARMAR Health.`);
logActivity(patient, clinicianName(ctx.store), "clinician", `Dr Okafor accepted and confirmed ${inv.patientName} is his patient${link ? ", linked to the clinic record" : ""}.`);
ctx.store.invitations.delete(inv.id);
return { patientId: patient.base.id, shareId: inv.id };
}),
route("POST", "/invitations/:id/decline", "patients", (ctx) => {
const inv = need(ctx.store.invitations.get(ctx.params[0]), "That invitation");
const reason = ctx.body?.reason ?? null;
if (reason !== null && !["not_my_patient", "not_for_context", "not_using_context", "other"].includes(reason)) throw invalid("Check the highlighted fields.", [{ path: "reason", message: "Invalid option" }]);
ctx.store.invitations.delete(inv.id);
return { declined: true };
}),
route("GET", "/patients", "patients", ({ store }) => ({
items: [...store.patients.values()].map((p) => wireItem(patientDetail(store, p), lastCheckAt(store, p))),
asOf: new Date().toISOString(),
})),
route("POST", "/patients/search", "patients", (ctx) => {
const q = text(ctx.body?.query, 120).toLowerCase();
if (!q) throw invalid("Check the highlighted fields.", [{ path: "query", message: "Too small" }]);
return {
items: [...ctx.store.patients.values()].filter((p) => p.base.name.toLowerCase().includes(q)).map((p) => wireItem(patientDetail(ctx.store, p), lastCheckAt(ctx.store, p))),
asOf: new Date().toISOString(),
};
}),
route("POST", "/patients", "patients", (ctx) => {
const name = text(ctx.body?.displayName, 120);
if (name.length < 2) throw invalid("Check the highlighted fields.", [{ path: "displayName", message: "Too small" }]);
const a = ctx.body?.attestation;
if (a?.careRelationship !== true || a?.patientAgreement !== true || a?.version !== "2026-10-30")
throw invalid("Check the highlighted fields.", [{ path: "attestation", message: "Confirm the care relationship and the patient's agreement" }]);
const dob = typeof ctx.body.dateOfBirth === "string" ? ctx.body.dateOfBirth : null;
if (!dob === (ctx.body.ageYears === undefined)) throw invalid("Check the highlighted fields.", [{ path: "dateOfBirth", message: "Give a date of birth or an age" }]);
const id = `p-${randomUUID().slice(0, 8)}`;
const p: StoredPatient = {
base: {
id, kind: "clinic_added", name, firstName: name.split(" ")[0],
age: dob ? Math.floor((Date.now() - Date.parse(dob)) / (365.25 * 86_400_000)) : Number(ctx.body.ageYears),
sharing: null, lastActivityAt: new Date().toISOString(), dateOfBirth: dob, birthYear: dob ? Number(dob.slice(0, 4)) : null,
sex: ctx.body.sex === "female" || ctx.body.sex === "male" ? ctx.body.sex : null, phone: null, pronoun: null, preferredTitle: null,
language: "en", hasOwnAccount: false, identityOnFile: dob ? "date_of_birth" : "none", timeZone: "Africa/Lagos",
quietHours: { start: "22:00", end: "07:00" }, blockedTimes: [], lastCheckId: null, newInformation: null,
},
shared: null, lastDaily: null, lastLooked: {}, newSinceLastCheck: false, usedThisWeekBefore: 0, variant: "film", activity: [],
};
logActivity(p, clinicianName(ctx.store), "clinician", `Dr Okafor added ${name} and confirmed a care relationship and the patient's agreement.`);
ctx.store.patients.set(id, p);
// As the backend: 201 with the bare patient row, not the detail.
const { attention: _a, lastCheckId: _l, newInformation: _n, sharing: _s, medicines: _m, doses: _d, lastCheckIn: _c, name: _name, age: _age, ...row } = wireItem(patientDetail(ctx.store, p));
return { patient: row };
}, 201),
route("GET", "/patients/:id", "patients", (ctx) => {
const p = patientOf(ctx);
return { patient: wireDetail(patientDetail(ctx.store, p), lastCheckAt(ctx.store, p)) };
}),
route("GET", "/patients/:id/shared", "patients", (ctx) => wireTile(patientDetail(ctx.store, shared(ctx, null).p))),
route("POST", "/patients/:id/viewed", "patients", (ctx) => {
const p = patientOf(ctx);
const section = text(ctx.body?.section, 20);
if (!["medicines", "doses", "check_ins", "records", "overview"].includes(section)) throw invalid("Check the highlighted fields.", [{ path: "section", message: "Invalid option" }]);
const lastLookedAt = p.lastLooked[`viewed.${section}`] ?? null;
p.lastLooked[`viewed.${section}`] = new Date().toISOString();
return { lastLookedAt };
}),
route("GET", "/patients/:id/activity", "patients", (ctx) => ({ events: [...patientOf(ctx).activity].reverse() })),
route("GET", "/patients/:id/shared/medicines", "patients", (ctx) => {
const { p, asOf, lastLookedAt } = shared(ctx, "medicines");
return { ...p.shared!.medicines, asOf, lastLookedAt };
}),
route("GET", "/patients/:id/shared/doses", "patients", (ctx) => {
const { p, asOf, lastLookedAt } = shared(ctx, "doses");
return { ...p.shared!.doses, asOf, lastLookedAt, complete: true };
}),
route("GET", "/patients/:id/shared/check-ins", "patients", (ctx) => {
const { p, asOf, lastLookedAt } = shared(ctx, "check_ins");
return { daily: p.shared!.daily, requested: [], asOf, lastLookedAt };
}),
route("GET", "/patients/:id/shared/records", "patients", (ctx) => {
const { p, asOf, lastLookedAt } = shared(ctx, "records");
return { ...p.shared!.records, asOf, lastLookedAt };
}),
route("GET", "/patients/:id/records", "patients", (ctx) => ({
records: recordsFor(ctx.store, patientOf(ctx).base.id).map(wireRecord),
})),
route("GET", "/records/:id/lines", "patients", (ctx) => {
const rec = need(ctx.store.records.get(ctx.params[0]), "That record");
return {
recordId: rec.id,
lines: (rec.text ?? "").split("\n").map((line, i) => ({ number: i + 1, text: line, page: null, box: null, confidence: null, hardToRead: false, handwritten: false })),
};
}),
route("PATCH", "/records/:id", "patients", (ctx) => {
const rec = need(ctx.store.records.get(ctx.params[0]), "That record");
if (rec.origin === "app" || rec.origin === "check_in") throw new HttpError(403, "READ_ONLY", "Records from MARMAR Health and check-ins cannot be edited.");
const title = text(ctx.body?.title, 120);
if (ctx.body?.title !== undefined && !title) throw invalid("A record needs a title.");
if (title) rec.title = title;
if (ctx.body?.type) rec.type = ctx.body.type;
if (ctx.body?.place !== undefined) rec.place = text(ctx.body.place, 120) || null;
if (ctx.body?.recordDate !== undefined) rec.recordDate = text(ctx.body.recordDate, 10) || null;
return wireRecord(rec);
}),
route("POST", "/records/:id/confirm-patient", "patients", (ctx) => {
const rec = need(ctx.store.records.get(ctx.params[0]), "That record");
if (!rec.heldBack) throw new HttpError(409, "NOT_HELD_BACK", "This record is not held back.");
rec.heldBack = null;
return wireRecord(rec);
}),
route("POST", "/records/:id/read-again", "patients", (ctx) => {
const rec = need(ctx.store.records.get(ctx.params[0]), "That record");
if (rec.reading !== "failed") throw new HttpError(409, "NOT_FAILED", "Only a record that could not be read is read again.");
return wireRecord(rec);
}),
route("DELETE", "/records/:id", "patients", (ctx) => {
const rec = need(ctx.store.records.get(ctx.params[0]), "That record");
if (rec.origin === "app") throw new HttpError(403, "READ_ONLY", "Records from MARMAR Health are removed in the app.");
ctx.store.records.delete(rec.id);
return { deleted: true, removedAt: new Date().toISOString(), reading: "unchanged" };
}),
route("POST", "/patients/:id/records/uploads", "patients", (ctx) => {
const p = patientOf(ctx);
const fileName = text(ctx.body?.fileName, 200);
const size = Number(ctx.body?.sizeBytes);
const type = text(ctx.body?.contentType, 100);
if (!ACCEPT[type]) throw invalid(`${fileName} is not a file Context reads. Use TXT, PDF, JPG, PNG or HEIC.`);
if (size > 15 * MB)
throw new HttpError(400, "FILE_TOO_LARGE", `${fileName} is ${(size / MB).toFixed(1)} MB, ${((size - 15 * MB) / MB).toFixed(1)} MB over the 15 MB limit per file.`);
const uploadId = randomUUID();
ctx.store.uploads.set(uploadId, { patientId: p.base.id, fileName, contentType: type, sizeBytes: size, data: null });
return { uploadId, uploadUrl: `${ctx.url.origin}/__fixture/uploads/${uploadId}`, method: "PUT", headers: { "Content-Type": type }, expiresAt: iso(Date.now() + 15 * 60_000), maxBytes: 15 * MB };
}, 201),
route("POST", "/patients/:id/records", "patients", async (ctx) => {
const p = patientOf(ctx);
if (recordsFor(ctx.store, p.base.id).length >= 20)
throw new HttpError(400, "TOO_MANY_RECORDS", "A check can use up to 20 records. Remove one before adding another.");
const title = text(ctx.body?.title, 120) || "Untitled record";
const type = ["prescription", "lab_result", "discharge_summary", "clinic_note", "other"].includes(ctx.body?.type) ? ctx.body.type : "other";
const common = { title, type, place: text(ctx.body?.place, 120) || null, recordDate: text(ctx.body?.recordDate, 10) || null, pages: null };
let rec: StoredRecord;
if (ctx.body?.uploadId) {
const up = need(ctx.store.uploads.get(ctx.body.uploadId), "That upload");
if (!up.data) throw invalid("The file has not finished uploading. Try again.");
const isText = up.contentType === "text/plain";
// Photos and PDFs: Meta Muse Spark transcribes them when it is the configured model.
const byModel = !isText && modelProvider() === "meta";
const body = isText ? up.data.toString("utf8").replace(/\r\n/g, "\n").trim() : byModel ? await transcribe(up.data, up.contentType) : null;
const read = isText || byModel;
rec = addRecord(ctx.store, p, {
...common, origin: ctx.body.origin === "camera" ? "camera" : "upload", fileName: up.fileName, contentType: up.contentType, sizeBytes: up.sizeBytes,
reading: read ? "read" : "failed",
readingError: read ? null : "The fixture reads text files only. Scans and photos are read on MARMAR's servers.",
readingErrorCode: read ? null : "OCR_NOT_CONFIGURED", heldBack: null, clarification: null,
lineCount: body ? body.split("\n").length : 0, text: body,
});
ctx.store.uploads.delete(ctx.body.uploadId);
} else {
const body = typeof ctx.body?.text === "string" ? ctx.body.text.replace(/\r\n/g, "\n").trim() : "";
if (!body) throw invalid("Paste or type the record's text.");
if (body.length > 12000) throw invalid("This note is longer than 12,000 characters. Split it into smaller records.");
rec = addRecord(ctx.store, p, { ...common, origin: "pasted", fileName: null, contentType: "text/plain", sizeBytes: body.length, reading: "read", readingError: null,
readingErrorCode: null, heldBack: null, clarification: null, lineCount: body.split("\n").length, text: body });
}
logActivity(p, clinicianName(ctx.store), "clinician", `Dr Okafor added the record "${rec.title}".`);
return wireRecord(rec);
}, 201),
route("POST", "/patients/:id/extractions", "patients", async (ctx) => {
const p = patientOf(ctx);
const records = recordsFor(ctx.store, p.base.id);
if (!records.some((r) => r.reading === "read")) throw new HttpError(400, "NO_READABLE_RECORDS", "Add at least one readable record first.");
const ex = await readRecords(p.base.id, records);
const previous = ctx.store.extractions.get(ctx.store.latestExtraction.get(p.base.id) ?? "");
if (previous) // Keep decisions for facts that read exactly the same.
for (const f of ex.facts) {
const old = previous.facts.find((o) => o.id === f.id && o.label === f.label && o.detail === f.detail && o.quote === f.quote);
if (old && previous.decisions[old.id]) ex.decisions[f.id] = previous.decisions[old.id];
}
ctx.store.extractions.set(ex.id, ex);
ctx.store.latestExtraction.set(p.base.id, ex.id);
return publicExtraction(ctx.store, ex);
}, 201),
route("GET", "/patients/:id/extractions/latest", "patients", (ctx) => {
const ex = ctx.store.extractions.get(ctx.store.latestExtraction.get(patientOf(ctx).base.id) ?? "");
return { extraction: ex ? publicExtraction(ctx.store, ex) : null };
}),
route("GET", "/extractions/:id", "patients", (ctx) => publicExtraction(ctx.store, need(ctx.store.extractions.get(ctx.params[0]), "That reading"))),
route("PUT", "/extractions/:id/decisions", "patients", (ctx) => {
const ex = need(ctx.store.extractions.get(ctx.params[0]), "That reading");
if (ctx.body?.revision !== ex.revision)
throw new HttpError(409, "STALE_REVISION", "This review was changed in another tab. Load the latest version before you go on.");
const confirmed: string[] = Array.isArray(ctx.body?.confirmedHardToRead) ? ctx.body.confirmedHardToRead : [];
const decisions = ctx.body?.decisions ?? {};
if (confirmed.some((id) => !ex.facts.some((f) => f.id === id && f.hardToRead) || decisions[id] !== "include"))
throw invalid("Only an included, hard-to-read fact can be confirmed.");
if (Object.entries(decisions).some(([id, d]) => !ex.facts.some((f) => f.id === id) || (d !== "include" && d !== "exclude")))
throw invalid("A decision names a fact outside this reading.");
ex.decisions = decisions;
ex.coverageReview = Object.fromEntries(
Object.entries(ctx.body?.coverageReview ?? {}).map(([id, v]) => [id, { ...(v as object), confirmedBy: clinicianName(ctx.store) }]),
) as T.Extraction["coverageReview"];
ex.acknowledgedOriginals = ctx.body?.acknowledgedOriginals === true;
ex.confirmedHardToRead = confirmed;
ex.revision += 1;
return { revision: ex.revision, savedAt: new Date().toISOString() };
}),
route("POST", "/extractions/:id/clarifications", "patients", async (ctx) => {
const ex = need(ctx.store.extractions.get(ctx.params[0]), "That reading");
const p = need(ctx.store.patients.get(ex.patientId), "That patient");
const original = need(ctx.store.records.get(text(ctx.body?.recordId, 80)), "That record");
const line = Number(ctx.body?.line);
const quote = (original.text ?? "").split("\n")[line - 1]?.trim();
const value = text(ctx.body?.text, 500);
const reason = text(ctx.body?.reason, 500);
if (!quote || quote !== text(ctx.body?.quote, 12000)) throw invalid("The original line changed. Read the records again.");
if (!value) throw invalid("Write the fact you are adding.");
if (reason.length < 3) throw invalid("Say how you confirmed it.");
addRecord(ctx.store, p, {
title: `Clarification · line ${line} of ${original.title}`, type: "clinic_note", place: null, recordDate: original.recordDate, pages: null,
origin: "pasted", fileName: null, contentType: "text/plain", sizeBytes: value.length, reading: "read", readingError: null, readingErrorCode: null,
heldBack: null, clarification: { clarificationId: nextId("clar") }, lineCount: 1, text: value,
clarifies: { sourceId: original.id, line, quote, reason, confirmedBy: clinicianName(ctx.store) },
});
logActivity(p, clinicianName(ctx.store), "clinician", `Dr Okafor added a clarification to "${original.title}".`);
const next = await readRecords(p.base.id, recordsFor(ctx.store, p.base.id));
for (const f of next.facts) if (ex.decisions[f.id] && ex.facts.some((o) => o.id === f.id && o.label === f.label && o.detail === f.detail)) next.decisions[f.id] = ex.decisions[f.id];
ctx.store.extractions.set(next.id, next);
ctx.store.latestExtraction.set(p.base.id, next.id);
return publicExtraction(ctx.store, next);
}, 201),
route("POST", "/patients/:id/checks", "patients", (ctx) => {
const p = patientOf(ctx);
const ex = need(ctx.store.extractions.get(text(ctx.body?.extractionId, 80)), "That reading");
if (ex.recordsNotReady.length)
throw new HttpError(409, "RECORDS_NOT_READY", `Some records are not ready for the check: ${ex.recordsNotReady.map((r) => `${r.title} (${r.status.replace("_", " ")})`).join(", ")}.`);
if (isStale(ctx.store, ex)) throw new HttpError(409, "STALE_EXTRACTION", "The records changed after they were read. Read them again.");
if (ex.facts.some((f) => f.hardToRead && ex.decisions[f.id] === "include" && !ex.confirmedHardToRead.includes(f.id)))
throw new HttpError(400, "HARD_TO_READ_NOT_CONFIRMED", "Check each included Hard to read value against the original first.");
if (ctx.body?.revision !== ex.revision) throw new HttpError(409, "STALE_REVISION", "This review was changed in another tab. Load the latest version first.");
let check: T.CheckRun;
const newMeds = (p.shared?.medicines.medicines ?? []).filter((m) => m.startDate && Date.now() - Date.parse(m.startDate) < 21 * 86_400_000).map((m) => m.name.toLowerCase());
try {
const reactions = Object.fromEntries((p.shared?.medicines.allergies ?? []).map((a) => [a.substance.toLowerCase(), { reaction: a.reaction, onset: a.onset, year: a.year }]));
check = runCheckRun(ex, recordsFor(ctx.store, p.base.id), clinicianName(ctx.store), !!p.base.sharing?.withheldSome, newMeds, reactions);
} catch (error) {
throw invalid(error instanceof Error ? error.message : "The check could not run.");
}
ctx.store.checks.set(check.id, check);
ctx.store.patientChecks.set(p.base.id, [...(ctx.store.patientChecks.get(p.base.id) ?? []), check.id]);
p.base.lastCheckId = check.id;
p.newSinceLastCheck = false;
p.base.newInformation = null;
logActivity(p, clinicianName(ctx.store), "clinician", `Dr Okafor ran the check: ${check.assessment.contextual.findings.length} findings for review.`);
return wireCheck(check, true);
}, 201),
route("GET", "/patients/:id/checks", "patients", (ctx) => ({
checks: (ctx.store.patientChecks.get(patientOf(ctx).base.id) ?? [])
.map((id) => ctx.store.checks.get(id)!)
.map(({ id, ranAt, ranBy, assessment }) => ({ id, ranAt, ranBy, findings: assessment.contextual.findings.length, added: assessment.addedRuleIds.length }))
.reverse(),
})),
route("GET", "/checks/:id", "patients", (ctx) => {
const check = need(ctx.store.checks.get(ctx.params[0]), "That check");
return wireCheck(check, latestCheck(ctx.store, check));
}),
route("POST", "/checks/:id/findings/:finding/mark", "patients", (ctx) => {
const check = need(ctx.store.checks.get(ctx.params[0]), "That check");
const findingId = decodeURIComponent(ctx.params[1]);
if (!check.assessment.contextual.findings.some((f) => f.id === findingId)) throw new HttpError(404, "FINDING_NOT_FOUND", "That finding is not part of this check.");
const kind = ctx.body?.kind;
if (!["reviewed", "not_relevant", "problem"].includes(kind)) throw invalid("Choose Reviewed, Not relevant or Report a problem.");
const reason = text(ctx.body?.reason, 500) || null;
const note = text(ctx.body?.note, 1000) || null;
if (kind === "not_relevant" && (!reason || reason.length < 3)) throw new HttpError(400, "REASON_REQUIRED", "Say why this finding is not relevant.");
if (kind === "problem" && !note && !reason) throw new HttpError(400, "DESCRIPTION_REQUIRED", "Describe the problem for MARMAR's clinical team.");
const mark: T.FindingMark = { findingId, kind, note, reason, by: clinicianName(ctx.store), at: new Date().toISOString() };
check.marks.push(mark);
const p = ctx.store.patients.get(check.patientId);
if (p) logActivity(p, mark.by, "clinician", `Dr Okafor marked a finding ${kind.replace("_", " ")}.`);
return mark;
}, 201),
route("GET", "/checks/:id/export", "patients", (ctx) => {
const check = need(ctx.store.checks.get(ctx.params[0]), "That check");
const p = need(ctx.store.patients.get(check.patientId), "That patient");
const format = ctx.url.searchParams.get("format") ?? "json";
if (!["json", "html", "pdf"].includes(format)) throw invalid("Check the highlighted fields.", [{ path: "format", message: "Invalid option" }]);
logActivity(p, clinicianName(ctx.store), "clinician", `Dr Okafor exported the check as ${format.toUpperCase()}.`);
const name = `marmar-context-${p.base.name.toLowerCase().replace(/\s+/g, "-")}-${check.ranAt.slice(0, 10)}`;
// As the backend: pdf and html both answer the printable page.
if (format !== "json") {
const html = printableHtml("MARMAR Context: check evidence", exportLines(check, p.base.name, check.ranBy, recordsFor(ctx.store, p.base.id)));
ctx.res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Content-Disposition": `inline; filename="${name}.html"`, "X-Export-Format": "html",
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" });
ctx.res.end(html);
return undefined;
}
ctx.res.writeHead(200, { "Content-Type": "application/json", "Content-Disposition": `attachment; filename="${name}.json"` });
ctx.res.end(JSON.stringify({ product: "MARMAR Context", schemaVersion: 1, exportedAt: new Date().toISOString(), exportedBy: clinicianName(ctx.store), patient: { name: p.base.name, age: p.base.age, dateOfBirth: p.base.dateOfBirth },
clinic: { name: ctx.store.clinic.name }, check: wireCheck(check, latestCheck(ctx.store, check)), informationTool: check.informationTool }, null, 2));
return undefined;
}),
];