Files
vigilcare-clinical/docs/patient-encounter-api-lifecycle.md
T
2026-06-20 17:11:08 +08:00

20 KiB
Raw Blame History

Patient Encounter API Lifecycle

Audience: API integrators, simulator authors, dashboard developers, and anyone who needs to understand how a patient moves through VigilCare from registration to discharge — not from one subsystem's perspective, but across the full /api/v1 surface.

Companion docs:


What this document covers

A hospital stay in VigilCare is an encounter scoped to a patient. Everything clinical — observations, alerts, orders, medications, NEWS2 scores, sepsis bundles — hangs off that encounter while it is ACTIVE. This guide walks through the lifecycle in chronological order: what to call, what happens synchronously in the HTTP response, what happens asynchronously via Kafka consumers, and what stops working after discharge.

There is no single "admit patient" orchestration endpoint. Integrators compose the standard REST resources below.


Conventions

Item Value
Base path /api/v1
Response envelope { success, statusCode, data, error }
Correlation Optional X-Correlation-Id request header; echoed on the response
Active encounter guard POST observations, medications, and orders return 409 (ENCOUNTER_NOT_ACTIVE) when the encounter is discharged or cancelled
Async latency Warning alerts, NEWS2 scores, SIRS/qSOFA, trend alerts, and sepsis bundles are created by Kafka consumers — allow a few seconds after observation ingest before polling read endpoints

Lifecycle overview

flowchart LR
    subgraph admission [Admission]
        A1[POST /patients]
        A2[POST /patients/id/encounters]
    end
    subgraph monitoring [Active monitoring]
        M1[POST /encounters/id/observations]
        M2[POST /encounters/id/medications]
        M3[POST /encounters/id/orders]
    end
    subgraph async [Async engines via Kafka]
        K1[warning-evaluator]
        K2[news2-scoring]
        K3[sepsis-engine]
        K4[trend-analyzer]
    end
    subgraph triage [Clinician response]
        T1[GET /alerts]
        T2[POST /alerts/id/acknowledge]
        T3[POST /alerts/id/resolve]
        T4[PATCH /orders/id/result]
    end
    subgraph discharge [Discharge]
        D1[PATCH /encounters/id/status]
        D2[encounter.status.changed → discharge summary]
    end

    A1 --> A2 --> M1
    M1 --> K1 & K2 & K3 & K4
    K1 & K2 & K3 & K4 --> T1
    M2 & M3 --> T1
    T1 --> T2 --> T3
    M3 --> T4
    T3 --> D1 --> D2

Encounter status machine (enforced by PATCH /encounters/{id}/status):

scheduled → active → discharged
          → cancelled

POST /patients/{id}/encounters creates encounters directly in ACTIVE (the scheduled state exists in the model but is not used by the open-encounter endpoint). Illegal transitions return 409 (ILLEGAL_STATUS_TRANSITION).


Phase 0 — Platform prerequisites (before any patient)

These are typically seeded at deploy time, not called per patient.

What How Why it matters
Alert thresholds (12 observation codes) Seeded in PostgreSQL; loaded into Redis on startup; manageable via POST/GET/PUT /alert-thresholds Every observation ingest validates against a configured code; thresholds drive critical (sync) and warning (async) alerts
Kafka topics Provisioned by KafkaTopicProvisioner observation.recorded, alert.generated, encounter.status.changed (+ sepsis bundle topics)
Drug-vital mappings MedicationCorrelation section in appsettings.json Medication context on warning/NEWS2 alert details

Phase 1 — Patient arrives (registration and admission)

1.1 Register the patient

POST /api/v1/patients
Field Required Notes
firstName, lastName, dateOfBirth, gender yes
bloodType, allergies, emergencyContactName, emergencyContactPhone no Stored on the patient record for ward context

Response: 201 Createddata includes system-generated mrn (e.g. MRN-000042) and id (UUID). Save both; the MRN is the human-facing identifier, the UUID is used in all subsequent paths.

Later lookups:

Need Endpoint
Search by name or MRN GET /patients?q=…
Demographics + active encounter summary GET /patients/{id}

1.2 Open an encounter (admission)

POST /api/v1/patients/{patientId}/encounters
Field Required Notes
encounterType yes INPATIENT, OUTPATIENT, EMERGENCY
department yes e.g. ICU, GENERAL_MEDICINE, SURGERY
attendingPhysician yes
roomBed no Ward assignment (e.g. ICU-1A)
admissionReason no Clinical context for admission

Response: 201 Created — encounter id, status: ACTIVE, admittedAt.

Side effects:

  • Outbox event encounter.status.changed → Kafka → Elasticsearch ward index, data lake, discharge-summary queue listener
  • Only one active encounter per patient per encounter type — duplicate returns 409 (DUPLICATE_ACTIVE_ENCOUNTER)

