feature: Warning Alert Consumer, Orders API & Input Validation

This commit is contained in:
voltsrage
2026-06-18 15:57:55 +08:00
parent c0cd75856c
commit 7d9e53fb8d
30 changed files with 2334 additions and 29 deletions
+57 -25
View File
@@ -2,7 +2,7 @@
A production-quality clinical backend built with ASP.NET Core 8, PostgreSQL, Apache Kafka, RabbitMQ, Elasticsearch, Redis, and MinIO. The domain models the observe-alert-acknowledge lifecycle at the center of any clinical monitoring system: patient encounters, continuous vital sign and lab result ingest, real-time sepsis detection, and clinician notification with automatic escalation.
**Implementation status:** All nine planned phases are complete — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, and the MinIO Parquet data lake. See [Implemented Phases](#implemented-phases) for the full breakdown.
**Implementation status:** All ten planned phases are complete — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, and clinical data model expansion (patient demographics, encounter enrichment, 12 observation codes). See [Implemented Phases](#implemented-phases) for the full breakdown.
## Domain Model — How It Maps to a Real Clinical System
@@ -19,11 +19,11 @@ Patient ────────────────────────
### Patient
A `Patient` is registered with demographic information and assigned a Medical Record Number (MRN) — a stable identifier that never changes across encounters. Patient search supports both MRN exact match and name partial match (`ILIKE`).
A `Patient` is registered with demographic information and assigned a Medical Record Number (MRN) — a stable identifier that never changes across encounters. Optional clinical fields include blood type (`A+`, `O-`, etc.), known allergies, and emergency contact name/phone. Patient search supports both MRN exact match and name partial match (`ILIKE`).
### Encounter
An `Encounter` is a single clinical episode. Status follows a controlled machine: `scheduled → active → discharged` (or `cancelled` from any pre-discharged state). Observations, alerts, and orders belong to an encounter, not directly to a patient — this bounds queries naturally and mirrors real clinical data ownership. Discharge triggers a RabbitMQ job to generate a discharge summary.
An `Encounter` is a single clinical episode. Status follows a controlled machine: `scheduled → active → discharged` (or `cancelled` from any pre-discharged state). Optional `roomBed` and `admissionReason` fields support ward assignment and clinical context; `dischargeDiagnosis` is set on discharge. Observations, alerts, and orders belong to an encounter, not directly to a patient — this bounds queries naturally and mirrors real clinical data ownership. Discharge triggers a RabbitMQ job to generate a discharge summary.
### AlertThreshold
@@ -47,9 +47,9 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
## Features
- **Patient Registration** — register patients with MRN generation; paginated list with name (`ILIKE`) and MRN (exact) search; patient detail with active encounter summary
- **Encounter Management** — open encounters against a patient; encounter status state machine (`scheduled → active → discharged / cancelled`) with 409 on illegal transitions; encounter timeline as a merged chronological view across status changes, observation summaries, and alerts
- **Alert Threshold Management** — configure per-observation-code numeric bounds (`criticalLow`, `warningLow`, `warningHigh`, `criticalHigh`); thresholds pre-loaded into Redis on startup; write-through cache invalidation on update
- **Patient Registration** — register patients with MRN generation; optional blood type, allergies, and emergency contact; paginated list with name (`ILIKE`) and MRN (exact) search; patient detail with active encounter summary
- **Encounter Management** — open encounters against a patient with optional room/bed and admission reason; encounter status state machine (`scheduled → active → discharged / cancelled`) with 409 on illegal transitions; optional discharge diagnosis on discharge; encounter timeline as a merged chronological view across status changes, observation summaries, and alerts
- **Alert Threshold Management** — configure per-observation-code numeric bounds (`criticalLow`, `warningLow`, `warningHigh`, `criticalHigh`) for 12 observation codes; thresholds pre-loaded into Redis on startup; write-through cache invalidation on update
- **Observation Ingest** — `POST /encounters/:id/observations` accepts single or small batch (up to 10); idempotency via `Idempotency-Key` header (partial unique index); plausibility validation per observation code; synchronous critical alert creation within the ingest transaction; warning breach deferred to Kafka consumer; outbox event written in the same commit; cursor-paginated history on `(encounter_id, observation_code, recorded_at DESC)`
- **Clinical Alert Lifecycle** — paginated alert list per encounter and globally; acknowledge with clinician ID and optional note; resolve (must be acknowledged first); global list filterable by status, severity, and department
- **Outbox Relay** — `IHostedService` polling every 500ms; reads unprocessed outbox rows, publishes to Kafka, marks processed; partitioned by `encounterId` for per-encounter ordering
@@ -149,12 +149,14 @@ VigilCareClinicalAPI/
│ ├── EncounterType.cs # Inpatient, Outpatient, Emergency
│ ├── AlertSeverity.cs # Warning, Critical
│ ├── AlertStatus.cs # Open, Acknowledged, Resolved, Escalated
│ ├── AlertType.cs # ThresholdBreach, SepsisWarning, …
│ ├── AlertType.cs # Threshold breach, sepsis, systolic BP, AVPU, glucose, …
│ ├── BloodType.cs # A+, O-, AB-, … with ToDbString/FromDbString
│ ├── ObservationSource.cs # Device, Manual, Lab
│ └── OrderType.cs / ReconciliationCheckType.cs / Department.cs / OrderStatus.cs
│ └── Json/
│ ├── ObservationSourceJsonConverter.cs
── DepartmentJsonConverter.cs
── DepartmentJsonConverter.cs
│ └── BloodTypeJsonConverter.cs # Clinical notation (A+, AB-) in JSON API
├── Services/
│ ├── Interfaces/ # IPatientService, IEncounterService, …
│ ├── PatientService.cs
@@ -247,7 +249,8 @@ tests/
├── NotificationPipelineTests.cs # RabbitMQ topology, DLQ routing
├── ReconciliationTests.cs # Three reconciliation checks, deduplication, RabbitMQ publish
├── ObservabilityPhase8Tests.cs # /metrics families and correlation header behavior
── DataLakePhase9Tests.cs # Kafka → MinIO Parquet flow and schema checks
── DataLakePhase9Tests.cs # Kafka → MinIO Parquet flow and schema checks
└── ClinicalDemographicsAndObservationTests.cs # Patient/encounter enrichment, expanded observation alerts
scripts/
├── run-api-redis-tests.sh # Phase 1 — patient/encounter/threshold + Redis cache
@@ -257,10 +260,11 @@ scripts/
├── run-notification-pipeline-tests.sh # Phase 6 — RabbitMQ paging, DLQ, discharge summary
├── run-reconciliation-tests.sh # Phase 7 — reconciliation scheduler checks
├── run-phase8-verification.sh # Phase 8 — Prometheus metrics, alerts_unacknowledged_gauge, correlation headers
── run-phase9-verification.sh # Phase 9 — data lake tests, Kafka offsets, MinIO Parquet, DuckDB schema
── run-phase9-verification.sh # Phase 9 — data lake tests, Kafka offsets, MinIO Parquet, DuckDB schema
└── run-phase10-verification.sh # Phase 10 — 12 Redis thresholds, clinical enrichment, ES pipeline, integration tests
docs/
├── plans/ # Phase 19 implementation and verification guides
├── plans/ # Phase 110 implementation and verification guides
├── decisions/
│ ├── data-lake-design.md # Parquet vs JSON, partitioning, replay rationale
│ └── sepsis-engine-design.md # SIRS sliding window and idempotent alert design
@@ -374,7 +378,7 @@ dotnet run
On startup the application:
1. Runs EF Core migrations
2. Seeds two patients, one active inpatient encounter each, four alert thresholds, and sample observations
2. Seeds two patients (with blood type, allergies, emergency contact), one active inpatient encounter each (with room/bed and admission reason), twelve alert thresholds, and sample observations
3. Pre-loads all thresholds into Redis
4. Provisions Kafka topics and Elasticsearch indices
5. Declares the RabbitMQ exchange and queue topology
@@ -400,6 +404,7 @@ Integration tests use `WebApplicationFactory` with a `Testing` environment and T
| `ReconciliationTests` | 7 | Three reconciliation checks, deduplication, RabbitMQ publish |
| `ObservabilityPhase8Tests` | 8 | All eight `/metrics` families, correlation headers, ingest counter increment |
| `DataLakePhase9Tests` | 9 | Kafka → MinIO Parquet flow and schema checks |
| `ClinicalDemographicsAndObservationTests` | 10 | Patient clinical fields, encounter enrichment, expanded observation codes, critical glucose alert |
### Verification Scripts
@@ -408,6 +413,7 @@ With the API running (`dotnet run`) and Docker Compose up:
```bash
./scripts/run-phase8-verification.sh # Prometheus target UP, eight metrics, alerts_unacknowledged_gauge live update
./scripts/run-phase9-verification.sh # DataLakePhase9Tests, Kafka consumer group, MinIO Parquet, DuckDB schema
./scripts/run-phase10-verification.sh # 12 Redis thresholds, clinical enrichment, ES pipeline, Phase 10 integration tests
```
Per-phase test runners (subset of `dotnet test`):
@@ -434,7 +440,7 @@ curl https://install.duckdb.org | sh
export PATH="$HOME/.duckdb/cli/latest:$HOME/.local/bin:$PATH"
```
See `docs/plans/phase-8-plan.md` and `docs/plans/phase-9-plan.md` for manual Grafana, Seq, Kafka replay, and DuckDB query examples.
See `docs/plans/phase-8-plan.md`, `docs/plans/phase-9-plan.md`, and `docs/plans/phase-10-plan.md` for manual Grafana, Seq, Kafka replay, and DuckDB query examples.
---
@@ -495,6 +501,10 @@ Error response:
| `lastName` | string | yes | |
| `dateOfBirth` | date | yes | |
| `gender` | string | yes | |
| `bloodType` | string | no | Clinical notation: `A+`, `O-`, `AB-`, etc. |
| `allergies` | string | no | Free-text allergy list |
| `emergencyContactName` | string | no | |
| `emergencyContactPhone` | string | no | |
### Encounters
@@ -521,6 +531,10 @@ scheduled → active → discharged
| `encounterType` | string | yes | `INPATIENT`, `OUTPATIENT`, `EMERGENCY` |
| `department` | string | yes | |
| `attendingPhysician` | string | yes | |
| `roomBed` | string | no | Ward and bed assignment (e.g. `ICU-1A`) |
| `admissionReason` | string | no | Clinical reason for admission |
**PATCH `/encounters/{id}/status` body** — optional `dischargeDiagnosis` when transitioning to `DISCHARGED`.
### Alert Thresholds
@@ -535,7 +549,7 @@ scheduled → active → discharged
| Field | Type | Required | Description |
|---|---|---|---|
| `observationCode` | string | yes | e.g. `HEART_RATE`, `TEMP_C`, `POTASSIUM_MEQ_L` |
| `observationCode` | string | yes | e.g. `HEART_RATE`, `SYSTOLIC_BP`, `AVPU`, `GLUCOSE_MG_DL` |
| `displayName` | string | yes | Human-readable label |
| `unit` | string | yes | e.g. `bpm`, `°C`, `mEq/L` |
| `criticalLow` | decimal | no | |
@@ -543,14 +557,22 @@ scheduled → active → discharged
| `warningHigh` | decimal | no | |
| `criticalHigh` | decimal | no | |
Seeded thresholds:
Seeded thresholds (12 codes):
| Code | Display | Unit | Critical Low | Warning Low | Warning High | Critical High |
|---|---|---|---|---|---|---|
| `HEART_RATE` | Heart Rate | bpm | 30 | 50 | 100 | 150 |
| `TEMP_C` | Temperature | °C | 35.0 | 36.0 | 38.3 | 40.0 |
| `POTASSIUM_MEQ_L` | Serum Potassium | mEq/L | 2.5 | 3.5 | 5.0 | 6.5 |
| `SPO2_PCT` | Oxygen Saturation | % | 85 | 90 | — | — |
| `SPO2` | Oxygen Saturation | % | 88 | 92 | — | — |
| `RESP_RATE` | Respiratory Rate | breaths/min | — | 12 | 20 | 30 |
| `WBC_K_UL` | White Blood Cell Count | k/µL | 2.0 | 4.0 | 12.0 | 20.0 |
| `SYSTOLIC_BP` | Systolic Blood Pressure | mmHg | 70 | 90 | 160 | 180 |
| `DIASTOLIC_BP` | Diastolic Blood Pressure | mmHg | 40 | 60 | 90 | 110 |
| `LACTATE_MMOL_L` | Serum Lactate | mmol/L | — | — | 2.0 | 4.0 |
| `AVPU` | AVPU Consciousness | score | — | — | — | 2 |
| `SUPPLEMENTAL_O2` | Supplemental Oxygen | flag | — | — | — | — |
| `GLUCOSE_MG_DL` | Blood Glucose | mg/dL | 40 | 70 | 180 | 400 |
### Observations
@@ -660,14 +682,18 @@ The `population` query uses Elasticsearch's numeric range aggregation engine —
### Patient
```
id Guid PK
mrn string required, unique — auto-generated on registration (e.g. MRN-000001)
firstName string required (max 100)
lastName string required (max 100)
dateOfBirth Date required
gender string required (max 10)
status string active | inactive (default: active)
createdAt DateTimeOffset
id Guid PK
mrn string required, unique — auto-generated on registration (e.g. MRN-000001)
firstName string required (max 100)
lastName string required (max 100)
dateOfBirth Date required
gender string required (max 10)
bloodType string? A+ | A- | B+ | B- | AB+ | AB- | O+ | O-
allergies string? free text
emergencyContactName string? (max 200)
emergencyContactPhone string? (max 30)
status string active | inactive (default: active)
createdAt DateTimeOffset
```
### Encounter
@@ -679,6 +705,9 @@ encounterType string INPATIENT | OUTPATIENT | EMERGENCY
status string scheduled | active | discharged | cancelled (default: scheduled)
department string required (max 100)
attendingPhysician string required (max 200)
roomBed string? ward/bed assignment (max 50)
admissionReason string? clinical reason for admission
dischargeDiagnosis string? set on discharge
admittedAt DateTimeOffset
dischargedAt DateTimeOffset?
createdAt DateTimeOffset
@@ -790,6 +819,8 @@ createdAt DateTimeOffset
"department": "ICU",
"status": "active",
"attendingPhysician": "Dr. Osei",
"roomBed": "ICU-4B",
"admissionReason": "Chest pain, rule out MI",
"admittedAt": "2025-01-01T08:00:00Z",
"openAlertCount": 2,
"lastObservationAt": "2025-01-01T09:45:00Z"
@@ -948,7 +979,7 @@ Observation history uses cursor pagination on `(recorded_at DESC, id DESC)`. Off
## Implemented Phases
All nine phases from the project roadmap are implemented and covered by integration tests and/or verification scripts.
All ten phases from the project roadmap are implemented and covered by integration tests and/or verification scripts.
| Phase | Feature | Status |
|---|---|---|
@@ -961,5 +992,6 @@ All nine phases from the project roadmap are implemented and covered by integrat
| 7 | Reconciliation scheduler — unacknowledged critical alerts, stale pending orders, disconnected monitors; `reconciliation_alerts` table; RabbitMQ publish; integration tests | Done |
| 8 | Prometheus metrics (`GET /metrics`); eight metric families and three collectors; Grafana clinical dashboard; `ObservabilityPhase8Tests`; `run-phase8-verification.sh` | Done |
| 9 | Data lake writer — `data-lake-writer` consumer group; date-partitioned Parquet flush to MinIO; `DataLakePhase9Tests`; `run-phase9-verification.sh`; design doc in `docs/decisions/data-lake-design.md` | Done |
| 10 | Clinical data model expansion — `BloodType`, patient allergies/emergency contact, encounter room/bed/admission/discharge fields; five new observation codes (`SYSTOLIC_BP`, `DIASTOLIC_BP`, `LACTATE_MMOL_L`, `AVPU`, `SUPPLEMENTAL_O2`); `GLUCOSE_MG_DL` threshold fix; 12 seeded thresholds; `ClinicalDemographicsAndObservationTests`; `run-phase10-verification.sh` | Done |
**Optional follow-up:** execute and document the Kafka replay demonstration for the data lake (reset `data-lake-writer` offsets, clear MinIO prefixes, restart API, confirm Parquet rebuild). See `docs/plans/phase-9-plan.md` § Replay demonstration.