// Typed client for /api/clinician (and the clinician Better Auth instance) on the MARMAR backend.
// It sends and reads the backend's own shapes (wire.ts) and hands the screens view types (types.ts).
// Every call sends the session cookie; sign-in, lock and terms refusals raise a window event the gate handles.
import * as A from "./adapt";
import * as C from "./adapt-checkins";
import type * as T from "./types";
import type * as W from "./wire";
/** Empty in the same-origin setup; set VITE_CONTEXT_API_ORIGIN when the API is elsewhere. */
export const API_ORIGIN = (import.meta.env?.VITE_CONTEXT_API_ORIGIN as string | undefined) ?? "";
const BASE = `${API_ORIGIN}/api/clinician`;
const AUTH = `${API_ORIGIN}/api/auth/clinician`;
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details?: unknown,
) {
super(message);
}
}
/** Raised so the gate can show sign-in, the lock screen or the terms. */
export const AUTH_EVENT = "context-auth";
/** 401s: sign in again. 403s: the session is fine but locked, needs the terms, or the clinician cannot see patients now. */
export const AUTH_CODES = new Set([
"SIGN_IN_REQUIRED",
"SESSION_EXPIRED",
"ACCOUNT_CLOSED",
"STEP_UP_REQUIRED",
"SESSION_LOCKED",
"TERMS_REQUIRED",
"CLINICIAN_SUSPENDED",
"VERIFICATION_LAPSED",
]);
/** background: a poll, not a person's action, so it does not count as activity for the idle lock. */
type Options = { signal?: AbortSignal; quietAuth?: boolean; background?: boolean };
async function request<R>(method: string, url: string, body?: unknown, options: Options = {}): Promise<R> {
let response: Response;
try {
response = await fetch(url, {
method,
credentials: "include",
signal: options.signal ?? AbortSignal.timeout(30_000),
headers: {
Accept: "application/json",
...(body === undefined ? {} : { "Content-Type": "application/json" }),
...(options.background ? { "X-Context-Background": "1" } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new ApiError(0, "NETWORK", "Context could not reach MARMAR. Check your connection and try again.");
}
const text = await response.text();
let data: unknown = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!response.ok) {
const err = (data ?? {}) as Partial<W.ErrorBody>;
const code = err.code ?? `HTTP_${response.status}`;
if ((response.status === 401 || response.status === 403) && AUTH_CODES.has(code) && !options.quietAuth && typeof window !== "undefined")
window.dispatchEvent(new CustomEvent(AUTH_EVENT, { detail: code }));
const details = err.details ?? (err.eligibility || err.phone !== undefined ? { eligibility: err.eligibility, phone: err.phone } : undefined);
throw new ApiError(response.status, code, err.message ?? "The request could not be completed. Try again.", details);
}
return data as R;
}
const get = <R>(path: string, options?: Options) => request<R>("GET", `${BASE}${path}`, undefined, options);
const post = <R>(path: string, body: unknown = {}, options?: Options) => request<R>("POST", `${BASE}${path}`, body, options);
const put = <R>(path: string, body: unknown) => request<R>("PUT", `${BASE}${path}`, body);
const patch = <R>(path: string, body: unknown) => request<R>("PATCH", `${BASE}${path}`, body);
const del = <R>(path: string) => request<R>("DELETE", `${BASE}${path}`);
const id = encodeURIComponent;
/** A switched-off area (404 *_NOT_ENABLED) reads as empty rather than as an error. */
const offIsEmpty = async <R>(call: Promise<R>, empty: R): Promise<R> => {
try {
return await call;
} catch (error) {
if (error instanceof ApiError && error.status === 404 && /_NOT_ENABLED$/.test(error.code)) return empty;
throw error;
}
};
const settle = async <R>(call: Promise<R>): Promise<R | null> => call.catch(() => null);
// Browser notifications are this browser's choice; the backend stores none.
const PREFS_KEY = "marmar-context:notifications";
const browserPrefs = (): { browser: boolean } => {
try {
return { browser: localStorage.getItem(PREFS_KEY) === "browser" };
} catch {
return { browser: false };
}
};
const saveBrowserPref = (on: boolean) => {
try {
if (on) localStorage.setItem(PREFS_KEY, "browser");
else localStorage.removeItem(PREFS_KEY);
} catch {
// Private windows may refuse storage; the setting then lasts for this page only.
}
};
const me = () => get<W.Me>("/me", { quietAuth: true });
const session = async () => A.sessionFromMe(await me());
const recordTitles = async (patientId: string) => A.titlesOf(patientId, (await get<W.Records>(`/patients/${id(patientId)}/records`)).records);
const extractionView = async (wire: W.Extraction) => A.extractionFromWire(wire, await recordTitles(wire.patientId));
export const clinicianApi = {
// Access (public)
async requestAccess(input: T.AccessRequestInput): Promise<T.AccessRequestReceipt> {
return A.receiptFromWire(await post<W.AccessRequestReceived>("/access-requests", A.accessBody(input)), input);
},
invitePreview: async (token: string) => A.invitePreviewFromWire(await get<W.InvitePreview>(`/invite-preview/${id(token)}`, { quietAuth: true })),
// Sign-in: Better Auth email one-time code, then MARMAR's step-up (ACC-7)
sendCode: (email: string) =>
request<{ success: boolean }>("POST", `${AUTH}/email-otp/send-verification-otp`, { email, type: "sign-in" }),
verifyCode: (email: string, otp: string) =>
request<{ token: string | null }>("POST", `${AUTH}/sign-in/email-otp`, { email, otp }),
signOut: () => request<{ success: boolean }>("POST", `${AUTH}/sign-out`, {}, { quietAuth: true }),
session,
async enrolTotp(): Promise<T.TotpEnrolment> {
const started = await post<W.TotpStart>("/step-up/totp", {}, { quietAuth: true });
const url = new URL(started.otpauthUrl);
const label = decodeURIComponent(url.pathname.replace(/^\/+totp\//, "").replace(/^\/+/, ""));
return { secret: started.secret, otpauthUrl: started.otpauthUrl, qrSvg: started.qrSvg ?? null, issuer: url.searchParams.get("issuer") ?? "MARMAR Context", account: label.split(":").pop() ?? label };
},
/** The first code from a new authenticator: turns it on and returns the recovery codes, once. */
async confirmTotp(code: string): Promise<T.TotpConfirmed> {
const confirmed = await post<W.TotpConfirmed>("/step-up/totp/confirm", { code }, { quietAuth: true });
return { session: await session(), recoveryCodes: confirmed.recoveryCodes };
},
/** A 6-digit code or a recovery code, to sign in or to unlock. */
async verifyTotp(code: string): Promise<T.ClinicianSession> {
await post<W.StepUpVerified>("/step-up/verify", { code }, { quietAuth: true });
return session();
},
async lock(): Promise<T.ClinicianSession> {
await post<{ locked: true }>("/session/lock", {}, { quietAuth: true });
return session();
},
async acceptTerms(): Promise<T.ClinicianSession> {
await post<{ clinician: W.Clinician }>("/me/terms", { termsVersion: A.TERMS_VERSION, dpaVersion: A.DPA_VERSION } satisfies W.TermsBody);
return session();
},
signOutEverywhere: async () => ({ revoked: (await post<{ signedOut: number }>("/sessions/sign-out-everywhere")).signedOut }),
// Profile and clinic (ACC-8, ACC-9)
me: async () => A.profileFromWire((await me()).clinician, browserPrefs()),
async updateMe(update: T.ClinicianProfileUpdate): Promise<T.ClinicianProfile> {
if (update.notifications) saveBrowserPref(update.notifications.browser);
const change = A.meChange(update);
if (!change) return A.profileFromWire((await me()).clinician, browserPrefs());
return A.profileFromWire((await patch<{ clinician: W.Clinician }>("/me", change)).clinician, browserPrefs());
},
clinic: async () => A.clinicFromWire((await get<{ clinic: W.Clinic }>("/clinic")).clinic),
async updateClinic(update: T.ClinicProfileUpdate, current: Pick<T.ClinicProfile, "version">): Promise<T.ClinicProfile> {
return A.clinicFromWire((await patch<{ clinic: W.Clinic }>("/clinic", A.clinicChange(update, current))).clinic);
},
// Invitations (INV-11 to INV-14)
invitations: async (): Promise<T.InvitationList> => {
const list = await offIsEmpty(get<W.InvitationList>("/invitations"), { invitations: [] });
return { invitations: list.invitations.map(A.invitationFromWire) };
},
acceptInvitation: (invitationId: string, input: T.AcceptInvitationInput) =>
post<W.Accepted>(`/invitations/${id(invitationId)}/accept`, input),
declineInvitation: (invitationId: string, input: T.DeclineInvitationInput) =>
post<{ declined: true }>(`/invitations/${id(invitationId)}/decline`, input satisfies W.DeclineBody),
// Patients (PAT-2, PAT-3, PAT-4)
async patients(): Promise<T.PatientList> {
const list = await get<W.PatientList>("/patients");
return { patients: list.items.map(A.summaryFromWire), asOf: list.asOf };
},
/** Names go in the body, never the URL (PAT-2). */
async searchPatients(query: string): Promise<T.PatientList> {
const list = await post<W.PatientList>("/patients/search", { query });
return { patients: list.items.map(A.summaryFromWire), asOf: list.asOf };
},
async createPatient(input: T.NewPatientInput): Promise<T.PatientDetail> {
const created = await post<{ patient: { id: string } }>("/patients", A.newPatientBody(input));
return clinicianApi.patient(created.patient.id);
},
/** A connected patient's page also reads the shared tile, the check-in plan and this clinician's check-ins. */
async patient(patientId: string): Promise<T.PatientDetail> {
const { patient } = await get<{ patient: W.PatientDetail }>(`/patients/${id(patientId)}`);
if (patient.kind !== "connected" || patient.sharing?.status !== "connected") return A.detailFromWire(patient);
const [tile, plan, checkIns] = await Promise.all([
settle(get<W.SharedSummary>(`/patients/${id(patientId)}/shared`)),
settle(get<W.PlanTemplate>(`/patients/${id(patientId)}/check-ins/plan`)),
settle(get<W.CheckInList>(`/patients/${id(patientId)}/check-ins`)),
]);
return A.detailFromWire(patient, { tile, plan, checkIns });
},
viewed: (patientId: string, section: T.SharedSection | "overview") =>
post<W.Viewed>(`/patients/${id(patientId)}/viewed`, { section }),
activity: (patientId: string) => get<T.ActivityList>(`/patients/${id(patientId)}/activity`),
// Shared from MARMAR Health (SHR-2 to SHR-6); each checks the share's scopes
sharedMedicines: (patientId: string) => get<W.SharedMedicines>(`/patients/${id(patientId)}/shared/medicines`),
sharedDoses: (patientId: string): Promise<T.SharedDoses> => get<W.SharedDoses>(`/patients/${id(patientId)}/shared/doses`),
sharedCheckIns: (patientId: string): Promise<T.SharedCheckIns> => get<W.SharedCheckIns>(`/patients/${id(patientId)}/shared/check-ins`),
sharedRecords: (patientId: string) => get<W.SharedRecords>(`/patients/${id(patientId)}/shared/records`),
// Records (REC-1 to REC-7)
records: async (patientId: string) => ({ records: (await get<W.Records>(`/patients/${id(patientId)}/records`)).records.map(A.recordFromWire) }),
recordLines: (recordId: string) => get<T.RecordLines>(`/records/${id(recordId)}/lines`),
requestUpload: (patientId: string, input: T.UploadRequest) =>
post<W.UploadTicket>(`/patients/${id(patientId)}/records/uploads`, input),
async uploadFile(ticket: T.UploadTicket, file: Blob): Promise<void> {
const response = await fetch(ticket.uploadUrl, { method: ticket.method, headers: ticket.headers, body: file });
if (!response.ok) throw new ApiError(response.status, "UPLOAD_FAILED", "The file did not upload. Try again.");
},
addRecord: async (patientId: string, input: T.NewRecordInput) =>
A.recordFromWire(await post<W.Record_>(`/patients/${id(patientId)}/records`, input satisfies W.NewRecordBody)),
updateRecord: async (recordId: string, update: T.RecordUpdate) => A.recordFromWire(await patch<W.Record_>(`/records/${id(recordId)}`, update)),
/** REC-7: the clinician confirms a held-back record is this patient's. */
confirmRecordPatient: async (recordId: string) => A.recordFromWire(await post<W.Record_>(`/records/${id(recordId)}/confirm-patient`)),
readRecordAgain: async (recordId: string) => A.recordFromWire(await post<W.Record_>(`/records/${id(recordId)}/read-again`)),
/** A soft delete: its facts leave review and the next check (the backend reads the rest again). */
removeRecord: (recordId: string) =>
del<{ deleted: true; removedAt?: string; reading?: "read_again" | "set_aside" | "unchanged" }>(`/records/${id(recordId)}`),
// Reading and review (REV)
startExtraction: async (patientId: string) => extractionView(await post<W.Extraction>(`/patients/${id(patientId)}/extractions`, {})),
async latestExtraction(patientId: string): Promise<{ extraction: T.Extraction | null }> {
const { extraction } = await get<{ extraction: W.Extraction | null }>(`/patients/${id(patientId)}/extractions/latest`);
return { extraction: extraction ? await extractionView(extraction) : null };
},
extraction: async (extractionId: string) => extractionView(await get<W.Extraction>(`/extractions/${id(extractionId)}`)),
saveDecisions: (extractionId: string, input: T.DecisionsInput) =>
put<T.DecisionsSaved>(`/extractions/${id(extractionId)}/decisions`, A.decisionsBody(input)),
addClarification: async (extractionId: string, input: T.ClarificationInput) =>
extractionView(await post<W.Extraction>(`/extractions/${id(extractionId)}/clarifications`, input)),
// The check (CHK, FND)
runCheck: async (patientId: string, input: T.CheckRunInput) => A.checkFromWire(await post<W.CheckRun>(`/patients/${id(patientId)}/checks`, input)),
checks: (patientId: string) => get<W.CheckRunList>(`/patients/${id(patientId)}/checks`),
check: async (checkId: string) => A.checkFromWire(await get<W.CheckRun>(`/checks/${id(checkId)}`)),
markFinding: (checkId: string, findingId: string, input: T.FindingMarkInput) =>
post<W.FindingMark>(`/checks/${id(checkId)}/findings/${id(findingId)}/mark`, input),
/** pdf: the backend answers a printable page (it has no PDF library); the browser saves it as a PDF. */
exportUrl: (checkId: string, format: "pdf" | "json") => `${BASE}/checks/${id(checkId)}/export?format=${format}`,
// Check-ins (YCI). The plan comes from the latest check; linked findings go in the create body.
planTemplate: async (patientId: string, _checkId?: string | null, _findingId?: string | null) =>
C.planFromWire(await get<W.PlanTemplate>(`/patients/${id(patientId)}/check-ins/plan`)),
validateQuestion: (input: T.QuestionCheckInput) => post<W.QuestionCheck>("/check-ins/questions/validate", input),
createCheckIn: async (patientId: string, input: T.NewCheckInInput) =>
C.checkInFromWire(await post<W.CheckIn>(`/patients/${id(patientId)}/check-ins`, input)),
checkIns: (patientId: string) => offIsEmpty(get<W.CheckInList>(`/patients/${id(patientId)}/check-ins`), { checkIns: [] }),
checkIn: async (checkInId: string) => C.checkInFromWire(await get<W.CheckIn>(`/check-ins/${id(checkInId)}`)),
updates: async (checkInId: string, after: number, signal?: AbortSignal) =>
C.updatesFromWire(await get<W.CheckInUpdates>(`/check-ins/${id(checkInId)}/updates?after=${after}`, { signal, background: true })),
eventsUrl: (checkInId: string, after: number) => `${BASE}/check-ins/${id(checkInId)}/events?lastEventId=${after}`,
acknowledgeNote: async (checkInId: string, noteId: string) =>
C.noteFromWire(await post<W.CheckInNote>(`/check-ins/${id(checkInId)}/notes/${id(noteId)}/acknowledge`)),
/** The chosen notes become one record from the call (YCI-27). */
async addNotesToCheck(checkInId: string, noteIds: string[]): Promise<{ notes: T.CheckInNote[]; recordId: string }> {
const added = await post<W.AddedNotesToCheck>(`/check-ins/${id(checkInId)}/add-to-check`, { noteIds });
return { notes: added.notes.map(C.noteFromWire), recordId: added.recordId };
},
async addNoteToCheck(checkInId: string, noteId: string): Promise<T.AddToCheckResult> {
const added = await post<W.AddedToCheck>(`/check-ins/${id(checkInId)}/notes/${id(noteId)}/add-to-check`);
return { note: C.noteFromWire(added.note), recordId: added.recordId };
},
markCheckInRead: (checkInId: string) => post<{ readAt: string }>(`/check-ins/${id(checkInId)}/read`),
cancelCheckIn: async (checkInId: string) => C.checkInFromWire(await post<W.CheckIn>(`/check-ins/${id(checkInId)}/cancel`)),
alerts: () => offIsEmpty(get<W.AlertList>("/alerts", { background: true }), { alerts: [] }),
};
export type ClinicianApi = typeof clinicianApi;