From this point, encounterId is the primary key for all clinical writes.


Phase 2 — Ward visibility (who is on the floor)

While the patient is active, ward systems poll aggregated state rather than joining tables client-side.

Virtual ward board

GET /api/v1/encounters?status=ACTIVE&department=ICU&page=1&pageSize=20

Each row (WardEncounterSummary) includes:

Field Source
encounterId, patientId, mrn, firstName, lastName, roomBed, department, status Encounter + patient
news2Score, news2RiskLevel Latest news2_scores row
qsofaScore Live Redis count (GET /encounters/{id}/qsofa/current uses the same backing store)
sepsisActive Whether a non-compliant sepsis bundle exists
sepsisBundleStatus IN_PROGRESS, COMPLIANT, NON_COMPLIANT
openAlertCount Open clinical alerts for the encounter

The dashboard sorts this list by NEWS2 score for acuity-first display.

Single-patient chart header

GET /api/v1/encounters/{id}

Returns the encounter with nested patient, the 10 most recent observations, and open alerts. Use this for a chart summary; use dedicated list endpoints for full history.


Phase 3 — Continuous monitoring (observation ingest)

Observations are the heartbeat of the system. Bedside devices, manual entry, and lab interfaces all use the same endpoint.

Record measurements

POST /api/v1/encounters/{encounterId}/observations

Body: { "observations": [ … ] }1 to 10 objects per call.

Observation field Required Notes
observationCode yes Must match a configured alert threshold (12 seeded codes)
value, unit yes Plausibility-checked per code
recordedAt yes When the measurement was taken
source no DEVICE (default), MANUAL, LAB

Optional header: Idempotency-Key — retries with the same key return the original row without duplicate insert.

What happens inside one ingest (synchronous HTTP path)

  1. Encounter must be ACTIVE
  2. Idempotency check
  3. Plausibility validation → 422 if out of range
  4. Insert observations row
  5. Load threshold from Redis
  6. If CRITICAL breach: insert clinical_alert + alert.generated outbox event in the same transaction
  7. Always insert observation.recorded outbox event
  8. COMMIT

HTTP response (201): per-observation result with observation, alertGenerated (true only for critical sync alerts), alertId, duplicate.

Status Meaning
201 Recorded
200 Idempotency key matched existing row
404 Encounter not found
409 Encounter not active
422 Plausibility failure or unknown observation code

What happens after commit (asynchronous — not in the HTTP response)

Every observation.recorded Kafka message is consumed independently:

Consumer group Produces
warning-evaluator WARNING_* threshold alerts (with optional medication annotation)
news2-scoring news2_scores row; NEWS2_WARNING / NEWS2_EMERGENCY when all 7 parameters present
sepsis-engine SEPSIS_WARNING (SIRS ≥2) or QSOFA_WARNING (qSOFA ≥2); triggers sepsis bundle
trend-analyzer RAPID_DETERIORATION when rate-of-change exceeds configured velocity
es-indexer Elasticsearch projection for ward search and analytics
data-lake-writer Parquet files in MinIO

Integrator pattern: after POST …/observations, poll GET /encounters/{id}/alerts or use the simulator's --poll flag. Do not expect warning or NEWS2 alerts in the ingest response body.

Read observation history

GET /api/v1/encounters/{encounterId}/observations?code=HEART_RATE&from=…&to=…&limit=50&cursor=…

Cursor-paginated on (recorded_at DESC, id DESC) — preferred over offset pagination for live streams.


Phase 4 — Clinical interventions (medications and orders)

These run in parallel with monitoring; they do not replace observation ingest.

Medication administrations

POST /api/v1/encounters/{encounterId}/medications
GET  /api/v1/encounters/{encounterId}/medications?since=…&page=1&pageSize=20
GET  /api/v1/medications/{id}

Recording a drug does not create alerts. When a later warning or NEWS2 alert fires, MedicationCorrelationHelper may append context to details if a mapped drug was given within the correlation window (default 90 min). See medication-correlation-design.md.

Clinical orders

POST  /api/v1/encounters/{encounterId}/orders
GET   /api/v1/encounters/{encounterId}/orders?status=PENDING
GET   /api/v1/orders/{id}
PATCH /api/v1/orders/{id}/status
PATCH /api/v1/orders/{id}/result

Order status machine:

pending → in_progress → resulted
        → cancelled

PATCH …/result is the clinical completion path — it transitions to Resulted and, for sepsis-bundle-linked orders, marks the corresponding bundle element complete.


Phase 5 — Alerts fire (detection to triage)

Alerts are created by multiple engines (sync critical threshold, async warning, NEWS2, SIRS/qSOFA, trend). All share the same read and lifecycle API.

List and inspect

