marmar-context / web / context / docs / api-contract.md
api-contract.md
Raw

MARMAR Context: the clinician API as Context calls it

The clinician screens in web/context call the MARMAR backend under /api/clinician and the clinician Better Auth instance under /api/auth/clinician. The backend's shapes win: src/api/wire.ts mirrors its JSON field for field (from src/clinician/** in marmar-cds), and the adapters (adapt.ts, adapt-checkins.ts) turn it into the view types the screens read (types.ts and its parts). The typed client is client.ts. The backend's own guides are docs/context-backend.md, docs/context-api.md and docs/clinician-sharing-and-check-ins.md; its machine-readable contract is GET /api/clinician/openapi.json.

test/fixture answers every route below in the same wire shapes with fictional data (npm run fixture), so fixture mode runs through the same client and adapters. If this page and wire.ts disagree, the backend wins: change wire.ts, the adapter and the fixture together.

Conventions

  • Origin. Same origin by default: the Vite dev server proxies /api (CONTEXT_API_PROXY), and a hosted site proxies /api/clinician and /api/auth/clinician to the backend. Or set VITE_CONTEXT_API_ORIGIN at build time to call the backend directly; it then needs the site's origin in CLINICIAN_ALLOWED_ORIGINS (the dev server's http://127.0.0.1:4190 and http://localhost:4190 are allowed outside production). Every call sends cookies (credentials: "include"); every write needs a Context Origin.
  • Errors are { code, message }; validation is 400 VALIDATION_ERROR with details: [{ path, message }]; some sharing errors add fields (eligibility, phone), which the client puts in ApiError.details. A switched-off area answers 404 <AREA>_NOT_ENABLED; the client reads the invitations, alerts and check-in lists as empty then.
  • The gate. 401 SIGN_IN_REQUIRED, SESSION_EXPIRED or ACCOUNT_CLOSED go to sign-in. 403 STEP_UP_REQUIRED or SESSION_LOCKED show the lock screen; 403 TERMS_REQUIRED shows the terms; 403 CLINICIAN_SUSPENDED or VERIFICATION_LAPSED go to sign-in, which says why.
  • Background requests (polling /check-ins/:id/updates, /alerts) send X-Context-Background: 1, so they do not keep the session from locking.
  • Days. The backend counts weekdays 1 to 7 from Monday; Context counts 0 to 6 from Sunday. The adapters convert both ways.

Sign-in, the step-up and the terms (ACC-7, ACC-10)

Method and path Body Answer View
POST /api/auth/clinician/email-otp/send-verification-otp { email, type: "sign-in" } { success }, whoever the email is
POST /api/auth/clinician/sign-in/email-otp { email, otp } sets the session cookie
POST /api/auth/clinician/sign-out {} ends this session
GET /me { clinician, session: { stepUpDone, locked, idleLockMinutes, startedAt, endsAt }, secondFactor: { totp }, features } ClinicianSession (stepUp: enrol with no authenticator, done, else required) and ClinicianProfile
POST /step-up/totp 201 { secret, otpauthUrl, qrSvg } (the QR is drawn by the backend) TotpEnrolment
POST /step-up/totp/confirm { code } { stepUpDone, recoveryCodes }: ten codes, shown once TotpConfirmed
POST /step-up/verify { code }: 6 digits or a recovery code { stepUpDone, method } the session again
POST /session/lock { locked: true } the session again
POST /me/terms { termsVersion, dpaVersion } { clinician } the session again
POST /sessions/sign-out-everywhere { signedOut }

Profile and clinic (ACC-8, ACC-9)

PATCH /me (step-up) takes { title, displayName, pronoun, mobilePhone, idleLockMinutes } and answers { clinician }. The name on the register (fullName) is not editable; Context splits it after the title. Browser notifications are kept in this browser, not on the server. GET /clinic answers { clinic } with addressLine, timezone, openingHours and alertWatchHours (days 1 to 7), backupAlertName/Phone/Email, emergencyNumbers (source: clinic | registry, testDialledAt), role and version. PATCH /clinic is the owner's only (403 CLINIC_OWNER_ONLY), takes expectedVersion (409 CLINIC_CHANGED), and never gets the registry's numbers back.

Access requests and invites (ACC-3, ACC-4, INV-9 to INV-14)

