marmar-context / web / context / src / api / wire.ts
wire.ts
Raw
// The JSON the MARMAR backend sends and takes under /api/clinician (src/clinician/** in marmar-cds).
// These mirror the backend's serializers, field for field; the adapters (adapt*.ts) turn them into
// the view types in types.ts. When the backend changes a shape, change it here first.
import type { CoverageCandidate } from "../coverage";
import type { Assessment, Draft } from "../types";
import type * as T from "./types";

export type Pronoun = "he" | "she" | "they";
/** 1 to 7, Monday first (src/clinician/profile.ts, check-ins/windows.ts). */
export type WeeklyHours = { day: number; open: string; close: string }[];

// ---------------------------------------------------------------- access (public)

/** POST /access-requests (src/clinician/access-requests.ts, strict). */
export type AccessRequestBody = {
  fullName: string;
  profession: "doctor" | "pharmacist";
  licenceBody: string;
  licenceNumber: string;
  countryCode: string;
  facilityName: string;
  facilityPhone: string;
  workEmail: string;
  mobilePhone: string;
  heardFrom: "film" | "patient_invite" | "colleague" | "other";
  heardFromDetail?: string;
  acceptTerms: true;
  termsVersion: string;
  inviteCode?: string;
};
/** 202, whatever is on file. */
export type AccessRequestReceived = { status: "received"; message: string };

/** GET /invite-preview/:token (sharing/invitations.ts previewInvitation). */
export type InvitePreview = {
  status: T.InvitePreview["status"];
  patientName: string;
  withPerson: { name: string; relation: string } | null;
  doctorName: string;
  clinicName: string | null;
  doctorEmail: string | null;
  expiresAt: string;
};

// ---------------------------------------------------------------- sign-in and profile

/** GET /me (profile.ts). Needs a clinician session, not the step-up. */
export type Me = {
  clinician: Clinician & { lapsed: boolean };
  session: { stepUpDone: boolean; locked: boolean; idleLockMinutes: number; startedAt: string; endsAt: string };
  secondFactor: { totp: boolean };
  features: { realPatients: boolean; invites: boolean; checkIns: boolean };
};
export type Clinician = {
  id: string;
  email: string;
  /** As on the register: "Dr Emeka Okafor". */
  fullName: string;
  title: string | null;
  /** How Yasmar says it: "Doctor Okafor". */
  displayName: string;
  pronoun: Pronoun | null;
  profession: "doctor" | "pharmacist";
  licenceBody: string;
  licenceNumber: string;
  countryCode: string;
  mobilePhone: string | null;
  status: "verified" | "suspended" | "closed";
  verifiedAt: string | null;
  verificationExpiresAt: string;
  idleLockMinutes: number;
  termsVersion: string | null;
  termsAcceptedAt: string | null;
  dpaVersion: string | null;
  dpaAcceptedAt: string | null;
};
/** PATCH /me (strict; needs the step-up). */
export type MeChange = Partial<{ title: string | null; displayName: string; pronoun: Pronoun | null; mobilePhone: string | null; idleLockMinutes: 15 | 30 | 60 }>;
/** POST /me/terms. */
export type TermsBody = { termsVersion: string; dpaVersion: string };

/** GET /step-up. */
export type StepUpState = { totpEnrolled: boolean; recoveryCodesLeft: number; stepUpDone: boolean; locked: boolean; pausedUntil: string | null };
/** POST /step-up/totp (201). */
/** qrSvg is drawn by the backend (src/clinician/qr.ts), never by a third party. */
export type TotpStart = { secret: string; otpauthUrl: string; qrSvg?: string };
/** POST /step-up/totp/confirm. */
export type TotpConfirmed = { stepUpDone: true; recoveryCodes: string[] };
/** POST /step-up/verify. */
export type StepUpVerified = { stepUpDone: true; method: "totp" | "recovery_code" };

export type EmergencyNumber = { number: string; label: string; source: "clinic" | "registry"; testDialledAt: string | null; registryRevision?: number | null };
/** GET /clinic, PATCH /clinic: { clinic }. */
export type Clinic = {
  id: string;
  name: string;
  addressLine: string | null;
  city: string | null;
  region: string | null;
  countryCode: string;
  timezone: string;
  phone: string | null;
  openingHours: WeeklyHours | null;
  alertWatchHours: WeeklyHours | null;
  backupAlertName: string | null;
  backupAlertPhone: string | null;
  backupAlertEmail: string | null;
  version: number;
  updatedAt: string;
  role: "owner" | "member";
  emergencyNumbers: EmergencyNumber[];
};
/** PATCH /clinic (strict; owner only; needs the step-up). */
export type ClinicChange = Partial<{
  expectedVersion: number;
  name: string;
  addressLine: string | null;
  city: string | null;
  region: string | null;
  timezone: string;
  phone: string | null;
  openingHours: WeeklyHours;
  alertWatchHours: WeeklyHours;
  backupAlertName: string | null;
  backupAlertPhone: string | null;
  backupAlertEmail: string | null;
  emergencyNumbers: { number: string; label: string; testDialledAt: string | null }[] | null;
}>;

