chore: add readme
This commit is contained in:
@@ -0,0 +1,775 @@
|
||||
# VigilCare Clinical API
|
||||
|
||||
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.
|
||||
|
||||
## Domain Model — How It Maps to a Real Clinical System
|
||||
|
||||
In a hospital, a patient presents for care and an encounter is opened. Bedside monitors and lab systems post observations continuously against that encounter. A rules engine evaluates each observation against configured thresholds and flags abnormal values as clinical alerts. Clinicians acknowledge and resolve alerts. If a critical alert goes unacknowledged for five minutes, the system escalates to the on-call backup. All events flow through Kafka so the Elasticsearch dashboard, sepsis engine, and data lake each consume the same stream independently.
|
||||
|
||||
```
|
||||
Patient ─────────────────────────── one patient = one MRN, many lifetime encounters
|
||||
└── Encounter one clinical episode (inpatient, outpatient, ED)
|
||||
├── Observation one measurement: vital sign, lab value, SpO₂
|
||||
│ └── OutboxEvent written in the same transaction → relayed to Kafka
|
||||
└── ClinicalAlert generated on threshold breach or SIRS detection
|
||||
└── OutboxEvent → Kafka → RabbitMQ → clinician page → escalation
|
||||
```
|
||||
|
||||
### 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`).
|
||||
|
||||
### 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.
|
||||
|
||||
### AlertThreshold
|
||||
|
||||
Alert thresholds define the numeric boundaries that trigger a clinical alert for a given observation code. Each threshold has four optional bounds: `criticalLow`, `warningLow`, `warningHigh`, `criticalHigh`. Thresholds are pre-loaded into Redis on startup and invalidated on write — they are read on every observation ingest and must not add database latency to the hot path.
|
||||
|
||||
### Observation
|
||||
|
||||
An `Observation` is a single recorded measurement: a vital sign, lab value, or pulse oximetry reading. Observations are append-only — never updated or deleted. Each observation is evaluated against the Redis-cached threshold immediately on ingest. A `CRITICAL` breach synchronously creates a `ClinicalAlert` within the same transaction before the API returns. A `WARNING` breach is deferred to the Kafka consumer. This split is a deliberate patient safety decision.
|
||||
|
||||
An `idempotencyKey` (partial unique index) prevents duplicate observations when medical devices retry on network failure.
|
||||
|
||||
### ClinicalAlert
|
||||
|
||||
A `ClinicalAlert` is generated when an observation breaches a threshold or when the sepsis engine detects two or more concurrent SIRS criteria. Lifecycle: `open → acknowledged → resolved` (or `escalated` after a five-minute NACK cycle through the RabbitMQ dead-letter queue). Alerts carry an audit trail: who acknowledged, when, and with what note.
|
||||
|
||||
### OutboxEvent
|
||||
|
||||
An `OutboxEvent` is written in the same transaction as any observation or alert, then relayed to Kafka by a background worker. This decouples Kafka availability from the ingest transaction — observations commit to PostgreSQL while Kafka is down, and the relay catches up on recovery.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- **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
|
||||
- **Kafka Pipeline** — three topics (`observation.recorded`, `alert.generated`, `encounter.status.changed`) with six partitions each; KRaft mode, no Zookeeper; `KAFKA_AUTO_CREATE_TOPICS_ENABLE=false` — topics are provisioned explicitly by `KafkaTopicProvisioner`
|
||||
- **Elasticsearch CQRS Projection** — `EsIndexerService` consumer group upserts `patient_encounters` documents, appends to the `observations` index, and updates `openAlertCount` on alert events; patient/encounter search; per-encounter observation trend (hourly avg/min/max); alert volume summary by department and severity; population query (numeric range aggregation across all patients)
|
||||
- **Sepsis Early Warning Engine** — `SepsisEngineService` Kafka consumer evaluates SIRS criteria (temperature, heart rate, respiratory rate, WBC) per encounter using Redis keys with a 30-minute TTL sliding window; on ≥2 active criteria, inserts a `SEPSIS_WARNING / CRITICAL` alert idempotently (`INSERT WHERE NOT EXISTS`)
|
||||
- **RabbitMQ Notification Workers** — `NotificationPublisherService` reads `alert.generated` from Kafka and publishes paging jobs to `alerts.paging.queue`; `PagingWorkerService` sends the page and waits for acknowledgment; NACK on timeout routes to `alerts.paging.dlq` with `x-message-ttl = 300000ms`; `EscalationWorkerService` pages the on-call backup and sets alert status to `escalated`; `DischargeSummaryWorkerService` reads `encounter.status.changed`, generates a discharge summary, and stores it in MinIO under `/discharge-summaries/{encounterId}/summary.pdf`
|
||||
- **Data Lake Writer** — Kafka consumer writing partitioned Parquet files to MinIO (`/observations/`, `/alerts/`, `/encounters/` by date); flush policy: 1,000 events or 5 minutes, whichever comes first; columnar format for 10-year regulatory retention
|
||||
- **Reconciliation Jobs** — three scheduled checks: (1) unacknowledged CRITICAL alerts older than 30 minutes, (2) pending orders without results after 4 hours, (3) active inpatients with no observation in 2 hours; each finding creates a `reconciliation_alerts` row and publishes to RabbitMQ
|
||||
- **Standard Envelope** — all responses use a consistent `{ success, statusCode, data, error }` wrapper; validation errors use the same shape; `ApiBehaviorOptions` overridden so model validation also produces the standard envelope
|
||||
- **Observability** — Serilog structured logging enriched with `correlationId`, `encounterId`, `patientId` on alert paths; Seq sink; per-request correlation IDs in request logs and response headers
|
||||
- **Swagger UI** — OpenAPI spec via Swashbuckle (Development only)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
HTTP request
|
||||
→ CorrelationIdMiddleware
|
||||
→ ExceptionHandlerMiddleware
|
||||
→ Controllers
|
||||
→ Services
|
||||
├── PostgreSQL (EF Core — writes, keyed reads)
|
||||
├── Redis (threshold cache, SIRS state)
|
||||
└── OutboxEvent (same transaction as domain write)
|
||||
|
||||
IHostedServices (background):
|
||||
ThresholdCacheLoader → pre-loads Redis on startup
|
||||
KafkaTopicProvisioner → creates topics with correct partition count
|
||||
RabbitMqTopologyProvisioner → declares exchange, queues, DLQ bindings
|
||||
ElasticIndexProvisioner → creates index mappings
|
||||
OutboxRelayService → PostgreSQL outbox → Kafka (every 500ms)
|
||||
EsIndexerService → Kafka → Elasticsearch (consumer group: es-indexer)
|
||||
SepsisEngineService → Kafka → Redis SIRS state → PostgreSQL alert (consumer group: sepsis-engine)
|
||||
NotificationPublisherService → Kafka → RabbitMQ paging.queue (consumer group: notification-publisher)
|
||||
PagingWorkerService → RabbitMQ paging.queue → log page → NACK on timeout
|
||||
EscalationWorkerService → RabbitMQ escalation.queue → update alert status
|
||||
DischargeSummaryWorkerService → RabbitMQ discharge.queue → MinIO Parquet
|
||||
```
|
||||
|
||||
**Why Kafka and RabbitMQ coexist:** Kafka is an append-only log — the same observation event reaches the Elasticsearch indexer, the sepsis engine, and the data lake independently without coordination. Each consumer holds its own offset and can replay from the beginning. RabbitMQ handles the action side: one message, one worker, one page. A duplicate page at 3am is a patient safety concern, not a minor inconvenience — RabbitMQ's acknowledgment-then-delete model is correct here. The DLQ TTL-based escalation has no equivalent in Kafka.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Server | ASP.NET Core 8 (.NET 8.0) |
|
||||
| Database | PostgreSQL 16 with EF Core 8 (code-first migrations) |
|
||||
| Cache / SIRS state | Redis 7 |
|
||||
| Message log | Apache Kafka 3.7 (KRaft, 6 partitions per topic) |
|
||||
| Task queue | RabbitMQ 3.13 (direct exchange, DLQ escalation) |
|
||||
| Search / analytics | Elasticsearch 8.13 (CQRS read projection) |
|
||||
| Data lake | MinIO (Parquet, S3-compatible) |
|
||||
| Logging | Serilog + Seq sink |
|
||||
| Docs | Swagger / OpenAPI (Swashbuckle) |
|
||||
| Testing | xUnit + Testcontainers |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
VigilCareClinicalAPI/
|
||||
├── Program.cs # Service registration, middleware, seed on startup
|
||||
├── appsettings.json # Connection strings, Kafka, Elasticsearch, RabbitMQ, MinIO, Serilog
|
||||
├── Controllers/
|
||||
│ ├── PatientsController.cs # Patient CRUD, search by name/MRN
|
||||
│ ├── EncountersController.cs # Encounter open, status PATCH, timeline
|
||||
│ ├── ObservationsController.cs # Ingest POST, cursor-paginated GET
|
||||
│ ├── AlertThresholdsController.cs # Threshold CRUD + cache invalidation
|
||||
│ ├── AlertsController.cs # Alert list (global + per-encounter), acknowledge, resolve
|
||||
│ └── AnalyticsController.cs # Elasticsearch-backed patient search, trend, alert summary, population
|
||||
├── Domains/
|
||||
│ ├── Entities/
|
||||
│ │ ├── Patient.cs
|
||||
│ │ ├── Encounter.cs # Status machine; SetStatus() enforces transition matrix
|
||||
│ │ ├── AlertThreshold.cs
|
||||
│ │ ├── Observation.cs # Append-only; IdempotencyKey; partial unique index
|
||||
│ │ ├── ClinicalAlert.cs # open → acknowledged → resolved / escalated
|
||||
│ │ ├── Order.cs
|
||||
│ │ ├── OutboxEvent.cs # topic + payload JSONB + processed_at
|
||||
│ │ └── ReconciliationAlert.cs
|
||||
│ └── Enums/
|
||||
│ ├── EncounterStatus.cs # Scheduled, Active, Discharged, Cancelled
|
||||
│ ├── EncounterType.cs # Inpatient, Outpatient, Emergency
|
||||
│ ├── AlertSeverity.cs # Warning, Critical
|
||||
│ ├── AlertStatus.cs # Open, Acknowledged, Resolved, Escalated
|
||||
│ ├── AlertType.cs # ThresholdBreach, SepsisWarning, …
|
||||
│ ├── ObservationSource.cs # Device, Manual, Lab
|
||||
│ └── OrderType.cs / ReconciliationCheckType.cs
|
||||
├── Services/
|
||||
│ ├── PatientService.cs
|
||||
│ ├── EncounterService.cs # Status state machine + ConflictException on invalid transitions
|
||||
│ ├── AlertThresholdService.cs # CRUD + Redis write-through invalidation
|
||||
│ ├── ObservationService.cs # Ingest transaction: idempotency → plausibility → threshold → alert → outbox
|
||||
│ ├── ObservationQueryService.cs # Cursor-paginated history
|
||||
│ ├── AlertService.cs # Acknowledge, resolve, list
|
||||
│ ├── AnalyticsService.cs # Elasticsearch query wrappers
|
||||
│ └── PlausibilityValidator.cs # Per-code numeric range guard
|
||||
├── BackgroundServices/
|
||||
│ ├── ThresholdCacheLoader.cs # Pre-loads all thresholds into Redis on startup
|
||||
│ ├── KafkaTopicProvisioner.cs # Creates topics with NumPartitions from config
|
||||
│ ├── ElasticIndexProvisioner.cs # Creates patient_encounters, observations, clinical_alerts indices
|
||||
│ ├── OutboxRelayService.cs # Polls outbox every 500ms; publishes to Kafka; marks processed
|
||||
│ ├── EsIndexerService.cs # consumer group: es-indexer; upserts Elasticsearch documents
|
||||
│ ├── SepsisEngineService.cs # consumer group: sepsis-engine; SIRS eval via Redis TTL keys
|
||||
│ └── Notifications/
|
||||
│ ├── NotificationPublisherService.cs # consumer group: notification-publisher; alert.generated → RabbitMQ paging.queue
|
||||
│ ├── PagingWorkerService.cs # RabbitMQ consumer; logs page; NACK on ack timeout → DLQ
|
||||
│ ├── EscalationWorkerService.cs # RabbitMQ escalation.queue; logs escalation; sets alert.status = escalated
|
||||
│ └── DischargeSummaryWorkerService.cs # RabbitMQ discharge.queue; generates summary; uploads to MinIO
|
||||
├── Sepsis/
|
||||
│ ├── SirsDetector.cs # Redis SIRS state management (SET/DEL/MGET)
|
||||
│ └── SirsEvaluator.cs # Per-code criterion evaluation
|
||||
├── Elasticsearch/Documents/
|
||||
│ ├── PatientEncounterDocument.cs
|
||||
│ ├── ObservationDocument.cs
|
||||
│ └── ClinicalAlertDocument.cs
|
||||
├── Notifications/
|
||||
│ └── RabbitMqTopologyProvisioner.cs # Declares exchange, queues, DLQ bindings on startup
|
||||
├── Storage/
|
||||
│ └── MinioClientFactory.cs
|
||||
├── Data/
|
||||
│ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints
|
||||
│ ├── Configurations/ # IEntityTypeConfiguration per entity
|
||||
│ └── Seed/DataSeeder.cs # Seeds patients, encounters, thresholds, observations
|
||||
├── Common/
|
||||
│ ├── ApiResponse.cs # { success, statusCode, data, error } envelope
|
||||
│ ├── PagedResult.cs / CursorPage.cs
|
||||
│ └── Exceptions/
|
||||
│ ├── NotFoundException.cs
|
||||
│ ├── ConflictException.cs # Thrown by encounter status machine
|
||||
│ ├── DomainException.cs
|
||||
│ └── ValidationException.cs
|
||||
├── Middlewares/
|
||||
│ ├── CorrelationIdMiddleware.cs
|
||||
│ └── ExceptionHandlerMiddleware.cs
|
||||
└── Migrations/
|
||||
|
||||
tests/
|
||||
└── VigilCareClinicalAPI.Tests/
|
||||
├── ObservationIngestTests.cs # Ingest happy path, critical alert creation, discharged encounter rejection, idempotency
|
||||
├── AlertLifecycleTests.cs # Acknowledge, resolve, escalation guard
|
||||
├── SirsDetectorTests.cs # Redis SIRS state SET/DEL/MGET logic
|
||||
├── SirsEvaluatorTests.cs # Per-code criterion evaluation
|
||||
└── NotificationPipelineTests.cs # RabbitMQ topology, DLQ routing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### Synchronous vs Asynchronous Alert Detection — The Split
|
||||
|
||||
Critical threshold breaches are detected synchronously within the ingest transaction. A critical potassium of 2.1 mEq/L is immediately life-threatening. If the API returns `201 Created` before generating the alert and the Kafka consumer lags by 30 seconds, a patient could deteriorate during that window. The synchronous check costs one additional Redis read per observation on the hot path — acceptable for correctness.
|
||||
|
||||
A warning heart rate of 95 bpm warrants attention but is not an emergency. The additional latency of Kafka consumer processing is clinically acceptable for a warning.
|
||||
|
||||
This is the architectural decision that separates thinking about healthcare systems from thinking about financial systems. The tradeoff is latency for correctness, and the correctness definition is clinical, not technical.
|
||||
|
||||
### Encounter as the Aggregate Root (Not Patient)
|
||||
|
||||
Observations, alerts, and orders belong to an encounter, not directly to a patient. A patient's blood pressure taken during a 2022 admission belongs to that admission. This bounds queries naturally: "show me all observations for this encounter" is a bounded query. "Show me all observations ever recorded for this patient" is a cross-encounter aggregation that belongs in the data lake.
|
||||
|
||||
### Redis for Two Distinct Purposes
|
||||
|
||||
Redis serves two independent roles with different semantics:
|
||||
|
||||
1. **Threshold cache:** write-through invalidation on every threshold update. Staleness here has clinical consequences — a stale threshold could suppress a critical alert. TTL expiry is not sufficient; invalidation must be immediate on write.
|
||||
|
||||
2. **SIRS sliding window:** `SET sirs:{encounterId}:{code} EX 1800`. The TTL does real work — a heart rate that was abnormal 31 minutes ago stops contributing to the SIRS count without any cleanup job. The 30-minute TTL is a clinical parameter, not an arbitrary cache timeout.
|
||||
|
||||
### Outbox Pattern
|
||||
|
||||
Observation and alert writes use the transactional outbox: the `outbox_events` row is inserted in the same transaction as the domain record. The relay publishes to Kafka asynchronously. This prevents message loss when Kafka is temporarily unavailable and prevents phantom messages when the transaction rolls back. The relay is idempotent — re-publishing an already-processed event is safe because all downstream consumers check for duplicates.
|
||||
|
||||
### Kafka Partition Key: `encounterId`
|
||||
|
||||
All events for the same encounter land on the same partition. The sepsis engine requires this: if observations from the same patient arrive on different partitions, they may be consumed out of order and simultaneous SIRS criteria could be missed. Six partitions balance parallelism against per-encounter ordering guarantees.
|
||||
|
||||
### Elasticsearch as a CQRS Read Projection
|
||||
|
||||
PostgreSQL is always the write side and the source of truth. Elasticsearch is a denormalized, queryable projection optimized for the queries clinicians actually run. The `population` endpoint — "how many active patients have a heart rate above 100 in the last hour" — is a numeric range aggregation across potentially millions of observation rows. Running this against PostgreSQL on the operational database would compete with ingest writes. Elasticsearch's aggregation engine is purpose-built for this pattern.
|
||||
|
||||
**The replay:** stop `EsIndexerService` → delete both indices → reset consumer group offset to 0 → restart → wait for rebuild → verify document count matches PostgreSQL row count. This is the proof that Elasticsearch is a projection and not a source of truth, and the clearest demonstration of why Kafka retains events after consumption.
|
||||
|
||||
### DLQ as a Clinical Escalation Protocol
|
||||
|
||||
The five-minute escalation is not a retry — it is a clinical workflow. When an alert is created, the paging worker sends a page to the attending physician. If no acknowledgment arrives within five minutes, the message NACKs to `alerts.paging.dlq` with `x-message-ttl = 300000ms`. After TTL expires, the DLQ re-routes to `alerts.escalation.queue` and the on-call backup is paged. This pattern has no equivalent in Kafka — Kafka has no concept of per-message TTL or conditional re-routing based on consumer acknowledgment.
|
||||
|
||||
### Why Not a Time-Series Database for Observations?
|
||||
|
||||
A medium hospital with 200 concurrent inpatients at five observations per patient per minute produces approximately 17 observations per second at steady state. PostgreSQL with the composite index `(encounter_id, observation_code, recorded_at DESC)` handles this volume with headroom. TimescaleDB would be the correct next step at 10,000+ observations/second — it is PostgreSQL with automatic time partitioning, meaning the query layer would not change. The Parquet data lake handles the analytics workload that would otherwise stress the operational database over a 10-year horizon.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- .NET 8 SDK
|
||||
- Docker and Docker Compose
|
||||
|
||||
### Start Infrastructure
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
| Service | Host Port | Notes |
|
||||
|---|---|---|
|
||||
| PostgreSQL 16 | 5436 | Database: `vigilcare`, user: `postgres`, password: `password` |
|
||||
| Redis 7 | 6382 | No auth |
|
||||
| Seq | 5345 | UI at `http://localhost:5345` — login: `admin` / `admin` |
|
||||
| Kafka 3.7 | 9092 | KRaft mode, no Zookeeper |
|
||||
| Elasticsearch 8.13 | 9200 | Security disabled for development |
|
||||
| RabbitMQ 3.13 | 5674 (AMQP), 15674 (UI) | login: `guest` / `guest` |
|
||||
| MinIO | 9005 (S3 API), 9006 (console) | login: `minioadmin` / `minioadmin` |
|
||||
|
||||
**Seq first-run:** `SEQ_FIRSTRUN_ADMINPASSWORD=admin` is set in `docker-compose.yml`. This password is only applied on the very first container start (when the `/data` volume is empty). After initialization, the password is stored in the volume and this env var is ignored.
|
||||
|
||||
### Install and Run
|
||||
|
||||
```bash
|
||||
cd VigilCareClinicalAPI
|
||||
dotnet restore
|
||||
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
|
||||
3. Pre-loads all thresholds into Redis
|
||||
4. Provisions Kafka topics and Elasticsearch indices
|
||||
5. Declares the RabbitMQ exchange and queue topology
|
||||
|
||||
Swagger UI is available at `http://localhost:<port>/swagger` in Development.
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
dotnet test
|
||||
```
|
||||
|
||||
Tests use Testcontainers to spin up a real PostgreSQL instance. No manual setup required.
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
All endpoints are prefixed `/api/v1`. Responses follow the standard envelope:
|
||||
|
||||
```json
|
||||
{ "success": true, "statusCode": 200, "data": {}, "error": null }
|
||||
```
|
||||
|
||||
Error response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"statusCode": 422,
|
||||
"data": null,
|
||||
"error": {
|
||||
"message": "Observation value exceeds plausible range for this code.",
|
||||
"code": "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Patients
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| POST | `/patients` | Register a patient; generates MRN |
|
||||
| GET | `/patients` | Paginated list; optional `q` search by name (`ILIKE`) or MRN (exact) |
|
||||
| GET | `/patients/{id}` | Patient detail with active encounter summary |
|
||||
|
||||
**POST body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `firstName` | string | yes | |
|
||||
| `lastName` | string | yes | |
|
||||
| `dateOfBirth` | date | yes | |
|
||||
| `gender` | string | yes | |
|
||||
|
||||
### Encounters
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| POST | `/patients/{id}/encounters` | Open an encounter |
|
||||
| GET | `/encounters/{id}` | Encounter detail with recent observations and open alerts |
|
||||
| PATCH | `/encounters/{id}/status` | Advance encounter status |
|
||||
| GET | `/encounters/{id}/timeline` | Merged chronological view: status changes, observations, alerts |
|
||||
|
||||
**Encounter status machine:**
|
||||
|
||||
```
|
||||
scheduled → active → discharged
|
||||
→ cancelled
|
||||
```
|
||||
|
||||
`PATCH /encounters/{id}/status` returns **409** on illegal transitions.
|
||||
|
||||
**POST body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `encounterType` | string | yes | `INPATIENT`, `OUTPATIENT`, `EMERGENCY` |
|
||||
| `department` | string | yes | |
|
||||
| `attendingPhysician` | string | yes | |
|
||||
|
||||
### Alert Thresholds
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| POST | `/alert-thresholds` | Create a threshold |
|
||||
| GET | `/alert-thresholds` | List all thresholds (paginated) |
|
||||
| GET | `/alert-thresholds/{id}` | Get a threshold by ID |
|
||||
| PUT | `/alert-thresholds/{id}` | Update a threshold; invalidates Redis cache |
|
||||
|
||||
**Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `observationCode` | string | yes | e.g. `HEART_RATE`, `TEMP_C`, `POTASSIUM_MEQ_L` |
|
||||
| `displayName` | string | yes | Human-readable label |
|
||||
| `unit` | string | yes | e.g. `bpm`, `°C`, `mEq/L` |
|
||||
| `criticalLow` | decimal | no | |
|
||||
| `warningLow` | decimal | no | |
|
||||
| `warningHigh` | decimal | no | |
|
||||
| `criticalHigh` | decimal | no | |
|
||||
|
||||
Seeded thresholds:
|
||||
|
||||
| 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 | — | — |
|
||||
|
||||
### Observations
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| POST | `/encounters/{id}/observations` | Ingest one or more observations (max 10 per call) |
|
||||
| GET | `/encounters/{id}/observations` | Cursor-paginated observation history |
|
||||
|
||||
**POST body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `observations` | array | yes | One to ten observation objects |
|
||||
|
||||
**Observation object:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `observationCode` | string | yes | Must match a configured alert threshold |
|
||||
| `value` | decimal | yes | Numeric measurement |
|
||||
| `unit` | string | yes | Unit of measure |
|
||||
| `source` | string | no | `DEVICE` (default), `MANUAL`, `LAB` |
|
||||
| `recordedAt` | DateTimeOffset | yes | When the measurement was taken |
|
||||
|
||||
**Idempotency:** Pass an `Idempotency-Key` header. Same key → original `201` response, no duplicate row.
|
||||
|
||||
**Ingest transaction sequence:**
|
||||
|
||||
1. Validate encounter is `active`
|
||||
2. Check idempotency key
|
||||
3. Validate observation value within plausible range
|
||||
4. Insert observation row
|
||||
5. Load alert threshold from Redis cache (→ PostgreSQL on miss)
|
||||
6. If value breaches `CRITICAL` threshold: insert `clinical_alert` + `outbox_event` (topic: `alert.generated`)
|
||||
7. Insert `outbox_event` (topic: `observation.recorded`)
|
||||
8. COMMIT
|
||||
|
||||
**Status codes:**
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| 201 | Observation(s) recorded |
|
||||
| 200 | `Idempotency-Key` matched existing observation |
|
||||
| 404 | Encounter not found |
|
||||
| 409 | Encounter is not active (discharged or cancelled) |
|
||||
| 422 | Value outside plausible range or body invalid |
|
||||
|
||||
**GET query params:**
|
||||
|
||||
| Param | Description |
|
||||
|---|---|
|
||||
| `code` | Filter by observation code |
|
||||
| `from` | Inclusive start (DateTimeOffset) |
|
||||
| `to` | Inclusive end (DateTimeOffset) |
|
||||
| `limit` | Page size (default 20) |
|
||||
| `cursor` | Opaque cursor from previous response for next page |
|
||||
|
||||
Uses cursor pagination on `(recorded_at DESC, id DESC)` — offset pagination would shift results as new observations arrive in a continuously growing table.
|
||||
|
||||
### Clinical Alerts
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/encounters/{id}/alerts` | Paginated alert list for an encounter |
|
||||
| GET | `/alerts` | Global alert list; optional `status`, `severity`, `department` filter |
|
||||
| GET | `/alerts/{id}` | Alert detail |
|
||||
| POST | `/alerts/{id}/acknowledge` | Acknowledge with clinician ID and optional note |
|
||||
| POST | `/alerts/{id}/resolve` | Resolve (must be acknowledged first) |
|
||||
|
||||
**Alert lifecycle:**
|
||||
|
||||
```
|
||||
open → acknowledged → resolved
|
||||
→ escalated (RabbitMQ DLQ after 5 min unacknowledged)
|
||||
```
|
||||
|
||||
**POST `/alerts/{id}/acknowledge` body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `clinicianId` | string | yes | Clinician identifier |
|
||||
| `note` | string | no | Optional acknowledgment note |
|
||||
|
||||
### Analytics (Elasticsearch)
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/analytics/patients` | Patient/encounter search across MRN, name, department |
|
||||
| GET | `/analytics/observations/trend` | Time-series aggregation (hourly avg/min/max) for a specific observation code per encounter |
|
||||
| GET | `/analytics/alerts/summary` | Alert volume by department and severity over a time window |
|
||||
| GET | `/analytics/population` | Count of patients with a value above or below a threshold in a time window |
|
||||
|
||||
**GET `/analytics/patients` query params:** `q` (free text), `department`, `status`
|
||||
|
||||
**GET `/analytics/observations/trend` query params:** `encounterId` (required), `code` (required), `from`, `to`
|
||||
|
||||
**GET `/analytics/alerts/summary` query params:** `severity`, `from`, `to`, `department`
|
||||
|
||||
**GET `/analytics/population` query params:** `code` (required), `threshold` (required), `from`, `to`
|
||||
|
||||
The `population` query uses Elasticsearch's numeric range aggregation engine — no full-text search. Running this against PostgreSQL on the operational database would compete with ingest writes under load.
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### Encounter
|
||||
|
||||
```
|
||||
id Guid PK
|
||||
patientId Guid FK → Patient
|
||||
encounterType string INPATIENT | OUTPATIENT | EMERGENCY
|
||||
status string scheduled | active | discharged | cancelled (default: scheduled)
|
||||
department string required (max 100)
|
||||
attendingPhysician string required (max 200)
|
||||
admittedAt DateTimeOffset
|
||||
dischargedAt DateTimeOffset?
|
||||
createdAt DateTimeOffset
|
||||
```
|
||||
|
||||
Indexes: `(patient_id, admitted_at DESC)`, partial `(status, admitted_at DESC) WHERE status = 'active'`
|
||||
|
||||
### AlertThreshold
|
||||
|
||||
```
|
||||
id Guid PK
|
||||
observationCode string required, unique (max 50)
|
||||
displayName string required (max 200)
|
||||
unit string required (max 20)
|
||||
criticalLow decimal(10,3)?
|
||||
warningLow decimal(10,3)?
|
||||
warningHigh decimal(10,3)?
|
||||
criticalHigh decimal(10,3)?
|
||||
createdAt DateTimeOffset
|
||||
```
|
||||
|
||||
### Observation
|
||||
|
||||
```
|
||||
id Guid PK
|
||||
encounterId UUID FK → Encounter
|
||||
observationCode string required (max 50)
|
||||
value decimal(10,3) required
|
||||
unit string required (max 20)
|
||||
source string DEVICE | MANUAL | LAB (default: DEVICE)
|
||||
idempotencyKey string? optional, partial unique index
|
||||
recordedAt DateTimeOffset required
|
||||
createdAt DateTimeOffset
|
||||
```
|
||||
|
||||
Indexes: partial unique `(idempotency_key) WHERE idempotency_key IS NOT NULL`, `(encounter_id, observation_code, recorded_at DESC)`
|
||||
|
||||
### ClinicalAlert
|
||||
|
||||
```
|
||||
id Guid PK
|
||||
encounterId Guid FK → Encounter
|
||||
patientId Guid FK → Patient
|
||||
observationId Guid? FK → Observation (null for SIRS alerts)
|
||||
alertType string e.g. THRESHOLD_BREACH, SEPSIS_WARNING
|
||||
severity string WARNING | CRITICAL
|
||||
details text required
|
||||
status string open | acknowledged | resolved | escalated (default: open)
|
||||
acknowledgedAt DateTimeOffset?
|
||||
acknowledgedBy string?
|
||||
resolvedAt DateTimeOffset?
|
||||
triggeredAt DateTimeOffset
|
||||
```
|
||||
|
||||
Indexes: `(encounter_id, triggered_at DESC)`, `(patient_id, triggered_at DESC)`, partial `(severity, triggered_at DESC) WHERE status = 'open'`
|
||||
|
||||
### Order
|
||||
|
||||
```
|
||||
id Guid PK
|
||||
encounterId Guid FK → Encounter
|
||||
orderType string LAB | MEDICATION | IMAGING
|
||||
description string required (max 500)
|
||||
orderedBy string required (max 200)
|
||||
status string pending | in_progress | resulted | cancelled (default: pending)
|
||||
orderedAt DateTimeOffset
|
||||
resultedAt DateTimeOffset?
|
||||
```
|
||||
|
||||
Indexes: `(encounter_id, ordered_at DESC)`, partial `(status, ordered_at) WHERE status IN ('pending', 'in_progress')`
|
||||
|
||||
### OutboxEvent
|
||||
|
||||
```
|
||||
id Guid PK
|
||||
topic string required (max 200)
|
||||
partitionKey string? — encounterId for per-encounter ordering
|
||||
payload JSONB required
|
||||
createdAt DateTimeOffset
|
||||
processedAt DateTimeOffset?
|
||||
```
|
||||
|
||||
Partial index: `(created_at) WHERE processed_at IS NULL`
|
||||
|
||||
### ReconciliationAlert
|
||||
|
||||
```
|
||||
id Guid PK
|
||||
checkType string UNACKNOWLEDGED_CRITICAL_ALERT | PENDING_ORDER_NO_RESULT | ACTIVE_INPATIENT_NO_OBSERVATION
|
||||
encounterId Guid? FK → Encounter
|
||||
patientId Guid? FK → Patient
|
||||
details text required
|
||||
resolvedAt DateTimeOffset?
|
||||
createdAt DateTimeOffset
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Elasticsearch Index Shapes
|
||||
|
||||
### `patient_encounters`
|
||||
|
||||
```json
|
||||
{
|
||||
"encounterId": "uuid",
|
||||
"patientId": "uuid",
|
||||
"mrn": "MRN-000001",
|
||||
"patientName": "Jane Smith",
|
||||
"department": "ICU",
|
||||
"status": "active",
|
||||
"attendingPhysician": "Dr. Osei",
|
||||
"admittedAt": "2025-01-01T08:00:00Z",
|
||||
"openAlertCount": 2,
|
||||
"lastObservationAt": "2025-01-01T09:45:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### `observations`
|
||||
|
||||
```json
|
||||
{
|
||||
"observationId": "uuid",
|
||||
"encounterId": "uuid",
|
||||
"patientId": "uuid",
|
||||
"mrn": "MRN-000001",
|
||||
"observationCode": "HEART_RATE",
|
||||
"value": 118.0,
|
||||
"unit": "bpm",
|
||||
"source": "DEVICE",
|
||||
"recordedAt": "2025-01-01T09:45:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### `clinical_alerts`
|
||||
|
||||
```json
|
||||
{
|
||||
"alertId": "uuid",
|
||||
"encounterId": "uuid",
|
||||
"patientId": "uuid",
|
||||
"department": "ICU",
|
||||
"alertType": "THRESHOLD_BREACH",
|
||||
"severity": "CRITICAL",
|
||||
"status": "open",
|
||||
"triggeredAt": "2025-01-01T09:45:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RabbitMQ Exchange Topology
|
||||
|
||||
Exchange: `clinical.notifications.exchange` (direct)
|
||||
|
||||
| Queue | Purpose | DLQ |
|
||||
|---|---|---|
|
||||
| `alerts.paging.queue` | Physician paging jobs; prefetch=3 | → `alerts.paging.dlq` on NACK |
|
||||
| `alerts.paging.dlq` | Dead-letter queue; `x-message-ttl = 300000ms` | → `alerts.escalation.queue` on TTL expiry |
|
||||
| `alerts.escalation.queue` | On-call backup paging | — |
|
||||
| `notifications.discharge.queue` | Discharge summary PDF generation + MinIO upload | — |
|
||||
| `notifications.appointment.queue` | Appointment reminder SMS | — |
|
||||
|
||||
---
|
||||
|
||||
## Kafka Topics
|
||||
|
||||
| Topic | Partition key | Consumer groups |
|
||||
|---|---|---|
|
||||
| `observation.recorded` | `encounterId` | `es-indexer`, `sepsis-engine`, `data-lake-writer` |
|
||||
| `alert.generated` | `encounterId` | `es-indexer`, `notification-publisher`, `data-lake-writer` |
|
||||
| `encounter.status.changed` | `encounterId` | `es-indexer`, `data-lake-writer` |
|
||||
|
||||
All topics use 6 partitions. `KAFKA_AUTO_CREATE_TOPICS_ENABLE=false` — topics are provisioned explicitly by `KafkaTopicProvisioner` to guarantee correct partition count.
|
||||
|
||||
---
|
||||
|
||||
## SIRS Criteria
|
||||
|
||||
The sepsis engine evaluates four SIRS (Systemic Inflammatory Response Syndrome) criteria per encounter using Redis keys with a 30-minute TTL:
|
||||
|
||||
| Criterion | Observation Code | Trigger |
|
||||
|---|---|---|
|
||||
| Fever or hypothermia | `TEMP_C` | > 38.3°C or < 36.0°C |
|
||||
| Tachycardia | `HEART_RATE` | > 90 bpm |
|
||||
| Tachypnea | `RESP_RATE` | > 20 breaths/min |
|
||||
| Abnormal WBC | `WBC_K_UL` | > 12.0 or < 4.0 k/µL |
|
||||
|
||||
When ≥ 2 criteria are active simultaneously (keys present in Redis) for the same encounter and no open `SEPSIS_WARNING` alert already exists, the engine inserts a `CRITICAL` alert and outbox event. The 30-minute TTL is a clinical parameter — it bounds the window within which simultaneous SIRS criteria must co-occur.
|
||||
|
||||
---
|
||||
|
||||
## Elasticsearch Index Replay
|
||||
|
||||
If the Elasticsearch indices need to be rebuilt (e.g., after a mapping change or data loss):
|
||||
|
||||
```bash
|
||||
# 1. Stop the indexer consumer group (set consumer group to a known-good offset, or reset to beginning)
|
||||
# 2. Delete existing indices
|
||||
curl -X DELETE http://localhost:9200/patient_encounters
|
||||
curl -X DELETE http://localhost:9200/observations
|
||||
curl -X DELETE http://localhost:9200/clinical_alerts
|
||||
|
||||
# 3. Reset the es-indexer consumer group offset to the beginning
|
||||
docker exec -it <kafka-container> kafka-consumer-groups.sh \
|
||||
--bootstrap-server localhost:9092 \
|
||||
--group es-indexer \
|
||||
--topic observation.recorded \
|
||||
--reset-offsets --to-earliest --execute
|
||||
|
||||
# 4. Restart the application — EsIndexerService will replay all events from offset 0
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The indices rebuild from the full Kafka history. Document count should match PostgreSQL row count when complete. This is only possible because Kafka retains events after consumption.
|
||||
|
||||
---
|
||||
|
||||
## Pagination
|
||||
|
||||
List endpoints use offset pagination:
|
||||
|
||||
| Param | Default | Description |
|
||||
|---|---|---|
|
||||
| `page` | 1 | Page number (1-based) |
|
||||
| `pageSize` | 20 | Items per page (max 100) |
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [],
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"totalCount": 42,
|
||||
"totalPages": 3
|
||||
}
|
||||
```
|
||||
|
||||
Observation history uses cursor pagination on `(recorded_at DESC, id DESC)`. Offset pagination would shift results as new observations arrive continuously. The cursor is opaque and returned in the response; pass it as `?cursor=` on the next request.
|
||||
|
||||
---
|
||||
|
||||
## Implemented Phases
|
||||
|
||||
| Phase | Feature | Status |
|
||||
|---|---|---|
|
||||
| 1 | Schema, EF Core migrations, patient/encounter CRUD, alert threshold CRUD, encounter status machine, Redis threshold pre-load, seed data | Done |
|
||||
| 2 | Observation ingest — idempotency, plausibility validation, synchronous critical alert creation, outbox event, cursor-paginated history; alert lifecycle (acknowledge, resolve); integration tests | Done |
|
||||
| 3 | Outbox relay (`IHostedService`, 500ms poll); Kafka topics with 6 partitions; `encounterId` partition key; relay survives Kafka restart | Done |
|
||||
| 4 | Elasticsearch CQRS projection (`EsIndexerService`); patient search; observation trend; alert summary; population aggregation; replay procedure | Done |
|
||||
| 5 | Sepsis detection engine (`SepsisEngineService`); Redis SIRS state with 30-min TTL; idempotent alert creation; integration tests | Done |
|
||||
| 6 | RabbitMQ exchange and queue topology; `NotificationPublisherService`; `PagingWorkerService`; DLQ escalation (`EscalationWorkerService`); discharge summary (`DischargeSummaryWorkerService` → MinIO); integration tests | Done |
|
||||
Reference in New Issue
Block a user