Endpoint Use
GET /encounters/{id}/alerts?status=OPEN Per-patient alert list
GET /alerts?status=OPEN&severity=CRITICAL&department=ICU Hospital-wide alert center
GET /alerts/{id} Alert detail with encounter

Each ClinicalAlert includes alertType, severity, details, status, triggeredAt, and optional observationId.

Alert types you will see during an active stay:

Type Engine Severity Medication annotation
CRITICAL_* Sync ingest CRITICAL Never
WARNING_* warning-evaluator WARNING Yes, when correlated
NEWS2_WARNING, NEWS2_EMERGENCY news2-scoring WARNING / CRITICAL Yes (warnings)
SEPSIS_WARNING, QSOFA_WARNING sepsis-engine WARNING / CRITICAL Never
RAPID_DETERIORATION trend-analyzer WARNING Never

Acknowledge and resolve

POST /api/v1/alerts/{id}/acknowledge
Body: { "clinicianId": "DR-SMITH", "note": "optional" }

POST /api/v1/alerts/{id}/resolve

Lifecycle:

open → acknowledged → resolved
     → escalated   (unacknowledged CRITICAL > 5 min → RabbitMQ DLQ → on-call page)

Acknowledging a suppressible warning (WARNING_*, NEWS2_WARNING) sets a Redis suppression window (default 30 min) so duplicate warnings for the same encounter/type do not fire repeatedly. Critical and sepsis alerts are never suppressed.

Notification path (no HTTP endpoint)

alert.generated → Kafka → RabbitMQ alerts.paging.queue → clinician page simulation. Critical unacknowledged alerts escalate via DLQ. This is infrastructure-side; integrators observe it through alert status and Prometheus escalations_total.


Phase 6 — Composite scores (NEWS2 and qSOFA)

NEWS2

Requires all seven parameters in Redis within a 4-hour window: RESP_RATE, SPO2, SYSTOLIC_BP, HEART_RATE, AVPU, TEMP_C, SUPPLEMENTAL_O2.

GET /api/v1/encounters/{id}/news2/current     → latest score (404 if not yet computed)
GET /api/v1/encounters/{id}/news2/history     → cursor-paginated score history

Scores and NEWS2 alerts are async — ingest the seventh parameter, wait a few seconds, then poll.

qSOFA

No dedicated score history table. Live criteria count from Redis:

GET /api/v1/encounters/{id}/qsofa/current

Returns active criteria count (03). When count reaches 2, sepsis-engine creates a QSOFA_WARNING alert and a sepsis bundle (same as SIRS).


Phase 7 — Sepsis bundle (when infection is suspected)

Triggered automatically when SEPSIS_WARNING or QSOFA_WARNING fires — not by a direct API call.

GET /api/v1/encounters/{id}/sepsis-bundle/current
GET /api/v1/sepsis-bundles/{id}

Bundle contents: four elements (blood cultures, serum lactate, broad-spectrum antibiotics, IV fluid resuscitation), each backed by an auto-created order (orderedBy: sepsis-bundle-engine).

Compliance flow:

  1. Bundle created with complianceStatus: IN_PROGRESS, deadlineAt = recognition + 1 hour
  2. Clinician (or simulator) results orders via PATCH /orders/{id}/result
  3. Each result marks the matching bundle element COMPLETED
  4. When all four complete: COMPLIANT (within deadline) or NON_COMPLIANT (past deadline)
  5. SepsisBundleMonitorService marks overdue in-progress bundles NON_COMPLIANT every 5 minutes
GET /api/v1/encounters/{id}/orders

Use this to see bundle-linked orders and their status during the stay.


Phase 8 — Review during the stay (timeline and analytics)

Encounter timeline

GET /api/v1/encounters/{id}/timeline

Merged chronological stream of observations and alerts (not medications, orders, or status changes). Useful for audit-style review and dashboard replay alignment.

Elasticsearch analytics (read-only, cross-encounter)

Endpoint Purpose
GET /analytics/patients?q=…&department=… Patient/encounter search
GET /analytics/observations/trend?encounterId=…&code=… Hourly avg/min/max time series
GET /analytics/alerts/summary?severity=…&department=… Alert volume by department
GET /analytics/population?code=…&threshold=… Patients above/below threshold in a window

These read from the CQRS projection fed by Kafka — not from live PostgreSQL ingest tables.


Phase 9 — Discharge (end of encounter)

Close the encounter

PATCH /api/v1/encounters/{encounterId}/status
Body: { "status": "DISCHARGED", "dischargeDiagnosis": "optional free text" }

Response: 200 OK{ encounterId, newStatus, dischargeDiagnosis }.

Side effects:

  • dischargedAt set to server UTC time
  • Outbox encounter.status.changed → Kafka → Elasticsearch status update, data lake, discharge summary worker (PDF to MinIO at /discharge-summaries/{encounterId}/summary.pdf)

What stops working after discharge

