20 KiB
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:
- API Reference — authoritative endpoint contracts and request bodies
- Medication correlation design — how drug context annotates warning/NEWS2 alerts
- Sepsis engine design — SIRS/qSOFA Redis state and bundle creation
- Simulator guide — replay JSON scenarios against this lifecycle
- Dashboard guide — which endpoints the Vue ward UI polls
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 Created — data 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)
- Encounter must be
ACTIVE - Idempotency check
- Plausibility validation → 422 if out of range
- Insert
observationsrow - Load threshold from Redis
- If CRITICAL breach: insert
clinical_alert+alert.generatedoutbox event in the same transaction - Always insert
observation.recordedoutbox event 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 (0–3). 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:
- Bundle created with
complianceStatus: IN_PROGRESS,deadlineAt= recognition + 1 hour - Clinician (or simulator) results orders via
PATCH /orders/{id}/result - Each result marks the matching bundle element
COMPLETED - When all four complete:
COMPLIANT(within deadline) orNON_COMPLIANT(past deadline) SepsisBundleMonitorServicemarks overdue in-progress bundlesNON_COMPLIANTevery 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:
dischargedAtset 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 ~1–3 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 |