From e7d419cba81a2b867fa0485f8b274186a1c15f8e Mon Sep 17 00:00:00 2001 From: voltsrage Date: Sat, 20 Jun 2026 17:11:08 +0800 Subject: [PATCH] add patient lifecycle doc --- README.md | 1 + .../medication-correlation-design.md | 205 ++++++- docs/patient-encounter-api-lifecycle.md | 524 ++++++++++++++++++ 3 files changed, 701 insertions(+), 29 deletions(-) create mode 100644 docs/patient-encounter-api-lifecycle.md diff --git a/README.md b/README.md index 9e62dbd..f48ef05 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/decisions/medication-correlation-design.md b/docs/decisions/medication-correlation-design.md index 7cd5e9e..c58f3da 100644 --- a/docs/decisions/medication-correlation-design.md +++ b/docs/decisions/medication-correlation-design.md @@ -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` 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 │ │ - │ │ │ - │ builds details│ │ - │ │ │ │ - │ ▼ │ │ - │ Correlation │◄───────────────┘ - │ Helper │ queries recent meds - │ │ │ for this encounter + - │ ▼ │ observation code - │ annotated │ - │ details │ - │ │ │ - │ ▼ │ - │ INSERT alert │──────► PostgreSQL clinical_alerts - │ + outbox event│──────► outbox_events → Kafka - └────────────────┘ + │ Kafka │ │ + │ observation. │ │ + │ recorded │ │ + └───────┬────────┘ │ + │ │ + ┌────────────┴────────────┐ │ + ▼ ▼ │ + ┌──────────────┐ ┌──────────────┐ │ + │ WarningAlert │ │ News2Scoring │ │ + │ Service │ │ Service │ │ + └──────┬───────┘ └──────┬───────┘ │ + │ │ │ + ▼ ▼ │ + ┌────────────────┐ ┌────────────────┐ │ + │ Warning │ │ News2 │ │ + │ Evaluator │ │ Detector │ │ + │ │ │ │ │ │ │ + │ ▼ │ │ ▼ │ │ + │ Correlation │◄──────┴───────┘ │ + │ Helper │◄────────────────────────┘ + │ │ │ queries recent meds + │ ▼ │ for encounter + code + │ annotated │ + │ details │ + │ │ │ + │ ▼ │ + │ INSERT alert │──────► PostgreSQL clinical_alerts + │ + outbox event│──────► outbox_events → Kafka alert.generated + └────────────────┘ + │ + ▼ + GET /api/v1/encounters/{id}/alerts + GET /api/v1/alerts/{id} + (annotated details in JSON response) ``` --- diff --git a/docs/patient-encounter-api-lifecycle.md b/docs/patient-encounter-api-lifecycle.md new file mode 100644 index 0000000..98de420 --- /dev/null +++ b/docs/patient-encounter-api-lifecycle.md @@ -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 (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:** + +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 ~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` |