// 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(method: string, url: string, body?: unknown, options: Options = {}): Promise { 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; 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 = (path: string, options?: Options) => request("GET", `${BASE}${path}`, undefined, options); const post = (path: string, body: unknown = {}, options?: Options) => request("POST", `${BASE}${path}`, body, options); const put = (path: string, body: unknown) => request("PUT", `${BASE}${path}`, body); const patch = (path: string, body: unknown) => request("PATCH", `${BASE}${path}`, body); const del = (path: string) => request("DELETE", `${BASE}${path}`); const id = encodeURIComponent; /** A switched-off area (404 *_NOT_ENABLED) reads as empty rather than as an error. */ const offIsEmpty = async (call: Promise, empty: R): Promise => { 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 (call: Promise): Promise => 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("/me", { quietAuth: true }); const session = async () => A.sessionFromMe(await me()); const recordTitles = async (patientId: string) => A.titlesOf(patientId, (await get(`/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 { return A.receiptFromWire(await post("/access-requests", A.accessBody(input)), input); }, invitePreview: async (token: string) => A.invitePreviewFromWire(await get(`/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 { const started = await post("/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 { const confirmed = await post("/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 { await post("/step-up/verify", { code }, { quietAuth: true }); return session(); }, async lock(): Promise { await post<{ locked: true }>("/session/lock", {}, { quietAuth: true }); return session(); }, async acceptTerms(): Promise { 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 { 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): Promise { return A.clinicFromWire((await patch<{ clinic: W.Clinic }>("/clinic", A.clinicChange(update, current))).clinic); }, // Invitations (INV-11 to INV-14) invitations: async (): Promise => { const list = await offIsEmpty(get("/invitations"), { invitations: [] }); return { invitations: list.invitations.map(A.invitationFromWire) }; }, acceptInvitation: (invitationId: string, input: T.AcceptInvitationInput) => post(`/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 { const list = await get("/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 { const list = await post("/patients/search", { query }); return { patients: list.items.map(A.summaryFromWire), asOf: list.asOf }; }, async createPatient(input: T.NewPatientInput): Promise { 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 { 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(`/patients/${id(patientId)}/shared`)), settle(get(`/patients/${id(patientId)}/check-ins/plan`)), settle(get(`/patients/${id(patientId)}/check-ins`)), ]); return A.detailFromWire(patient, { tile, plan, checkIns }); }, viewed: (patientId: string, section: T.SharedSection | "overview") => post(`/patients/${id(patientId)}/viewed`, { section }), activity: (patientId: string) => get(`/patients/${id(patientId)}/activity`), // Shared from MARMAR Health (SHR-2 to SHR-6); each checks the share's scopes sharedMedicines: (patientId: string) => get(`/patients/${id(patientId)}/shared/medicines`), sharedDoses: (patientId: string): Promise => get(`/patients/${id(patientId)}/shared/doses`), sharedCheckIns: (patientId: string): Promise => get(`/patients/${id(patientId)}/shared/check-ins`), sharedRecords: (patientId: string) => get(`/patients/${id(patientId)}/shared/records`), // Records (REC-1 to REC-7) records: async (patientId: string) => ({ records: (await get(`/patients/${id(patientId)}/records`)).records.map(A.recordFromWire) }), recordLines: (recordId: string) => get(`/records/${id(recordId)}/lines`), requestUpload: (patientId: string, input: T.UploadRequest) => post(`/patients/${id(patientId)}/records/uploads`, input), async uploadFile(ticket: T.UploadTicket, file: Blob): Promise { 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(`/patients/${id(patientId)}/records`, input satisfies W.NewRecordBody)), updateRecord: async (recordId: string, update: T.RecordUpdate) => A.recordFromWire(await patch(`/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(`/records/${id(recordId)}/confirm-patient`)), readRecordAgain: async (recordId: string) => A.recordFromWire(await post(`/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(`/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(`/extractions/${id(extractionId)}`)), saveDecisions: (extractionId: string, input: T.DecisionsInput) => put(`/extractions/${id(extractionId)}/decisions`, A.decisionsBody(input)), addClarification: async (extractionId: string, input: T.ClarificationInput) => extractionView(await post(`/extractions/${id(extractionId)}/clarifications`, input)), // The check (CHK, FND) runCheck: async (patientId: string, input: T.CheckRunInput) => A.checkFromWire(await post(`/patients/${id(patientId)}/checks`, input)), checks: (patientId: string) => get(`/patients/${id(patientId)}/checks`), check: async (checkId: string) => A.checkFromWire(await get(`/checks/${id(checkId)}`)), markFinding: (checkId: string, findingId: string, input: T.FindingMarkInput) => post(`/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(`/patients/${id(patientId)}/check-ins/plan`)), validateQuestion: (input: T.QuestionCheckInput) => post("/check-ins/questions/validate", input), createCheckIn: async (patientId: string, input: T.NewCheckInInput) => C.checkInFromWire(await post(`/patients/${id(patientId)}/check-ins`, input)), checkIns: (patientId: string) => offIsEmpty(get(`/patients/${id(patientId)}/check-ins`), { checkIns: [] }), checkIn: async (checkInId: string) => C.checkInFromWire(await get(`/check-ins/${id(checkInId)}`)), updates: async (checkInId: string, after: number, signal?: AbortSignal) => C.updatesFromWire(await get(`/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(`/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(`/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 { const added = await post(`/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(`/check-ins/${id(checkInId)}/cancel`)), alerts: () => offIsEmpty(get("/alerts", { background: true }), { alerts: [] }), }; export type ClinicianApi = typeof clinicianApi;