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
+1
View File
@@ -374,6 +374,7 @@ docs/
├── plans/ # Phase implementation and verification guides
├── clinical-testing-guide.md # Doctor/nurse guide — alert review & feedback sessions
├── dashboard-guide.md # VigilCare Dashboard user guide (ward, patient detail, charts)
├── patient-encounter-api-lifecycle.md # Full API walkthrough: registration → active stay → discharge
├── simulator-guide.md # VigilCare.Simulator user guide
├── decisions/
│ ├── data-lake-design.md # Parquet vs JSON, partitioning, replay rationale
+169 -22
View File
@@ -23,6 +23,77 @@ fires (the BP is genuinely low and may need monitoring), but the details say:
That single line changes the clinical interpretation from "something is wrong" to "the
medication is working — keep monitoring."
The authoritative endpoint list lives in the [API Reference](../../README.md#api-reference)
(`/api/v1` prefix, standard `{ success, statusCode, data, error }` envelope). For the
full patient journey from registration through discharge — not only medication correlation —
see [Patient Encounter API Lifecycle](../patient-encounter-api-lifecycle.md). This
document explains how medication correlation fits into that surface — which endpoints
write administrations, which paths produce annotated alerts, and which endpoints are
deliberately outside the correlation scope.
---
## Relationship to the full API
Medication correlation is not a standalone micro-API. It is a cross-cutting concern that
connects medication write endpoints, observation ingest, asynchronous alert consumers, and
alert read endpoints. The table below maps every `/api/v1` resource group to its role.
| Resource group | Endpoints | Role in medication correlation |
|---|---|---|
| **Patients** | `POST/GET /patients`, `GET /patients/{id}` | Prerequisite only — patients must exist before encounters are opened. No correlation logic. |
| **Encounters** | `GET/POST /encounters`, `PATCH /encounters/{id}/status`, `GET /encounters/{id}/timeline`, `GET /encounters/{id}/qsofa/current` | Medications and observations are scoped to an **active** encounter. `GET /encounters/{id}` and `GET /encounters/{id}/timeline` return open alerts whose `details` may already include server-side annotations; the timeline does **not** include medication administrations. |
| **Alert thresholds** | `POST/GET/PUT /alert-thresholds` | Defines the warning/critical bands that `WarningEvaluator` compares against. Threshold changes invalidate the Redis cache but do not affect drug-vital mappings (`MedicationCorrelation` config is separate). |
| **Observations** | `POST/GET /encounters/{id}/observations` | **Write path** that eventually triggers correlation. `POST` returns `alertGenerated` only for **critical** breaches created synchronously (never annotated). Warning and NEWS2 alerts are created asynchronously by Kafka consumers and include annotations when applicable. |
| **Clinical alerts** | `GET /encounters/{id}/alerts`, `GET /alerts`, `GET /alerts/{id}`, `POST /alerts/{id}/acknowledge`, `POST /alerts/{id}/resolve` | **Read path** where annotations surface. The `details` field on `ClinicalAlert` carries the `— note: …` suffix. Acknowledge/resolve lifecycle is unchanged. |
| **Orders** | `POST/GET /encounters/{id}/orders`, `GET/PATCH /orders/{id}` | Unrelated to correlation. Sepsis-bundle orders are auto-created by the sepsis engine, not by medication recording. |
| **Analytics** | `GET /analytics/patients`, `/observations/trend`, `/alerts/summary`, `/population` | Elasticsearch-backed reporting. Alert summaries index the annotated `details` text but do not run correlation themselves. |
| **NEWS2** | `GET /encounters/{id}/news2/current`, `GET /encounters/{id}/news2/history` | Scores are computed asynchronously (`news2-scoring` consumer). When a NEWS2 warning/emergency alert is created, its `details` may be annotated the same way as threshold warnings. Score history endpoints return numeric components only — annotations live on the alert, not the score row. |
| **Sepsis bundles** | `GET /encounters/{id}/sepsis-bundle/current`, `GET /sepsis-bundles/{id}` | Deliberately excluded from annotation (see [What is NOT annotated](#what-is-not-annotated-and-why)). |
| **Medications** | `POST/GET /encounters/{id}/medications`, `GET /medications/{id}` | **Write and read path** for administrations. Correlation reads via `MedicationService.GetRecentForEncounterAsync`; HTTP clients (dashboard, simulator) use the list endpoint to show recent drugs alongside alert reasoning. |
There is no dedicated "correlate" or "annotate" endpoint. Correlation runs inside background
consumers at alert-creation time and is visible only through alert `details` and
medication list responses.
### End-to-end API choreography (metoprolol example)
A typical integration or simulator run exercises these endpoints in order:
```
1. POST /api/v1/patients → register patient (MRN assigned)
2. POST /api/v1/patients/{id}/encounters → open encounter (status: active)
3. POST /api/v1/encounters/{id}/medications → record metoprolol 25mg PO
4. POST /api/v1/encounters/{id}/observations → ingest SYSTOLIC_BP = 95
├─ HTTP 201: observation row created; alertGenerated = false
│ (warning path is async — no annotation in this response)
└─ Kafka observation.recorded
→ WarningAlertService (consumer group: warning-evaluator)
→ MedicationCorrelationHelper annotates details
→ clinical_alerts row + alert.generated outbox event
5. GET /api/v1/encounters/{id}/alerts → poll until WARNING_SYSTOLIC_BP appears
details: "SYSTOLIC_BP value 95 is below warning low of 90. — note: metoprolol 25mg (PO) administered 45 min ago"
6. GET /api/v1/encounters/{id}/medications → optional; dashboard uses this for the
"Recent medications" panel in alert reasoning (client-side 90-min window)
```
Allow a few seconds between steps 4 and 5 for the Kafka consumer to process the
`observation.recorded` event. NEWS2 follows the same pattern via `news2-scoring` after
all seven parameters are present.
### Where annotations appear (and where they do not)
| Response field / endpoint | Contains annotation? | Notes |
|---|---|---|
| `POST …/observations``alertGenerated` / inline alert | No (warnings) | Only critical alerts return synchronously; critical path never calls `MedicationCorrelationHelper`. |
| `GET …/alerts`, `GET /alerts/{id}`, `GET /encounters/{id}` (embedded alerts) | Yes (warning + NEWS2) | Primary consumer surface. `details` is plain text with appended `— note: …`. |
| `GET …/encounters/{id}/timeline` | Yes (on alert events) | Timeline merges observations and alerts; alert events include annotated `details`. |
| `alert.generated` Kafka / outbox payload | Yes | `details` in the event matches the persisted alert row. |
| `GET …/news2/current`, `GET …/news2/history` | No | Scores only; read the corresponding NEWS2 alert for annotated context. |
| `GET …/medications` | No | Returns raw administration rows; dashboard correlates client-side for the reasoning panel. |
---
## How the pieces fit together
@@ -36,8 +107,9 @@ Nurse records medication
┌─────────────────────┐
│ MedicationsController│ ◄── thin HTTP layer, no business logic
│ POST /encounters/
{id}/medications
│ POST /api/v1/
encounters/{id}/
│ medications │
└────────┬────────────┘
@@ -54,11 +126,17 @@ Nurse records medication
... time passes, vital signs arrive ...
New observation arrives (e.g. SYSTOLIC_BP = 95)
POST /api/v1/encounters/{id}/observations (e.g. SYSTOLIC_BP = 95)
┌─────────────────────┐
WarningEvaluator │ ◄── or News2Detector for composite scores
ObservationService │ ◄── sync path: critical alerts only (never annotated)
│ COMMIT + outbox │ always emits observation.recorded → Kafka
└────────┬────────────┘
▼ (async, consumer group: warning-evaluator)
┌─────────────────────┐
│ WarningEvaluator │ ◄── or News2Detector (consumer group: news2-scoring)
│ │
│ 1. Load threshold │ (from Redis cache)
│ 2. Check breach │ (is 95 < warningLow of 90?)
@@ -87,6 +165,10 @@ New observation arrives (e.g. SYSTOLIC_BP = 95)
│ which drugs are "relevant"│
│ to which vital signs │
└────────────────────────────┘
... client reads result ...
GET /api/v1/encounters/{id}/alerts → annotated details in response
```
---
@@ -208,11 +290,51 @@ do what it's good at.
### 4. MedicationsController
**What it is:** Three HTTP endpoints:
- `POST /api/v1/encounters/{encounterId}/medications` — record a new administration
- `GET /api/v1/encounters/{encounterId}/medications` — list with pagination and optional
`since` filter
- `GET /api/v1/medications/{id}` — look up a single record
**What it is:** Three HTTP endpoints under `/api/v1` (see also
[Medications in the API Reference](../../README.md#medications)):
| Method | Path | Purpose |
|---|---|---|
| `POST` | `/encounters/{encounterId}/medications` | Record a new administration |
| `GET` | `/encounters/{encounterId}/medications` | List with pagination and optional `since` filter |
| `GET` | `/medications/{id}` | Look up a single record (includes nested `encounter`) |
**POST body** (`CreateMedicationAdministrationRequest`):
| Field | Type | Required | Notes |
|---|---|---|---|
| `drugName` | string | yes | Trimmed on persist; case-insensitive for `DrugVitalMappings` lookup |
| `dose` | decimal | yes | Must be > 0 |
| `doseUnit` | string | yes | e.g. `mg`, `g`, `mcg`, `units` |
| `route` | string | yes | e.g. `PO`, `IV`, `SubQ` |
| `administeredAt` | DateTimeOffset | no | Defaults to server UTC time if omitted |
| `administeredBy` | string | yes | Clinician or nurse identifier |
**POST response:** `201 Created` with `ApiResponse<MedicationAdministration>` in the
standard envelope. The `data` object includes `id`, `encounterId`, `drugName`, `dose`,
`doseUnit`, `route`, `administeredAt`, `administeredBy`.
**POST status codes:**
| Code | When |
|---|---|
| `201` | Administration recorded |
| `400` | FluentValidation failure (empty drug name, zero dose, `administeredAt` > 5 min in future, field length exceeded) |
| `404` | Encounter not found (`ENCOUNTER_NOT_FOUND`) |
| `409` | Encounter not active (`ENCOUNTER_NOT_ACTIVE`) |
**GET list response:** `200 OK` with paginated `{ items, page, pageSize, totalCount, totalPages }`.
Query params: `since` (ISO 8601 — administrations at or after this time), `page` (default 1),
`pageSize` (default 20). The dashboard's `fetchMedications` helper pages through this
endpoint (default `pageSize` 50) to populate the alert-reasoning panel.
**GET by id response:** `200 OK` with full `MedicationAdministration` (404 if not found).
**Why medication POST does not trigger correlation:**
Recording a drug does not create or modify alerts. Correlation is evaluated only when a
warning or NEWS2 alert is **created** — triggered by observation ingest (async) or NEWS2
scoring (async). This keeps the medication write path fast and idempotent with no
side effects beyond the `medication_administrations` row.
**Why the controller is almost empty:**
The controller's only job is to translate HTTP concepts (route parameters, query strings,
@@ -329,6 +451,12 @@ The annotated details string is also included in the outbox event payload, so do
consumers (notifications, dashboards) receive the medication context without needing
their own correlation logic.
Clients that poll `GET /api/v1/encounters/{id}/alerts` or `GET /api/v1/alerts/{id}` read
the same annotated `details` string. The ward dashboard (`vigilcare-dashboard`) adds a
second layer: `AlertReasoning.vue` fetches `GET …/medications` and shows administrations
within 90 minutes before `alert.triggeredAt`, independent of whether the server appended
the `— note:` suffix (e.g. when the drug name is not in `DrugVitalMappings`).
### News2Detector annotation strategy
NEWS2 is different from single-vital warnings because it's a composite score of 7
@@ -349,7 +477,7 @@ pharmaceutical review.
## Data flow summary
```
HTTP POST
HTTP POST /api/v1/encounters/{id}/medications
┌────────────────┐
@@ -364,26 +492,45 @@ pharmaceutical review.
└────────────────┘ │ administrations │
└────────┬────────┘
... later, observation arrives ... │
HTTP POST /api/v1/encounters/{id}/observations
(observation row + observation.recorded outbox)
│ │
▼ │
┌────────────────┐ │
Warning │ │
Evaluator │ │
Kafka │ │
observation. │ │
│ recorded │ │
└───────┬────────┘ │
│ │
┌────────────┴────────────┐ │
▼ ▼ │
┌──────────────┐ ┌──────────────┐ │
│ WarningAlert │ │ News2Scoring │ │
│ Service │ │ Service │ │
└──────┬───────┘ └──────┬───────┘ │
│ │ │
│ builds details│
│ │ │
▼ │
Correlation │◄───────────────┘
Helper queries recent meds
│ │ for this encounter +
│ ▼ │ observation code
┌────────────────┐ ┌────────────────┐
Warning│ News2
│ Evaluator Detector │ │
│ │
│ │ │ │
│ Correlation │◄──────┴───────┘ │
│ Helper │◄────────────────────────┘
│ │ │ queries recent meds
│ ▼ │ for encounter + code
│ annotated │
│ details │
│ │ │
│ ▼ │
│ INSERT alert │──────► PostgreSQL clinical_alerts
│ + outbox event│──────► outbox_events → Kafka
│ + outbox event│──────► outbox_events → Kafka alert.generated
└────────────────┘
GET /api/v1/encounters/{id}/alerts
GET /api/v1/alerts/{id}
(annotated details in JSON response)
```
---
+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` |