// ---------------------------------------------------------------- patients

/** sharedBy and withheldSome came with the tile (SHR-1); older backends left them out. */
export type Sharing = { status: "connected" | "paused" | "stopped"; scopes: T.ShareScope[]; allowsCheckInCalls: boolean; changedAt: string;
  sharedBy?: string[]; withheldSome?: boolean };
/** GET /patients { items, asOf } and POST /patients/search (workspace/patients.ts listItem). */
export type PatientItem = {
  id: string;
  kind: "clinic_added" | "connected";
  clinicId: string;
  displayName: string;
  dateOfBirth: string | null;
  birthYear: number | null;
  ageYears: number | null;
  ageRecordedOn: string | null;
  sex: "female" | "male" | "other" | null;
  shareId: string | null;
  lastActivityAt: string;
  createdAt: string;
  name: string;
  age: number | null;
  attention: { urgent: boolean; unreadNotes: number; newSinceLastCheck: boolean };
  lastCheckId: string | null;
  lastCheckAt: string | null;
  newInformation: { since: string; items: string[] } | null;
  sharing: Sharing | null;
  medicines: { count: number } | null;
  doses: { taken: number; expected: number; onTime: number } | null;
  lastCheckIn: T.LastCheckIn | null;
};
export type PatientList = { items: PatientItem[]; asOf: string };
/** GET /patients/:id { patient } (workspace/patients.ts detailOf). */
export type PatientDetail = PatientItem & {
  firstName: string;
  dateOfBirthIso: string | null;
  identityOnFile: "date_of_birth" | "birth_year" | "none";
  timeZone: string;
  hasOwnAccount: boolean;
  phone: string | null;
  pronoun: Pronoun | null;
  preferredTitle: string | null;
  activeCheckInId: string | null;
};
/** POST /patients (strict). 201 { patient } is the bare row, not the detail. */
export type NewPatientBody = {
  displayName: string;
  dateOfBirth?: string;
  ageYears?: number;
  sex?: "female" | "male" | "other";
  attestation: { careRelationship: true; patientAgreement: true; version: string };
};
export type ActivityList = { events: { id: string; at: string; actor: string; actorKind: "clinician"; action: string }[] };
/** POST /patients/:id/viewed. */
export type Viewed = { lastLookedAt: string | null };

// ---------------------------------------------------------------- shared from MARMAR Health (sharing/shared-data.ts)

/** GET /patients/:id/shared: the tile. */
export type SharedSummary = {
  asOf: string;
  sharing: { status: "connected"; sharedBy: string[]; scopes: T.ShareScope[]; allowsCheckInCalls: boolean; withheldSome: boolean; changedAt: string };
  medicines: { count: number } | null;
  doses: { taken: number; expected: number; onTime: number } | null;
  lastCheckIn: T.LastCheckIn | null;
  phone: string | null;
};
export type SharedMedicines = T.SharedMedicines;
export type SharedDoses = T.SharedDoses & { complete: boolean };
export type SharedCheckIns = Omit<T.SharedCheckIns, "requested"> & { requested: CheckInListItem[] };
export type SharedRecords = T.SharedRecords;

// ---------------------------------------------------------------- invitations (sharing/invitations.ts)

export type Invitation = {
  id: string;
  patientName: string;
  patientAge: number | null;
  withPerson: { name: string; relation: string } | null;
  scopes: T.ShareScope[];
  allowsCheckInCalls: boolean;
  withheldSome: boolean;
  sentAt: string;
  expiresAt: string;
  possibleMatch: { patientId: string; name: string; dateOfBirth: string | null } | null;
};
export type InvitationList = { invitations: Invitation[] };
export type Accepted = { patientId: string; shareId: string };
/** POST /invitations/:id/decline (strict): not_for_context is stored as not_using_context. */
export type DeclineBody = { reason?: "not_my_patient" | "not_for_context" | "other" | null; note?: string | null };

// ---------------------------------------------------------------- records (workspace/records.ts)

export type Record_ = {
  id: string;
  patientId: string;
  title: string;
  type: T.RecordType;
  place: string | null;
  recordDate: string | null;
  addedBy: string;
  addedAt: string;
  /** "clarification" records read as "pasted". */
  origin: T.RecordOrigin;
  fileName: null;
  contentType: string | null;
  sizeBytes: number | null;
  pages: number | null;
  reading: "queued" | "reading" | "read" | "failed";
  readingError: string | null;
  readingErrorCode: string | null;
  lineCount: number;
  heldBack: { reason: string } | null;
  clarification: { clarificationId: string } | null;
};
export type Records = { records: Record_[] };
export type UploadTicket = T.UploadTicket;
/** POST /patients/:id/records (strict). */
export type NewRecordBody = {
  text?: string | null;
  uploadId?: string | null;
  title?: string;
  type: T.RecordType;
  place?: string | null;
  recordDate?: string | null;
  origin: "upload" | "pasted" | "camera";
};

// ---------------------------------------------------------------- reading and review (workspace/review.ts extractionView)

