marmar-context / web / context / src / api / client.ts
client.ts
Raw
// 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;