add patient lifecycle doc

This commit is contained in:
voltsrage
2026-06-20 17:11:08 +08:00
parent e1c78b49f4
commit e7d419cba8
3 changed files with 701 additions and 29 deletions
+524
View File
@@ -0,0 +1,524 @@
# 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](../README.md#api-reference) — authoritative endpoint contracts and request bodies
- [Medication correlation design](decisions/medication-correlation-design.md) — how drug context annotates warning/NEWS2 alerts
- [Sepsis engine design](decisions/sepsis-engine-design.md) — SIRS/qSOFA Redis state and bundle creation
- [Simulator guide](simulator-guide.md) — replay JSON scenarios against this lifecycle
- [Dashboard guide](dashboard-guide.md) — 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
```mermaid
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)
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](decisions/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](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` |