Action Result
POST …/observations 409 ENCOUNTER_NOT_ACTIVE
POST …/medications 409 ENCOUNTER_NOT_ACTIVE
POST …/orders 409 ENCOUNTER_NOT_ACTIVE
GET …/alerts, GET …/observations, GET …/news2/history Still work — historical read
GET /encounters?status=ACTIVE Patient drops off the active ward list

Cancellation path: PATCH …/status with { "status": "CANCELLED" } from ACTIVE (e.g. admission error). Same write restrictions apply afterward.

A patient may later receive a new encounter via POST /patients/{id}/encounters — each episode is independent; MRN persists across the lifetime.


Complete endpoint index by lifecycle phase

Phase Method Path
0 — Config GET/POST/PUT /alert-thresholds, /alert-thresholds/{id}
1 — Admission POST /patients
POST /patients/{id}/encounters
GET /patients, /patients/{id}
2 — Ward GET /encounters, /encounters/{id}
GET /encounters/{id}/qsofa/current
3 — Monitoring POST /encounters/{id}/observations
GET /encounters/{id}/observations
4 — Interventions POST/GET /encounters/{id}/medications, /medications/{id}
POST/GET/PATCH /encounters/{id}/orders, /orders/{id}, /orders/{id}/status, /orders/{id}/result
5 — Alerts GET /encounters/{id}/alerts, /alerts, /alerts/{id}
POST /alerts/{id}/acknowledge, /alerts/{id}/resolve
6 — Scores GET /encounters/{id}/news2/current, /encounters/{id}/news2/history
7 — Sepsis GET /encounters/{id}/sepsis-bundle/current, /sepsis-bundles/{id}
8 — Review GET /encounters/{id}/timeline
GET /analytics/patients, /analytics/observations/trend, /analytics/alerts/summary, /analytics/population
9 — Discharge PATCH /encounters/{id}/status

Operational (not encounter-scoped): GET /metrics (Prometheus), Swagger UI (development only).


Reference walkthrough — inpatient stay (curl-style sequence)

Minimal happy-path sequence an integrator or simulator follows:

# 1. Admit
POST /api/v1/patients
POST /api/v1/patients/{patientId}/encounters

# 2. Baseline vitals (may not trigger alerts)
POST /api/v1/encounters/{encounterId}/observations

# 3. Treat
POST /api/v1/encounters/{encounterId}/medications
POST /api/v1/encounters/{encounterId}/orders

# 4. Monitor & respond (repeat during stay)
POST /api/v1/encounters/{encounterId}/observations
GET  /api/v1/encounters/{encounterId}/alerts
GET  /api/v1/encounters/{encounterId}/news2/current
GET  /api/v1/encounters/{encounterId}/sepsis-bundle/current
POST /api/v1/alerts/{alertId}/acknowledge
PATCH /api/v1/orders/{orderId}/result

# 5. Ward round
GET  /api/v1/encounters?status=ACTIVE
GET  /api/v1/encounters/{encounterId}
GET  /api/v1/encounters/{encounterId}/timeline

# 6. Discharge
PATCH /api/v1/encounters/{encounterId}/status  { "status": "DISCHARGED", "dischargeDiagnosis": "…" }

For a worked clinical narrative (UTI → sepsis, medication false alarm, rapid deterioration), replay the JSON scenarios in VigilCare.Simulator/Scenarios/List/ — each scenario file is a scripted instance of this lifecycle.


Dashboard mapping

The Vue ward dashboard (vigilcare-dashboard/) implements this lifecycle through polling:

UI screen Primary API calls
Virtual Ward GET /encounters?status=ACTIVE
Patient Detail GET /encounters/{id}, GET …/news2/current, GET …/medications, GET …/orders, GET …/sepsis-bundle/current, GET …/observations, GET …/news2/history
Alert Center GET /alerts?status=OPEN
Alert actions POST /alerts/{id}/acknowledge, POST /alerts/{id}/resolve
Alert reasoning alert.details from alert list + client-side medication window from GET …/medications

Clinician feedback ratings are stored in the browser only (no API endpoint) — see clinical-testing-guide.md.


Sync vs async quick reference

Event Visible in HTTP response? Poll these endpoints
Observation saved Yes (201) GET …/observations
Critical alert Yes (alertGenerated: true) GET …/alerts
Warning alert No — Kafka GET …/alerts (after ~13 s)
NEWS2 score / alert No — Kafka GET …/news2/current, GET …/alerts
SIRS / qSOFA / sepsis bundle No — Kafka GET …/alerts, GET …/sepsis-bundle/current, GET …/qsofa/current
Trend alert No — Kafka GET …/alerts
Medication annotation on alert No — applied at alert insert GET …/alerts → read details
Discharge summary PDF No — RabbitMQ worker MinIO /discharge-summaries/{encounterId}/summary.pdf