Method and path Body or answer
POST /access-requests (public) { fullName, profession, licenceBody, licenceNumber, countryCode, facilityName, facilityPhone, workEmail, mobilePhone, heardFrom, heardFromDetail?, acceptTerms: true, termsVersion, inviteCode? } → 202 { status: "received", message } whatever is on file
GET /invite-preview/:token (public) { status, patientName, withPerson, doctorName, clinicName, doctorEmail, expiresAt }; no pronoun, so Context says they/them
GET /invitations { invitations: [{ id, patientName, patientAge, withPerson, scopes, allowsCheckInCalls, withheldSome, sentAt, expiresAt, possibleMatch }] }
POST /invitations/:id/accept { confirmedMyPatient: true, linkToPatientId } → { patientId, shareId }
POST /invitations/:id/decline { reason: not_my_patient | not_for_context | other | null, note } → { declined: true }; the note is not kept

Patients (PAT-2 to PAT-7, SHR-1)

Method and path Answer
GET /patients { items, asOf }, sorted by what needs attention. Each connected patient carries the tile: sharing.sharedBy, medicines: { count }, doses: { taken, expected, onTime } and lastCheckIn: { at, kind, status }, each null for an area not shared (the tile reads "Not shared") and all null while paused
POST /patients/search { query } → the same { items, asOf } (names never go in a URL)
POST /patients { displayName, dateOfBirth | ageYears, sex?, attestation: { careRelationship: true, patientAgreement: true, version: "2026-10-30" } } → 201 { patient } (the bare row); the client then reads the detail
GET /patients/:id { patient }: the item plus firstName, dateOfBirthIso, identityOnFile, timeZone (the clinic's), hasOwnAccount, phone, pronoun, activeCheckInId
GET /patients/:id/shared The tile: { asOf, sharing: { sharedBy, scopes, allowsCheckInCalls, withheldSome, … }, medicines, doses, lastCheckIn: { at, kind, status }, phone }. GET /patients, search and GET /patients/:id carry the same medicines, doses, lastCheckIn and sharing.sharedBy for each connected patient while sharing is on
POST /patients/:id/viewed { section } → { lastLookedAt } (the previous look)
GET /patients/:id/activity { events: [{ id, at, actor, actorKind, action }] }

For a connected patient GET /patients/:id carries hasOwnAccount, phone, firstName, language and activeCheckInId; the client still reads the tile, GET /patients/:id/check-ins/plan (eligibility, limits, quiet hours, blocked times, the patient's zone) and GET /patients/:id/check-ins for the rest.

GET /patients/:id/shared/medicines | doses | check-ins | records answer as SharedMedicines and friends (doses adds complete); 403 SHARING_PAUSED, SHARING_STOPPED or SCOPE_NOT_SHARED, 404 PATIENT_NOT_VISIBLE. prescriber is null until the app stores one.

Records (REC-1 to REC-7)

Method and path Body Answer
GET /patients/:id/records { records }: each with reading, readingError, readingErrorCode, heldBack: { reason } | null, clarification, and fileName: null (names are not kept)
POST /patients/:id/records/uploads { fileName, contentType, sizeBytes } 201 { uploadId, uploadUrl, method, headers, expiresAt, maxBytes }
PUT <uploadUrl> the file storage's own answer
POST /patients/:id/records { text } or { uploadId }, title, type, place, recordDate, origin 201, the record
PATCH /records/:id { title, type, place, recordDate } the record
POST /records/:id/confirm-patient the record, no longer held back
POST /records/:id/read-again the record (a failed file)
DELETE /records/:id { deleted: true, removedAt, reading: read_again | set_aside | unchanged }: a soft delete; its facts leave review and the next check. Context asks to confirm first
GET /records/:id/lines { recordId, lines: [{ number, text, page, box, confidence, hardToRead, handwritten }] }

Reading and review (REV)

POST /patients/:id/extractions (201), GET /patients/:id/extractions/latest ({ extraction }), GET /extractions/:id: the preview's Draft plus revision, records: [{ recordId, status (incl. held_back), error, inReading }] (no titles: the client reads them from the records list), recordsNotReady, decisions, coverageReview, acknowledgedOriginals, confirmedHardToRead, stale. Facts carry hardToRead; coverage candidates carry kind (medicine, allergy, kidney).

PUT /extractions/:id/decisions takes { revision, decisions, coverageReview: { id: { choice: not_current \| not_relevant, reason? } }, acknowledgedOriginals, confirmedHardToRead } → { revision, savedAt }; 409 STALE_REVISION from an older tab. A reason is needed for an allergy (10 characters or more) or with no choice. An included Hard to read fact must be confirmed one by one, never in bulk. POST /extractions/:id/clarifications (201) answers the new reading.

The check (CHK, FND)

Method and path Answer
POST /patients/:id/checks { extractionId, revision } 201 CheckRun: the two-pass assessment, summary, details, marks, missingFactors, monitoring, notChecked (with lines), urgentReview, versions (with signedOffOnly), checkedOn, latest, newInformation, informationTool, recordTitles, sourceLines. 409 RECORDS_NOT_READY, STALE_EXTRACTION, STALE_REVISION; 400 with the open gate's code
GET /patients/:id/checks { checks: [{ id, ranAt, ranBy, findings, added }] }
GET /checks/:id CheckRun
POST /checks/:id/findings/:findingId/mark { kind, note, reason } 201 FindingMark; 400 REASON_REQUIRED or DESCRIPTION_REQUIRED
GET /checks/:id/export?format=json|html|pdf JSON as an attachment; html and pdf both answer the printable page (X-Export-Format: html), which Context opens in a new tab to print or save as a PDF

Check-ins (YCI)

Method and path Body Answer
GET /patients/:id/check-ins/plan?checkId=&findingId= The plan for the latest check, or checkId's, with findingId's questions first: eligibility { ok, code, message }, urgentFirst, the fixed lines, suggestions, closings (sent, read_today, see_in_clinic, call_by_5, safety_net), window (days 1 to 7; quietHours null when off; only zones for a clinic-added patient), limits (null when not eligible), fairUse, maxQuestions (10, own included). Linked findings go in the create body
POST /check-ins/questions/validate { patientId, text } { ok: true, text } or { ok: false, rule, reason }
POST /patients/:id/check-ins { questions, closingIds, window, expiresInHours, linkedFindingIds, checkId, urgentCallConfirmedAt, readToday } 201 CheckIn. 409 CHECK_IN_NOT_ALLOWED (eligibility), URGENT_CALL_FIRST (phone), NO_WINDOW; 429 LIMIT_REACHED
GET /patients/:id/check-ins { checkIns } for this clinician
GET /check-ins/:id CheckIn: plan (with linkedTiers), script, status, history, window, later, urgent, urgentAcknowledgedAt, notes (with validation, endedEarly), summary, transcript, alerts (already sent: urgent sent, escalated, not reached), lastEventId
GET /check-ins/:id/events Server-sent events, replayed from Last-Event-ID or ?lastEventId=
GET /check-ins/:id/updates?after=N { events, lastEventId }
POST /check-ins/:id/read { readAt }
POST /check-ins/:id/notes/:noteId/acknowledge The note; every urgent note of the check-in is acknowledged
POST /check-ins/:id/add-to-check { noteIds } { recordId, notes, note }: the chosen notes become one record from the call. Context sends its whole choice here; 409 NOTE_NOT_CONFIRMED for a note not found in the call text or disputed
POST /check-ins/:id/notes/:noteId/add-to-check { note, recordId }, one note on its own (a record each time)
POST /check-ins/:id/cancel CheckIn; 409 CHECK_IN_NOT_OPEN
GET /alerts { alerts }, one per check-in with an urgent note not yet acknowledged (noteId may be null)

Statuses (YCI-28): sent, ringing, missed, later, in_progress, completed, declined, not_reached (expired without a call), identity_not_confirmed, ended_early, cancelled. Urgent is a flag (urgent, with the urgent note), never a status. Status detail is a code (call_started, cancelled_by_clinician).

Events: status, note, note_updated, transcript, summary, urgent, and alert ({ kind: urgent_sent | escalated | not_reached, message }), which Context shows as a banner on the live panel. Context folds events with applyEvent (src/logic/checkin-events.ts), ignoring any id at or below the last one seen; two stream errors in a minute switch to polling every 3 seconds.