export type Extraction = Draft & {
  id: string;
  patientId: string;
  revision: number;
  createdAt: string;
  records: { recordId: string; status: "queued" | "reading" | "read" | "failed" | "held_back"; error: string | null; inReading: boolean }[];
  decisions: Record<string, "include" | "exclude">;
  coverageReview: Record<string, { fingerprint: string; choice?: "not_current" | "not_relevant"; reason: string; confirmedBy: string }>;
  acknowledgedOriginals: boolean;
  confirmedHardToRead: string[];
  stale: boolean;
  recordsNotReady: { recordId: string; title: string; status: string }[];
  coverage?: (CoverageCandidate & { kind: "medicine" | "allergy" | "kidney" })[];
};
/** PUT /extractions/:id/decisions (strict). */
export type DecisionsBody = {
  revision: number;
  decisions: Record<string, "include" | "exclude">;
  coverageReview: Record<string, { choice?: "not_current" | "not_relevant"; reason?: string; fingerprint?: string; confirmedBy?: string }>;
  acknowledgedOriginals: boolean;
  confirmedHardToRead: string[];
};

// ---------------------------------------------------------------- the check (workspace/checks.ts checkView)

export type CheckRun = Omit<T.CheckRun, "assessment" | "summary"> & {
  assessment: Assessment;
  summary: { before: number; after: number; added: number; headline: string; subline: string };
  profile: unknown[];
  medicines: unknown[];
  sourceLines: { recordId: string; recordTitle: string; page: number | null; line: number; text: string; hardToRead: boolean }[];
};
export type CheckRunList = T.CheckRunList;
export type FindingMark = T.FindingMark;

// ---------------------------------------------------------------- check-ins (check-ins/*)

/** GET /patients/:id/check-ins/plan (check-in-plan.ts planTemplate). */
export type PlanTemplate = Omit<T.CheckInPlanTemplate, "window" | "limits" | "maxQuestions"> & {
  /** Older servers leave it out; 10. */
  maxQuestions?: number;
  window: {
    patientTimeZone: string | null;
    clinicTimeZone: string;
    clinicHours?: WeeklyHours;
    alertWatchHours?: WeeklyHours;
    dayStart?: string;
    dayEnd?: string;
    /** null: the patient turned quiet hours off. */
    quietHours: { start: string; end: string } | null;
    blockedTimes?: { day: number | null; start: string; end: string; label?: string }[];
    defaultExpiryHours: number;
    maxExpiryHours: number;
  };
  limits: T.CheckInLimits | null;
};
export type CheckInStatus = T.CheckInStatus;
export type CheckInNote = T.CheckInNote & { validation: "pending" | "matched" | "unmatched" };
/** GET /check-ins/:id (check-in-clinician.ts checkInForClinician). */
export type CheckIn = {
  id: string;
  patientId: string;
  patientName: string;
  requestedBy: string;
  requestedAt: string;
  /** linkedTiers: the tiers of the linked findings (older servers leave it out). */
  plan: Omit<T.CheckIn["plan"], "linkedTiers"> & { linkedTiers?: string[] };
  script: { opening: string; identity: string[]; consent: string; feeling: string; alwaysLast: string; closing: string[]; protocolVersion: string };
  status: CheckInStatus;
  statusAt: string;
  history: T.StatusChange[];
  window: { start: string; end: string };
  later: { start: string; end: string } | null;
  urgent: boolean;
  urgentAcknowledgedAt: string | null;
  notes: CheckInNote[];
  summary: T.CheckInSummary | null;
  transcript: T.TranscriptLine[];
  /** The alerts already sent (urgent sent, escalated, not reached), so a reload keeps them. */
  alerts?: { id: number; at: string; kind: string | null; message: string | null }[];
  lastEventId: number;
};
export type CheckInListItem = T.CheckInListItem;
export type CheckInList = { checkIns: CheckInListItem[] };
export type CheckInEvent =
  | { id: number; type: "status"; at: string; status: CheckInStatus; detail: string | null; later: { start: string; end: string } | null }
  | { id: number; type: "note" | "note_updated"; at: string; note: CheckInNote }
  | { id: number; type: "transcript"; at: string; line: T.TranscriptLine }
  | { id: number; type: "summary"; at: string; summary: T.CheckInSummary }
  | { id: number; type: "urgent"; at: string; noteId: string; reason: string }
  | { id: number; type: "alert"; at: string; kind: string | null; message: string | null };
export type CheckInUpdates = { events: CheckInEvent[]; lastEventId: number };
/** POST /check-ins/:id/notes/:noteId/add-to-check. */
export type AddedToCheck = { note: CheckInNote; recordId: string };
/** POST /check-ins/:id/add-to-check { noteIds }: one record from the call holds every chosen note. */
export type AddedNotesToCheck = { recordId: string; notes: CheckInNote[]; note: CheckInNote };
export type AlertList = { alerts: T.UrgentAlert[] };
export type QuestionCheck = T.QuestionCheck;

// ---------------------------------------------------------------- errors

/** Every error: { code, message }, with details on 400 VALIDATION_ERROR and extra fields on some sharing errors. */
export type ErrorBody = { code: string; message: string; details?: { path: string; message: string }[]; eligibility?: string; phone?: string | null };