feature: NEWS2 Composite Scoring Engine
This commit is contained in:
@@ -1,19 +1,19 @@
|
||||
# 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.
|
||||
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 and NEWS2 scoring, and clinician notification with automatic escalation.
|
||||
|
||||
**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.
|
||||
**Implementation status:** All twelve 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, clinical data model expansion (patient demographics, encounter enrichment, 12 observation codes), warning alerts and orders, and the NEWS2 composite scoring engine. See [Implemented Phases](#implemented-phases) for the full breakdown.
|
||||
|
||||
## 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 writer consume the same stream independently.
|
||||
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. Composite scoring engines (NEWS2, SIRS/sepsis) aggregate multiple vitals into acuity scores. 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, scoring engines, and data lake writer 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
|
||||
└── ClinicalAlert generated on threshold breach, SIRS detection, or NEWS2 composite score
|
||||
└── OutboxEvent → Kafka → RabbitMQ → clinician page → escalation
|
||||
```
|
||||
|
||||
@@ -37,7 +37,7 @@ An `idempotencyKey` (partial unique index) prevents duplicate observations when
|
||||
|
||||
### 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.
|
||||
A `ClinicalAlert` is generated when an observation breaches a threshold, when the sepsis engine detects two or more concurrent SIRS criteria, or when the NEWS2 engine computes a medium/high-risk composite score (or a single-parameter score of 3). 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
|
||||
|
||||
@@ -56,14 +56,15 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
|
||||
- **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)
|
||||
- **Elasticsearch CQRS Projection** — `EsIndexerService` consumer group upserts `patient_encounters` documents, appends to the `observations` index, increments `openAlertCount` on alert events, and stamps `news2Score` / `news2RiskLevel` when a NEWS2 alert is generated; 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`)
|
||||
- **NEWS2 Composite Scoring Engine** — `News2ScoringService` Kafka consumer (`news2-scoring`) evaluates seven vital parameters per encounter (`RESP_RATE`, `SPO2`, `SYSTOLIC_BP`, `HEART_RATE`, `AVPU`, `TEMP_C`, `SUPPLEMENTAL_O2`) using Redis keys with a 4-hour TTL; when all seven are present, computes the official NEWS2 aggregate score, persists to `news2_scores`, and creates `NEWS2_WARNING` (score 5–6 or single param = 3) or `NEWS2_EMERGENCY` (score ≥ 7) alerts idempotently; `GET /encounters/:id/news2/current` and `/history` expose score history; Prometheus `news2_scores_total` and `news2_scoring_duration_seconds`
|
||||
- **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; if no ack arrives before timeout it NACKs to `alerts.paging.dlq` with `x-message-ttl = 300000ms`; if the host is stopping, in-flight paging messages are NACKed with `requeue=true` so they are retried after restart and do not false-escalate; `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** — `DataLakeWriterService` (consumer group `data-lake-writer`) buffers `observation.recorded`, `alert.generated`, and `encounter.status.changed` events, flushes date-partitioned Parquet files to MinIO (`/observations/`, `/alerts/`, `/encounters/`), and commits Kafka offsets only after successful uploads; `kafka_partition` and `kafka_offset` columns provide audit lineage
|
||||
- **Data Lake Writer** — `DataLakeWriterService` (consumer group `data-lake-writer`) buffers `observation.recorded`, `alert.generated`, and `encounter.status.changed` events, flushes date-partitioned Parquet files to MinIO (`/observations/`, `/alerts/`, `/encounters/`), and commits Kafka offsets only after at least one successful upload; shutdown flush uses an uncanceled token so MinIO writes complete on Ctrl+C; `kafka_partition` and `kafka_offset` columns provide audit lineage
|
||||
- **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 with field-level `details`
|
||||
- **Input Validation** — FluentValidation validators on all request DTOs (patient registration, encounter open, observation ingest, alert acknowledge, alert thresholds, orders); invalid requests return 400 before reaching the service layer
|
||||
- **Observability** — Serilog structured logging enriched with `correlationId`, `encounterId`, `patientId` on alert paths; Seq sink (`http://localhost:5345`); Prometheus (`http://localhost:9101`) scrapes `GET /metrics`; eight application metric families via `ClinicalMetrics` and three background collectors (`AlertsUnacknowledgedCollector`, `OutboxPendingCollector`, `KafkaConsumerLagCollector`); Grafana clinical dashboard (`http://localhost:3101`, admin/admin) with `alerts_unacknowledged_gauge` as the primary safety panel; per-request correlation IDs in request logs and `X-Correlation-Id` response headers
|
||||
- **Observability** — Serilog structured logging enriched with `correlationId`, `encounterId`, `patientId` on alert paths; Seq sink (`http://localhost:5345`); Prometheus (`http://localhost:9101`) scrapes `GET /metrics`; ten application metric families via `ClinicalMetrics` and three background collectors (`AlertsUnacknowledgedCollector`, `OutboxPendingCollector`, `KafkaConsumerLagCollector`); Grafana clinical dashboard (`http://localhost:3101`, admin/admin) with `alerts_unacknowledged_gauge` as the primary safety panel; per-request correlation IDs in request logs and `X-Correlation-Id` response headers
|
||||
- **Swagger UI** — OpenAPI spec via Swashbuckle (Development only)
|
||||
|
||||
---
|
||||
@@ -77,7 +78,7 @@ HTTP request
|
||||
→ Controllers
|
||||
→ Services
|
||||
├── PostgreSQL (EF Core — writes, keyed reads)
|
||||
├── Redis (threshold cache, SIRS state)
|
||||
├── Redis (threshold cache, SIRS state, NEWS2 parameter state)
|
||||
└── OutboxEvent (same transaction as domain write)
|
||||
|
||||
IHostedServices (background):
|
||||
@@ -89,6 +90,7 @@ IHostedServices (background):
|
||||
EsIndexerService → Kafka → Elasticsearch (consumer group: es-indexer)
|
||||
SepsisEngineService → Kafka → Redis SIRS state → PostgreSQL alert (consumer group: sepsis-engine)
|
||||
WarningAlertService → Kafka → WarningEvaluator → PostgreSQL WARNING alert (consumer group: warning-evaluator)
|
||||
News2ScoringService → Kafka → News2Detector → Redis NEWS2 state → PostgreSQL score + alert (consumer group: news2-scoring)
|
||||
NotificationPublisherService → Kafka → RabbitMQ paging.queue (consumer group: notification-publisher)
|
||||
PagingWorkerService → RabbitMQ paging.queue → log page → NACK on timeout (or requeue on shutdown)
|
||||
EscalationWorkerService → RabbitMQ escalation.queue → update alert status
|
||||
@@ -98,7 +100,7 @@ IHostedServices (background):
|
||||
AlertsUnacknowledgedCollector → polls PostgreSQL every 30s → alerts_unacknowledged_gauge
|
||||
OutboxPendingCollector → polls outbox every 30s → outbox_pending_events
|
||||
KafkaConsumerLagCollector → polls four consumer groups every 30s → kafka_consumer_lag
|
||||
ClinicalMetrics (singleton) → inline counters/histogram from ingest, SIRS, escalation paths
|
||||
ClinicalMetrics (singleton) → inline counters/histogram from ingest, SIRS, NEWS2, escalation paths
|
||||
```
|
||||
|
||||
**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.
|
||||
@@ -111,7 +113,7 @@ IHostedServices (background):
|
||||
|---|---|
|
||||
| Server | ASP.NET Core 8 (.NET 8.0) |
|
||||
| Database | PostgreSQL 16 with EF Core 8 (code-first migrations) |
|
||||
| Cache / SIRS state | Redis 7 |
|
||||
| Cache / SIRS & NEWS2 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) |
|
||||
@@ -139,6 +141,7 @@ VigilCareClinicalAPI/
|
||||
│ ├── AlertThresholdsController.cs # Threshold CRUD + cache invalidation
|
||||
│ ├── AlertsController.cs # Alert list (global + per-encounter), acknowledge, resolve
|
||||
│ ├── OrdersController.cs # Order create, list, get, status transition, record result
|
||||
│ ├── News2Controller.cs # Current NEWS2 score and cursor-paginated history
|
||||
│ └── AnalyticsController.cs # Elasticsearch-backed patient search, trend, alert summary, population
|
||||
├── Domains/
|
||||
│ ├── Entities/
|
||||
@@ -148,6 +151,7 @@ VigilCareClinicalAPI/
|
||||
│ │ ├── Observation.cs # Append-only; IdempotencyKey; partial unique index
|
||||
│ │ ├── ClinicalAlert.cs # open → acknowledged → resolved / escalated
|
||||
│ │ ├── Order.cs
|
||||
│ │ ├── News2Score.cs # Composite score with seven component scores + risk level
|
||||
│ │ ├── OutboxEvent.cs # topic + payload JSONB + processed_at
|
||||
│ │ └── ReconciliationAlert.cs
|
||||
│ └── Enums/
|
||||
@@ -155,7 +159,7 @@ VigilCareClinicalAPI/
|
||||
│ ├── EncounterType.cs # Inpatient, Outpatient, Emergency
|
||||
│ ├── AlertSeverity.cs # Warning, Critical
|
||||
│ ├── AlertStatus.cs # Open, Acknowledged, Resolved, Escalated
|
||||
│ ├── AlertType.cs # Threshold breach, sepsis, warning*, systolic BP, AVPU, glucose, …
|
||||
│ ├── AlertType.cs # Threshold breach, sepsis, warning*, NEWS2_*, 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
|
||||
@@ -172,13 +176,14 @@ VigilCareClinicalAPI/
|
||||
│ ├── ObservationQueryService.cs # Cursor-paginated history
|
||||
│ ├── AlertService.cs # Acknowledge, resolve, list
|
||||
│ ├── OrderService.cs # Order lifecycle; status machine; ConflictException on illegal transitions
|
||||
│ ├── News2Service.cs # Current score + cursor-paginated history from PostgreSQL
|
||||
│ ├── WarningEvaluator.cs # Warning-range threshold evaluation; idempotent alert INSERT
|
||||
│ ├── AnalyticsService.cs # Elasticsearch query wrappers
|
||||
│ └── PlausibilityValidator.cs # Per-code numeric range guard
|
||||
├── Validators/ # FluentValidation — RegisterPatient, OpenEncounter, IngestObservation, …
|
||||
├── Observability/
|
||||
│ └── Metrics/
|
||||
│ └── ClinicalMetrics.cs # Eight Prometheus metric families (counters, histogram, gauges)
|
||||
│ └── ClinicalMetrics.cs # Ten Prometheus metric families (counters, histograms, gauges)
|
||||
├── BackgroundServices/
|
||||
│ ├── ThresholdCacheLoader.cs # Pre-loads all thresholds into Redis on startup
|
||||
│ ├── KafkaTopicProvisioner.cs # Creates topics with NumPartitions from config
|
||||
@@ -192,6 +197,7 @@ VigilCareClinicalAPI/
|
||||
│ │ └── EsIndexerService.cs # consumer group: es-indexer; upserts Elasticsearch documents
|
||||
│ ├── SepsisEngineService.cs # consumer group: sepsis-engine; SIRS eval via Redis TTL keys
|
||||
│ ├── WarningAlertService.cs # consumer group: warning-evaluator; observation.recorded → WARNING alerts
|
||||
│ ├── News2ScoringService.cs # consumer group: news2-scoring; observation.recorded → NEWS2 score + alert
|
||||
│ ├── Notifications/
|
||||
│ │ ├── NotificationPublisherService.cs # consumer group: notification-publisher; alert.generated → RabbitMQ paging.queue
|
||||
│ │ ├── PagingWorkerService.cs # RabbitMQ consumer; logs page; NACK on ack timeout → DLQ, requeue on graceful shutdown
|
||||
@@ -210,6 +216,9 @@ VigilCareClinicalAPI/
|
||||
├── Sepsis/
|
||||
│ ├── SirsDetector.cs # Redis SIRS state management (SET/DEL/MGET)
|
||||
│ └── SirsEvaluator.cs # Per-code criterion evaluation
|
||||
├── News2/
|
||||
│ ├── News2Calculator.cs # Pure static NEWS2 scoring tables (no I/O)
|
||||
│ └── News2Detector.cs # Redis parameter state, score persistence, alert creation
|
||||
├── Elasticsearch/Documents/
|
||||
│ ├── PatientEncounterDocument.cs
|
||||
│ ├── ObservationDocument.cs
|
||||
@@ -220,7 +229,7 @@ VigilCareClinicalAPI/
|
||||
│ └── MinioClientFactory.cs
|
||||
├── DataLake/
|
||||
│ ├── DataLakeOptions.cs # Flush thresholds and bucket settings
|
||||
│ ├── DataLakeWriterService.cs # consumer group: data-lake-writer; Kafka → Parquet → MinIO
|
||||
│ ├── DataLakeWriterService.cs # consumer group: data-lake-writer; Kafka → Parquet → MinIO; graceful shutdown flush
|
||||
│ └── ParquetFileBuilder.cs # Topic row models → Parquet byte arrays
|
||||
├── Models/Records/
|
||||
│ ├── Observation/ObservationRow.cs # Parquet row contract for observation events
|
||||
@@ -263,7 +272,9 @@ tests/
|
||||
├── ClinicalDemographicsAndObservationTests.cs # Patient/encounter enrichment, expanded observation alerts
|
||||
├── WarningAlertTests.cs # WarningEvaluator — warning created, normal/critical skipped, idempotent
|
||||
├── OrderLifecycleTests.cs # Orders API — create, list, record result, illegal transition 409
|
||||
└── ValidationTests.cs # FluentValidation — empty fields, threshold ordering, order description
|
||||
├── ValidationTests.cs # FluentValidation — empty fields, threshold ordering, order description
|
||||
├── News2CalculatorTests.cs # Boundary tests for all seven NEWS2 scoring tables
|
||||
└── News2DetectorTests.cs # NEWS2 detector — score tiers, alerts, idempotency, incomplete set
|
||||
|
||||
scripts/
|
||||
├── run-api-redis-tests.sh # Phase 1 — patient/encounter/threshold + Redis cache
|
||||
@@ -275,10 +286,11 @@ scripts/
|
||||
├── 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-phase10-verification.sh # Phase 10 — 12 Redis thresholds, clinical enrichment, ES pipeline, integration tests
|
||||
└── run-phase11-verification.sh # Phase 11 — warning alerts, orders API, validation, integration tests
|
||||
├── run-phase11-verification.sh # Phase 11 — warning alerts, orders API, validation, integration tests
|
||||
└── run-phase12-verification.sh # Phase 12 — NEWS2 end-to-end pipeline, API, ES, Prometheus, integration tests
|
||||
|
||||
docs/
|
||||
├── plans/ # Phase 1–11 implementation and verification guides
|
||||
├── plans/ # Phase 1–12 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
|
||||
@@ -302,14 +314,16 @@ This is the architectural decision that separates thinking about healthcare syst
|
||||
|
||||
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 for Three Distinct Purposes
|
||||
|
||||
Redis serves two independent roles with different semantics:
|
||||
Redis serves three 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.
|
||||
|
||||
3. **NEWS2 parameter state:** `SET news2:{encounterId}:{code}` with a 4-hour TTL. Each of the seven NEWS2 parameters is scored individually and stored in Redis; `MGET` across all seven keys determines completeness. An incomplete set (fewer than seven present keys) does not produce a score — expired parameters must be re-recorded before the aggregate is computed.
|
||||
|
||||
### 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.
|
||||
@@ -396,7 +410,7 @@ On startup the application:
|
||||
3. Pre-loads all thresholds into Redis
|
||||
4. Provisions Kafka topics and Elasticsearch indices
|
||||
5. Declares the RabbitMQ exchange and queue topology
|
||||
6. Starts all background consumers (outbox relay, ES indexer, sepsis engine, notification workers, data lake writer, reconciliation scheduler)
|
||||
6. Starts all background consumers (outbox relay, ES indexer, sepsis engine, warning evaluator, NEWS2 scoring, notification workers, data lake writer, reconciliation scheduler)
|
||||
7. Starts Prometheus metric collectors (unacknowledged alerts, outbox pending, Kafka consumer lag)
|
||||
|
||||
Swagger UI is available at `http://localhost:5270/swagger` in Development (API binds to `0.0.0.0:5270` per `launchSettings.json`).
|
||||
@@ -416,22 +430,25 @@ Integration tests use `WebApplicationFactory` with a `Testing` environment and T
|
||||
| `SirsDetectorTests` / `SirsEvaluatorTests` | 5 | Redis SIRS state and per-code criterion evaluation |
|
||||
| `NotificationPipelineTests` | 6 | RabbitMQ topology, DLQ routing, paging |
|
||||
| `ReconciliationTests` | 7 | Three reconciliation checks, deduplication, RabbitMQ publish |
|
||||
| `ObservabilityPhase8Tests` | 8 | All eight `/metrics` families, correlation headers, ingest counter increment |
|
||||
| `ObservabilityPhase8Tests` | 8 | All ten `/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 |
|
||||
| `WarningAlertTests` | 11 | WarningEvaluator — warning HR alert, normal/critical skipped, duplicate idempotent |
|
||||
| `OrderLifecycleTests` | 11 | Orders API create, list, record result, cancel-resulted 409 |
|
||||
| `ValidationTests` | 11 | FluentValidation 400 on empty first name, invalid threshold order, empty order description |
|
||||
| `News2CalculatorTests` | 12 | Boundary tests for all seven NEWS2 scoring tables and risk-level determination |
|
||||
| `News2DetectorTests` | 12 | NEWS2 detector — score tiers, alert creation, incomplete parameters, idempotency |
|
||||
|
||||
### Verification Scripts
|
||||
|
||||
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-phase8-verification.sh # Prometheus target UP, ten 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
|
||||
./scripts/run-phase11-verification.sh # Warning alert pipeline, orders API, FluentValidation, Phase 11 integration tests
|
||||
./scripts/run-phase12-verification.sh # NEWS2 end-to-end pipeline, API, Elasticsearch, Prometheus, Phase 12 integration tests
|
||||
```
|
||||
|
||||
Per-phase test runners (subset of `dotnet test`):
|
||||
@@ -458,20 +475,22 @@ curl https://install.duckdb.org | sh
|
||||
export PATH="$HOME/.duckdb/cli/latest:$HOME/.local/bin:$PATH"
|
||||
```
|
||||
|
||||
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.
|
||||
See `docs/plans/phase-8-plan.md` through `docs/plans/phase-12-plan.md` for manual Grafana, Seq, Kafka replay, and DuckDB query examples.
|
||||
|
||||
---
|
||||
|
||||
## Prometheus Metrics
|
||||
|
||||
`GET /metrics` exposes eight application metric families registered in `ClinicalMetrics`. Three background collectors poll PostgreSQL and Kafka every 30 seconds; counters and the ingest histogram are updated inline during request handling.
|
||||
`GET /metrics` exposes ten application metric families registered in `ClinicalMetrics`. Three background collectors poll PostgreSQL and Kafka every 30 seconds; counters and histograms are updated inline during request handling and background processing.
|
||||
|
||||
| Metric | Type | Labels | Source |
|
||||
|---|---|---|---|
|
||||
| `observations_ingested_total` | Counter | `observation_code`, `source` | `ObservationService` on each committed observation |
|
||||
| `observation_ingest_duration_seconds` | Histogram | — | `ObservationService` — full ingest transaction to COMMIT |
|
||||
| `clinical_alerts_total` | Counter | `alert_type`, `severity` | `ObservationService` (threshold breach), `SirsDetector` (sepsis) |
|
||||
| `clinical_alerts_total` | Counter | `alert_type`, `severity` | `ObservationService` (threshold breach), `SirsDetector`, `News2Detector` |
|
||||
| `sirs_detections_total` | Counter | — | `SirsDetector` — only on successful idempotent insert |
|
||||
| `news2_scores_total` | Counter | `risk_level` | `News2Detector` — on each persisted score (`LOW`, `MEDIUM`, `HIGH`, …) |
|
||||
| `news2_scoring_duration_seconds` | Histogram | — | `News2Detector` — Redis update through score persistence |
|
||||
| `escalations_total` | Counter | — | `EscalationWorkerService` on DLQ escalation |
|
||||
| `alerts_unacknowledged_gauge` | Gauge | — | `AlertsUnacknowledgedCollector` — open CRITICAL alerts older than 5 minutes |
|
||||
| `outbox_pending_events` | Gauge | — | `OutboxPendingCollector` — unprocessed outbox rows |
|
||||
@@ -726,6 +745,19 @@ pending → in_progress → resulted
|
||||
|
||||
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.
|
||||
|
||||
### NEWS2 (National Early Warning Score 2)
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/encounters/{id}/news2/current` | Latest NEWS2 score for an encounter (404 if none computed) |
|
||||
| GET | `/encounters/{id}/news2/history` | Cursor-paginated score history |
|
||||
|
||||
**`GET /news2/current` response** includes `totalScore`, `riskLevel` (`LOW`, `LOW_MEDIUM`, `MEDIUM`, `HIGH`), all seven component scores (`respRateScore`, `spo2Score`, …), `hasSingleParamThree`, and `calculatedAt`.
|
||||
|
||||
**`GET /news2/history` query params:** `limit` (default 20), `cursor` (opaque token from previous response).
|
||||
|
||||
Scores are computed asynchronously by `News2ScoringService` after observations are ingested — allow a few seconds for the Kafka consumer to process all seven parameters before querying.
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
@@ -802,8 +834,8 @@ Indexes: partial unique `(idempotency_key) WHERE idempotency_key IS NOT NULL`, `
|
||||
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
|
||||
observationId Guid? FK → Observation (null for SIRS and NEWS2 composite alerts)
|
||||
alertType string e.g. THRESHOLD_BREACH, SEPSIS_WARNING, NEWS2_WARNING, NEWS2_EMERGENCY
|
||||
severity string WARNING | CRITICAL
|
||||
details text required
|
||||
status string open | acknowledged | resolved | escalated (default: open)
|
||||
@@ -815,6 +847,27 @@ triggeredAt DateTimeOffset
|
||||
|
||||
Indexes: `(encounter_id, triggered_at DESC)`, `(patient_id, triggered_at DESC)`, partial `(severity, triggered_at DESC) WHERE status = 'open'`
|
||||
|
||||
### News2Score
|
||||
|
||||
```
|
||||
id Guid PK
|
||||
encounterId Guid FK → Encounter
|
||||
patientId Guid FK → Patient
|
||||
totalScore int aggregate 0–20+
|
||||
riskLevel string LOW | LOW_MEDIUM | MEDIUM | HIGH
|
||||
respRateScore int component 0–3
|
||||
spo2Score int
|
||||
systolicBpScore int
|
||||
heartRateScore int
|
||||
consciousnessScore int
|
||||
temperatureScore int
|
||||
supplementalO2Score int
|
||||
hasSingleParamThree bool true when any single parameter scored 3
|
||||
calculatedAt DateTimeOffset
|
||||
```
|
||||
|
||||
Indexes: `(encounter_id, calculated_at DESC)`, `(patient_id, calculated_at DESC)`
|
||||
|
||||
### Order
|
||||
|
||||
```
|
||||
@@ -875,10 +928,14 @@ createdAt DateTimeOffset
|
||||
"admissionReason": "Chest pain, rule out MI",
|
||||
"admittedAt": "2025-01-01T08:00:00Z",
|
||||
"openAlertCount": 2,
|
||||
"news2Score": 6,
|
||||
"news2RiskLevel": "MEDIUM",
|
||||
"lastObservationAt": "2025-01-01T09:45:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
`news2Score` and `news2RiskLevel` are optional — populated by `EsIndexerService` when a `NEWS2_WARNING` or `NEWS2_EMERGENCY` alert is indexed (payload fields `news2Score` / `news2RiskLevel` from `News2Detector`). LOW-risk scores with no alert are not projected to Elasticsearch.
|
||||
|
||||
### `observations`
|
||||
|
||||
```json
|
||||
@@ -931,12 +988,23 @@ Exchange: `clinical.notifications.exchange` (direct)
|
||||
|
||||
| Topic | Partition key | Consumer groups |
|
||||
|---|---|---|
|
||||
| `observation.recorded` | `encounterId` | `es-indexer`, `sepsis-engine`, `warning-evaluator`, `data-lake-writer` |
|
||||
| `observation.recorded` | `encounterId` | `es-indexer`, `sepsis-engine`, `warning-evaluator`, `news2-scoring`, `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.
|
||||
|
||||
**`alert.generated` payload (minimum fields for downstream consumers):**
|
||||
|
||||
| Field | Required by | Notes |
|
||||
|---|---|---|
|
||||
| `alertId`, `encounterId`, `patientId` | ES indexer, data lake, paging | UUIDs |
|
||||
| `alertType`, `severity`, `triggeredAt` | All consumers | DB string literals for type/severity |
|
||||
| `details` | Data lake Parquet | Human-readable breach summary; always set on new alerts |
|
||||
| `department` | ES indexer | Optional; critical ingest alerts include it |
|
||||
| `news2Score`, `news2RiskLevel` | ES indexer | Optional; set on `NEWS2_WARNING` / `NEWS2_EMERGENCY` alerts for encounter document projection |
|
||||
| `partitionKey` | Outbox relay | Same as `encounterId` |
|
||||
|
||||
---
|
||||
|
||||
## SIRS Criteria
|
||||
@@ -954,6 +1022,33 @@ When ≥ 2 criteria are active simultaneously (keys present in Redis) for the sa
|
||||
|
||||
---
|
||||
|
||||
## NEWS2 Scoring
|
||||
|
||||
The NEWS2 engine evaluates seven observation codes that overlap with the expanded vital-sign vocabulary from Phase 10:
|
||||
|
||||
| Parameter | Observation Code | Score range |
|
||||
|---|---|---|
|
||||
| Respiratory rate | `RESP_RATE` | 0–3 |
|
||||
| Oxygen saturation (Scale 1) | `SPO2` | 0–3 |
|
||||
| Systolic blood pressure | `SYSTOLIC_BP` | 0–3 |
|
||||
| Heart rate | `HEART_RATE` | 0–3 |
|
||||
| Consciousness (AVPU) | `AVPU` | 0 or 3 |
|
||||
| Temperature | `TEMP_C` | 0–3 |
|
||||
| Supplemental oxygen | `SUPPLEMENTAL_O2` | 0 or 2 |
|
||||
|
||||
**Risk levels** (aggregate score):
|
||||
|
||||
| Total score | Risk level | Alert |
|
||||
|---|---|---|
|
||||
| 0–4 (no single param = 3) | `LOW` | None |
|
||||
| Any single param = 3 (total under 5) | `LOW_MEDIUM` | `NEWS2_WARNING` |
|
||||
| 5–6 | `MEDIUM` | `NEWS2_WARNING` |
|
||||
| ≥ 7 | `HIGH` | `NEWS2_EMERGENCY` (CRITICAL) |
|
||||
|
||||
All seven parameters must be present in Redis (4-hour TTL per key) before a score is computed. Each new observation after completeness triggers a new `news2_scores` row; alert creation is idempotent per encounter and alert type while an alert remains open.
|
||||
|
||||
---
|
||||
|
||||
## Elasticsearch Index Replay
|
||||
|
||||
If the Elasticsearch indices need to be rebuilt (e.g., after a mapping change or data loss):
|
||||
@@ -1031,7 +1126,7 @@ Observation history uses cursor pagination on `(recorded_at DESC, id DESC)`. Off
|
||||
|
||||
## Implemented Phases
|
||||
|
||||
Eleven phases from the project roadmap are implemented. Phases 1–10 and Step 4 of Phase 11 are covered by integration tests (`dotnet test` — 64 passing). Phase 11 Step 5 manual verification (docker compose end-to-end) is documented in `docs/plans/phase-11-plan.md`.
|
||||
Twelve phases from the project roadmap are implemented and verified. Integration tests (`dotnet test` — 126 passing) and per-phase verification scripts cover Phases 8–12.
|
||||
|
||||
| Phase | Feature | Status |
|
||||
|---|---|---|
|
||||
@@ -1042,9 +1137,10 @@ Eleven phases from the project roadmap are implemented. Phases 1–10 and Step 4
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 8 | Prometheus metrics (`GET /metrics`); ten 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 |
|
||||
| 11 | Warning alert consumer (`WarningAlertService` / `warning-evaluator`); 10 `Warning*` alert types; Orders API (`OrdersController`, `OrderService`); FluentValidation on all request DTOs; `WarningAlertTests`, `OrderLifecycleTests`, `ValidationTests`; `run-phase11-verification.sh` | Done (Step 5 E2E verification via script) |
|
||||
| 11 | Warning alert consumer (`WarningAlertService` / `warning-evaluator`); 10 `Warning*` alert types; Orders API (`OrdersController`, `OrderService`); FluentValidation on all request DTOs; `WarningAlertTests`, `OrderLifecycleTests`, `ValidationTests`; `run-phase11-verification.sh` | Done |
|
||||
| 12 | NEWS2 composite scoring (`News2Calculator`, `News2Detector`, `News2ScoringService`); `news2_scores` table; `NEWS2_WARNING` / `NEWS2_EMERGENCY` alert types; `News2Controller` (current + history); ES `news2Score` / `news2RiskLevel` projection; Prometheus NEWS2 metrics; `News2CalculatorTests`, `News2DetectorTests`; `run-phase12-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.
|
||||
|
||||
@@ -8,7 +8,7 @@ public static class DbResetHelper
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(@"
|
||||
TRUNCATE TABLE reconciliation_alerts, outbox_events, orders,
|
||||
clinical_alerts, observations, encounters,
|
||||
clinical_alerts, news2_scores, observations, encounters,
|
||||
alert_thresholds, patients
|
||||
RESTART IDENTITY CASCADE;
|
||||
");
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using FluentAssertions;
|
||||
|
||||
public class News2CalculatorTests
|
||||
{
|
||||
// --- Respiratory rate ---
|
||||
[Theory]
|
||||
[InlineData(8, 3)] // ≤8
|
||||
[InlineData(9, 1)] // 9-11
|
||||
[InlineData(11, 1)]
|
||||
[InlineData(12, 0)] // 12-20
|
||||
[InlineData(20, 0)]
|
||||
[InlineData(21, 2)] // 21-24
|
||||
[InlineData(24, 2)]
|
||||
[InlineData(25, 3)] // ≥25
|
||||
public void ScoreRespRate(int value, int expected) =>
|
||||
News2Calculator.ScoreRespRate(value).Should().Be(expected);
|
||||
|
||||
// --- SpO2 (Scale 1) ---
|
||||
[Theory]
|
||||
[InlineData(91, 3)]
|
||||
[InlineData(92, 2)]
|
||||
[InlineData(93, 2)]
|
||||
[InlineData(94, 1)]
|
||||
[InlineData(95, 1)]
|
||||
[InlineData(96, 0)]
|
||||
[InlineData(99, 0)]
|
||||
public void ScoreSpo2(int value, int expected) =>
|
||||
News2Calculator.ScoreSpo2(value).Should().Be(expected);
|
||||
|
||||
// --- Systolic BP ---
|
||||
[Theory]
|
||||
[InlineData(90, 3)]
|
||||
[InlineData(91, 2)]
|
||||
[InlineData(100, 2)]
|
||||
[InlineData(101, 1)]
|
||||
[InlineData(110, 1)]
|
||||
[InlineData(111, 0)]
|
||||
[InlineData(219, 0)]
|
||||
[InlineData(220, 3)]
|
||||
public void ScoreSystolicBp(int value, int expected) =>
|
||||
News2Calculator.ScoreSystolicBp(value).Should().Be(expected);
|
||||
|
||||
// --- Heart rate ---
|
||||
[Theory]
|
||||
[InlineData(40, 3)]
|
||||
[InlineData(41, 1)]
|
||||
[InlineData(50, 1)]
|
||||
[InlineData(51, 0)]
|
||||
[InlineData(90, 0)]
|
||||
[InlineData(91, 1)]
|
||||
[InlineData(110, 1)]
|
||||
[InlineData(111, 2)]
|
||||
[InlineData(130, 2)]
|
||||
[InlineData(131, 3)]
|
||||
public void ScoreHeartRate(int value, int expected) =>
|
||||
News2Calculator.ScoreHeartRate(value).Should().Be(expected);
|
||||
|
||||
// --- AVPU ---
|
||||
[Theory]
|
||||
[InlineData(0, 0)] // Alert
|
||||
[InlineData(1, 3)] // Voice
|
||||
[InlineData(2, 3)] // Pain
|
||||
[InlineData(3, 3)] // Unresponsive
|
||||
public void ScoreConsciousness(int value, int expected) =>
|
||||
News2Calculator.ScoreConsciousness(value).Should().Be(expected);
|
||||
|
||||
// --- Temperature ---
|
||||
[Theory]
|
||||
[InlineData(35.0, 3)]
|
||||
[InlineData(35.1, 1)]
|
||||
[InlineData(36.0, 1)]
|
||||
[InlineData(36.1, 0)]
|
||||
[InlineData(38.0, 0)]
|
||||
[InlineData(38.1, 1)]
|
||||
[InlineData(39.0, 1)]
|
||||
[InlineData(39.1, 2)]
|
||||
public void ScoreTemperature(double value, int expected) =>
|
||||
News2Calculator.ScoreTemperature((decimal)value).Should().Be(expected);
|
||||
|
||||
// --- Supplemental O2 ---
|
||||
[Theory]
|
||||
[InlineData(0, 0)]
|
||||
[InlineData(1, 2)]
|
||||
public void ScoreSupplementalO2(int value, int expected) =>
|
||||
News2Calculator.ScoreSupplementalO2(value).Should().Be(expected);
|
||||
|
||||
// --- Risk level determination ---
|
||||
[Theory]
|
||||
[InlineData(0, false, "LOW")]
|
||||
[InlineData(4, false, "LOW")]
|
||||
[InlineData(3, true, "LOW_MEDIUM")] // single param = 3
|
||||
[InlineData(5, false, "MEDIUM")]
|
||||
[InlineData(6, true, "MEDIUM")] // 5-6 is MEDIUM regardless of single-3
|
||||
[InlineData(7, false, "HIGH")]
|
||||
[InlineData(12, true, "HIGH")]
|
||||
public void DetermineRiskLevel(int total, bool singleThree, string expected) =>
|
||||
News2Calculator.DetermineRiskLevel(total, singleThree).Should().Be(expected);
|
||||
|
||||
// --- All 7 keys returned ---
|
||||
[Fact]
|
||||
public void AllParameterKeys_ReturnsSeven()
|
||||
{
|
||||
var keys = News2Calculator.AllParameterKeys(Guid.NewGuid());
|
||||
keys.Should().HaveCount(7);
|
||||
keys.Select(k => k.ToString()).Should().OnlyHaveUniqueItems();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StackExchange.Redis;
|
||||
|
||||
[Collection("Integration")]
|
||||
public class News2DetectorTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ApiFixture _fixture;
|
||||
private Guid _encounterId;
|
||||
private Guid _patientId;
|
||||
|
||||
public News2DetectorTests(ApiFixture fixture) => _fixture = fixture;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await DbResetHelper.ResetAsync(db);
|
||||
|
||||
var patient = new Patient
|
||||
{
|
||||
Id = Guid.NewGuid(), Mrn = "MRN-NEWS2-001", FirstName = "NEWS2", LastName = "Test",
|
||||
DateOfBirth = new DateOnly(1960, 1, 1), Gender = "M",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
var encounter = new Encounter
|
||||
{
|
||||
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
|
||||
Status = EncounterStatus.Active, Department = Department.Icu,
|
||||
AttendingPhysician = "Dr. NEWS2", AdmittedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Patients.Add(patient);
|
||||
db.Encounters.Add(encounter);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
_patientId = patient.Id;
|
||||
_encounterId = encounter.Id;
|
||||
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
var cache = redis.GetDatabase();
|
||||
foreach (var key in News2Calculator.AllParameterKeys(_encounterId))
|
||||
await cache.KeyDeleteAsync(key);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task AllNormalParameters_ScoreZero_NoAlert()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var detector = scope.ServiceProvider.GetRequiredService<News2Detector>();
|
||||
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 16m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "SPO2", 98m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 120m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 72m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "AVPU", 0m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 36.8m);
|
||||
var result = await detector.ProcessObservationAsync(
|
||||
_encounterId, _patientId, "SUPPLEMENTAL_O2", 0m);
|
||||
|
||||
result.Outcome.Should().Be(News2Outcome.ScoreComputed);
|
||||
result.TotalScore.Should().Be(0);
|
||||
result.RiskLevel.Should().Be("LOW");
|
||||
result.AlertCreated.Should().BeFalse();
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var score = await db.News2Scores.SingleAsync();
|
||||
score.TotalScore.Should().Be(0);
|
||||
score.RiskLevel.Should().Be("LOW");
|
||||
(await db.ClinicalAlerts.CountAsync()).Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MediumRisk_WarningAlert()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var detector = scope.ServiceProvider.GetRequiredService<News2Detector>();
|
||||
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 22m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "SPO2", 93m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 105m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 95m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "AVPU", 0m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 37.0m);
|
||||
var result = await detector.ProcessObservationAsync(
|
||||
_encounterId, _patientId, "SUPPLEMENTAL_O2", 0m);
|
||||
|
||||
result.TotalScore.Should().Be(6);
|
||||
result.RiskLevel.Should().Be("MEDIUM");
|
||||
result.AlertCreated.Should().BeTrue();
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var alert = await db.ClinicalAlerts.SingleAsync();
|
||||
alert.AlertType.Should().Be(AlertType.News2Warning);
|
||||
alert.Severity.Should().Be(AlertSeverity.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HighRisk_CriticalAlert()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var detector = scope.ServiceProvider.GetRequiredService<News2Detector>();
|
||||
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 25m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "SPO2", 91m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 72m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "AVPU", 0m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 37.0m);
|
||||
var result = await detector.ProcessObservationAsync(
|
||||
_encounterId, _patientId, "SUPPLEMENTAL_O2", 0m);
|
||||
|
||||
result.TotalScore.Should().Be(8);
|
||||
result.RiskLevel.Should().Be("HIGH");
|
||||
result.AlertCreated.Should().BeTrue();
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var alert = await db.ClinicalAlerts.SingleAsync();
|
||||
alert.AlertType.Should().Be(AlertType.News2Emergency);
|
||||
alert.Severity.Should().Be(AlertSeverity.Critical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SingleParamThree_LowMedium_Warning()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var detector = scope.ServiceProvider.GetRequiredService<News2Detector>();
|
||||
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 8m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "SPO2", 98m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 120m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 72m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "AVPU", 0m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 37.0m);
|
||||
var result = await detector.ProcessObservationAsync(
|
||||
_encounterId, _patientId, "SUPPLEMENTAL_O2", 0m);
|
||||
|
||||
result.TotalScore.Should().Be(3);
|
||||
result.RiskLevel.Should().Be("LOW_MEDIUM");
|
||||
result.AlertCreated.Should().BeTrue();
|
||||
result.HasSingleParamThree.Should().BeTrue();
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var score = await db.News2Scores.SingleAsync();
|
||||
score.HasSingleParamThree.Should().BeTrue();
|
||||
|
||||
var alert = await db.ClinicalAlerts.SingleAsync();
|
||||
alert.AlertType.Should().Be(AlertType.News2Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IncompleteParameters_NoScore()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var detector = scope.ServiceProvider.GetRequiredService<News2Detector>();
|
||||
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 16m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "SPO2", 98m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 72m);
|
||||
var result = await detector.ProcessObservationAsync(
|
||||
_encounterId, _patientId, "TEMP_C", 37.0m);
|
||||
|
||||
result.Outcome.Should().Be(News2Outcome.IncompleteParameters);
|
||||
result.PresentParameters.Should().Be(4);
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
(await db.News2Scores.CountAsync()).Should().Be(0);
|
||||
(await db.ClinicalAlerts.CountAsync()).Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonNews2Code_Ignored()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var detector = scope.ServiceProvider.GetRequiredService<News2Detector>();
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
|
||||
var result = await detector.ProcessObservationAsync(
|
||||
_encounterId, _patientId, "POTASSIUM_MEQ_L", 4.0m);
|
||||
|
||||
result.Outcome.Should().Be(News2Outcome.NotNews2Code);
|
||||
|
||||
var anyNews2Key = false;
|
||||
foreach (var key in News2Calculator.AllParameterKeys(_encounterId))
|
||||
{
|
||||
if (await redis.GetDatabase().KeyExistsAsync(key))
|
||||
anyNews2Key = true;
|
||||
}
|
||||
anyNews2Key.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DuplicateHighRisk_Idempotent()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var detector = scope.ServiceProvider.GetRequiredService<News2Detector>();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
// First pass — score 8, creates alert
|
||||
await FeedHighRiskSet(detector);
|
||||
|
||||
// Second scoring event — one parameter update while all 7 keys remain populated.
|
||||
// Each observation after completeness triggers a new score row; alert must stay deduplicated.
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 72m);
|
||||
|
||||
(await db.ClinicalAlerts.CountAsync()).Should().Be(1,
|
||||
"second NEWS2_EMERGENCY must be idempotent while first is still open");
|
||||
(await db.News2Scores.CountAsync()).Should().Be(2,
|
||||
"both scores should be persisted even though alert is deduplicated");
|
||||
}
|
||||
|
||||
private async Task FeedHighRiskSet(News2Detector detector)
|
||||
{
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 25m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "SPO2", 91m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 72m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "AVPU", 0m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 37.0m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "SUPPLEMENTAL_O2", 0m);
|
||||
}
|
||||
}
|
||||
@@ -58,8 +58,10 @@ public class ElasticIndexProvisioner : IHostedService
|
||||
.Keyword(k => k.Department)
|
||||
.Keyword(k => k.Status)
|
||||
.Keyword(k => k.AttendingPhysician)
|
||||
.Keyword(k => k.RoomBed)
|
||||
.Text(t => t.AdmissionReason)
|
||||
.Keyword(k => k.RoomBed!)
|
||||
.Text(t => t.AdmissionReason!)
|
||||
.IntegerNumber(i => i.News2Score!)
|
||||
.Keyword(k => k.News2RiskLevel!)
|
||||
.Date(d => d.AdmittedAt)
|
||||
.IntegerNumber(i => i.OpenAlertCount)
|
||||
.Date(d => d.LastObservationAt!)
|
||||
|
||||
@@ -201,8 +201,10 @@ public class EsIndexerService : BackgroundService
|
||||
private async Task HandleAlertGeneratedAsync(string payload, CancellationToken ct)
|
||||
{
|
||||
var evt = JsonSerializer.Deserialize<AlertGeneratedEvent>(payload, EventJsonOptions)!;
|
||||
using var payloadDoc = JsonDocument.Parse(payload);
|
||||
var root = payloadDoc.RootElement;
|
||||
|
||||
var doc = new ClinicalAlertDocument
|
||||
var alertDoc = new ClinicalAlertDocument
|
||||
{
|
||||
AlertId = evt.AlertId.ToString(),
|
||||
EncounterId = evt.EncounterId.ToString(),
|
||||
@@ -215,29 +217,47 @@ public class EsIndexerService : BackgroundService
|
||||
};
|
||||
|
||||
var indexResp = await _elastic.IndexAsync(
|
||||
doc,
|
||||
i => i.Index(_esOptions.Indices.ClinicalAlerts).Id(doc.AlertId),
|
||||
alertDoc,
|
||||
i => i.Index(_esOptions.Indices.ClinicalAlerts).Id(alertDoc.AlertId),
|
||||
ct);
|
||||
|
||||
if (!indexResp.IsValidResponse)
|
||||
throw new InvalidOperationException(
|
||||
$"ES index failed for alert {evt.AlertId}: {indexResp.DebugInformation}");
|
||||
|
||||
// Increment openAlertCount on the parent encounter document
|
||||
// Increment openAlertCount on the parent encounter document.
|
||||
// NEWS2 alerts also carry news2Score/news2RiskLevel in the Kafka payload;
|
||||
// stamp those on patient_encounters so ward dashboards can filter by acuity.
|
||||
var scriptLines = new List<string> { "ctx._source.openAlertCount += 1" };
|
||||
Dictionary<string, object>? scriptParams = null;
|
||||
|
||||
if (root.TryGetProperty("news2Score", out var scoreElem) &&
|
||||
root.TryGetProperty("news2RiskLevel", out var riskElem))
|
||||
{
|
||||
scriptLines.Add("ctx._source.news2Score = params.score");
|
||||
scriptLines.Add("ctx._source.news2RiskLevel = params.riskLevel");
|
||||
scriptParams = new Dictionary<string, object>
|
||||
{
|
||||
["score"] = scoreElem.GetInt32(),
|
||||
["riskLevel"] = riskElem.GetString()!
|
||||
};
|
||||
}
|
||||
|
||||
var updateResp = await _elastic.UpdateAsync<PatientEncounterDocument, object>(
|
||||
_esOptions.Indices.PatientEncounters,
|
||||
evt.EncounterId.ToString(),
|
||||
u => u
|
||||
.Script(new Script(new InlineScript
|
||||
{
|
||||
Source = "ctx._source.openAlertCount += 1",
|
||||
Language = ScriptLanguage.Painless
|
||||
Source = string.Join(";\n", scriptLines),
|
||||
Language = ScriptLanguage.Painless,
|
||||
Params = scriptParams
|
||||
}))
|
||||
.RetryOnConflict(3),
|
||||
ct);
|
||||
|
||||
if (!updateResp.IsValidResponse && updateResp.Result != Result.NotFound)
|
||||
_logger.LogWarning(
|
||||
"Could not increment openAlertCount for encounter {Id}", evt.EncounterId);
|
||||
"Could not update patient_encounters for alert on encounter {Id}", evt.EncounterId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Text.Json;
|
||||
using Confluent.Kafka;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public class News2ScoringService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly KafkaOptions _kafkaOptions;
|
||||
private readonly ILogger<News2ScoringService> _logger;
|
||||
|
||||
public News2ScoringService(
|
||||
IServiceProvider services,
|
||||
IOptions<KafkaOptions> kafkaOptions,
|
||||
ILogger<News2ScoringService> logger)
|
||||
{
|
||||
_services = services;
|
||||
_kafkaOptions = kafkaOptions.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var config = new ConsumerConfig
|
||||
{
|
||||
BootstrapServers = _kafkaOptions.BootstrapServers,
|
||||
GroupId = "news2-scoring",
|
||||
AutoOffsetReset = AutoOffsetReset.Earliest,
|
||||
EnableAutoCommit = false
|
||||
};
|
||||
|
||||
using var consumer = new ConsumerBuilder<string, string>(config).Build();
|
||||
consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded);
|
||||
|
||||
_logger.LogInformation("News2ScoringService started — consumer group: news2-scoring");
|
||||
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
ConsumeResult<string, string>? result = null;
|
||||
try
|
||||
{
|
||||
result = consumer.Consume(stoppingToken);
|
||||
|
||||
var evt = JsonSerializer.Deserialize<News2ObservationEvent>(
|
||||
result.Message.Value,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var detector = scope.ServiceProvider.GetRequiredService<News2Detector>();
|
||||
|
||||
var outcome = await detector.ProcessObservationAsync(
|
||||
evt.EncounterId,
|
||||
evt.PatientId,
|
||||
evt.ObservationCode,
|
||||
evt.Value,
|
||||
stoppingToken);
|
||||
|
||||
if (outcome.Outcome == News2Outcome.ScoreComputed)
|
||||
_logger.LogInformation(
|
||||
"NEWS2 scored via consumer — encounter={Id} score={Score} risk={Risk}",
|
||||
evt.EncounterId, outcome.TotalScore, outcome.RiskLevel);
|
||||
|
||||
consumer.Commit(result);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"News2ScoringService failed on topic={Topic} offset={Offset} — not committing",
|
||||
result?.Topic, result?.Offset.Value);
|
||||
await Task.Delay(2000, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
consumer.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// NEWS2 composite scoring: current score and paginated history per encounter.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/encounters/{encounterId:guid}/news2")]
|
||||
[Produces("application/json")]
|
||||
public class News2Controller : ControllerBase
|
||||
{
|
||||
private readonly INews2Service _news2;
|
||||
|
||||
public News2Controller(INews2Service news2) => _news2 = news2;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the latest NEWS2 score for an encounter, or 404 if no score has been computed.
|
||||
/// </summary>
|
||||
[HttpGet("current")]
|
||||
[ProducesResponseType(typeof(ApiResponse<News2Score>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Current(Guid encounterId)
|
||||
{
|
||||
var score = await _news2.GetCurrentAsync(encounterId);
|
||||
if (score is null)
|
||||
return NotFound(ApiResponse<object>.Fail(404, "No NEWS2 score computed for this encounter.", "NO_NEWS2_SCORE"));
|
||||
return Ok(ApiResponse<News2Score>.Ok(score));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns cursor-paginated NEWS2 score history for an encounter.
|
||||
/// </summary>
|
||||
[HttpGet("history")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> History(
|
||||
Guid encounterId,
|
||||
[FromQuery] int limit = 20,
|
||||
[FromQuery] string? cursor = null)
|
||||
{
|
||||
var page = await _news2.GetHistoryAsync(encounterId, limit, cursor);
|
||||
return Ok(ApiResponse<object>.Ok(new
|
||||
{
|
||||
items = page.Items,
|
||||
nextCursor = page.NextCursor,
|
||||
hasMore = page.HasMore
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ public class AppDbContext : DbContext
|
||||
public DbSet<Order> Orders => Set<Order>();
|
||||
public DbSet<OutboxEvent> OutboxEvents => Set<OutboxEvent>();
|
||||
public DbSet<ReconciliationAlert> ReconciliationAlerts => Set<ReconciliationAlert>();
|
||||
public DbSet<News2Score> News2Scores => Set<News2Score>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class News2ScoreConfiguration : IEntityTypeConfiguration<News2Score>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<News2Score> builder)
|
||||
{
|
||||
builder.ToTable("news2_scores", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_news2_scores_risk_level",
|
||||
"risk_level IN ('LOW', 'LOW_MEDIUM', 'MEDIUM', 'HIGH')");
|
||||
});
|
||||
builder.HasKey(n => n.Id);
|
||||
builder.Property(n => n.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(n => n.EncounterId).HasColumnName("encounter_id").IsRequired();
|
||||
builder.Property(n => n.PatientId).HasColumnName("patient_id").IsRequired();
|
||||
builder.Property(n => n.TotalScore).HasColumnName("total_score").IsRequired();
|
||||
builder.Property(n => n.RiskLevel).HasColumnName("risk_level").HasMaxLength(20).IsRequired();
|
||||
builder.Property(n => n.RespRateScore).HasColumnName("resp_rate_score").IsRequired();
|
||||
builder.Property(n => n.Spo2Score).HasColumnName("spo2_score").IsRequired();
|
||||
builder.Property(n => n.SystolicBpScore).HasColumnName("systolic_bp_score").IsRequired();
|
||||
builder.Property(n => n.HeartRateScore).HasColumnName("heart_rate_score").IsRequired();
|
||||
builder.Property(n => n.ConsciousnessScore).HasColumnName("consciousness_score").IsRequired();
|
||||
builder.Property(n => n.TemperatureScore).HasColumnName("temperature_score").IsRequired();
|
||||
builder.Property(n => n.SupplementalO2Score).HasColumnName("supplemental_o2_score").IsRequired();
|
||||
builder.Property(n => n.HasSingleParamThree).HasColumnName("has_single_param_three").IsRequired();
|
||||
builder.Property(n => n.CalculatedAt).HasColumnName("calculated_at").IsRequired();
|
||||
|
||||
builder.HasOne(n => n.Encounter)
|
||||
.WithMany()
|
||||
.HasForeignKey(n => n.EncounterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(n => new { n.EncounterId, n.CalculatedAt });
|
||||
builder.HasIndex(n => new { n.PatientId, n.CalculatedAt });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
public class News2Score
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid EncounterId { get; set; }
|
||||
public Guid PatientId { get; set; }
|
||||
public int TotalScore { get; set; }
|
||||
public string RiskLevel { get; set; } = null!;
|
||||
public int RespRateScore { get; set; }
|
||||
public int Spo2Score { get; set; }
|
||||
public int SystolicBpScore { get; set; }
|
||||
public int HeartRateScore { get; set; }
|
||||
public int ConsciousnessScore { get; set; }
|
||||
public int TemperatureScore { get; set; }
|
||||
public int SupplementalO2Score { get; set; }
|
||||
public bool HasSingleParamThree { get; set; }
|
||||
public DateTimeOffset CalculatedAt { get; set; }
|
||||
|
||||
public Encounter Encounter { get; set; } = null!;
|
||||
}
|
||||
@@ -23,7 +23,10 @@ public enum AlertType
|
||||
WarningSystolicBp,
|
||||
WarningDiastolicBp,
|
||||
WarningLactateMmolL,
|
||||
WarningGlucoseMgDl
|
||||
WarningGlucoseMgDl,
|
||||
|
||||
News2Warning,
|
||||
News2Emergency
|
||||
}
|
||||
|
||||
public static class AlertTypeExtensions
|
||||
@@ -52,6 +55,8 @@ public static class AlertTypeExtensions
|
||||
AlertType.WarningDiastolicBp => "WARNING_DIASTOLIC_BP",
|
||||
AlertType.WarningLactateMmolL => "WARNING_LACTATE_MMOL_L",
|
||||
AlertType.WarningGlucoseMgDl => "WARNING_GLUCOSE_MG_DL",
|
||||
AlertType.News2Warning => "NEWS2_WARNING",
|
||||
AlertType.News2Emergency => "NEWS2_EMERGENCY",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||
};
|
||||
|
||||
@@ -79,6 +84,8 @@ public static class AlertTypeExtensions
|
||||
"WARNING_DIASTOLIC_BP" => AlertType.WarningDiastolicBp,
|
||||
"WARNING_LACTATE_MMOL_L" => AlertType.WarningLactateMmolL,
|
||||
"WARNING_GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl,
|
||||
"NEWS2_WARNING" => AlertType.News2Warning,
|
||||
"NEWS2_EMERGENCY" => AlertType.News2Emergency,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert type: '{v}'")
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
public enum News2Outcome
|
||||
{
|
||||
NotNews2Code,
|
||||
IncompleteParameters,
|
||||
ScoreComputed
|
||||
}
|
||||
@@ -11,5 +11,7 @@ public class PatientEncounterDocument
|
||||
public string? RoomBed { get; set; }
|
||||
public string? AdmissionReason { get; set; }
|
||||
public int OpenAlertCount { get; set; }
|
||||
public int? News2Score { get; set; }
|
||||
public string? News2RiskLevel { get; set; }
|
||||
public DateTimeOffset? LastObservationAt { get; set; }
|
||||
}
|
||||
+716
@@ -0,0 +1,716 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260618083117_AddNews2ScoresTable")]
|
||||
partial class AddNews2ScoresTable
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("AlertThreshold", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<decimal?>("CriticalHigh")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("critical_high");
|
||||
|
||||
b.Property<decimal?>("CriticalLow")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("critical_low");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<decimal?>("WarningHigh")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("warning_high");
|
||||
|
||||
b.Property<decimal?>("WarningLow")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("warning_low");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ObservationCode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("alert_thresholds", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClinicalAlert", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset?>("AcknowledgedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("acknowledged_at");
|
||||
|
||||
b.Property<string>("AcknowledgedBy")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("acknowledged_by");
|
||||
|
||||
b.Property<string>("AlertType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("alert_type");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("details");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<Guid?>("ObservationId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("observation_id");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("resolved_at");
|
||||
|
||||
b.Property<string>("Severity")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("severity");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'OPEN'");
|
||||
|
||||
b.Property<DateTimeOffset>("TriggeredAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("triggered_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EncounterId", "TriggeredAt");
|
||||
|
||||
b.HasIndex("PatientId", "TriggeredAt");
|
||||
|
||||
b.HasIndex("Severity", "TriggeredAt")
|
||||
.HasFilter("status = 'OPEN'");
|
||||
|
||||
b.ToTable("clinical_alerts", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL')");
|
||||
|
||||
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
|
||||
|
||||
t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Encounter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("AdmissionReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("admission_reason");
|
||||
|
||||
b.Property<DateTimeOffset>("AdmittedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("admitted_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("AttendingPhysician")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("attending_physician");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Department")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("department");
|
||||
|
||||
b.Property<string>("DischargeDiagnosis")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("discharge_diagnosis");
|
||||
|
||||
b.Property<DateTimeOffset?>("DischargedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("discharged_at");
|
||||
|
||||
b.Property<string>("EncounterType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("encounter_type");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<string>("RoomBed")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("room_bed");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'SCHEDULED'");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PatientId", "AdmittedAt");
|
||||
|
||||
b.HasIndex("Status", "AdmittedAt")
|
||||
.HasFilter("status = 'ACTIVE'");
|
||||
|
||||
b.ToTable("encounters", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')");
|
||||
|
||||
t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
|
||||
|
||||
t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("News2Score", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CalculatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("calculated_at");
|
||||
|
||||
b.Property<int>("ConsciousnessScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("consciousness_score");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<bool>("HasSingleParamThree")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_single_param_three");
|
||||
|
||||
b.Property<int>("HeartRateScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("heart_rate_score");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<int>("RespRateScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("resp_rate_score");
|
||||
|
||||
b.Property<string>("RiskLevel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("risk_level");
|
||||
|
||||
b.Property<int>("Spo2Score")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("spo2_score");
|
||||
|
||||
b.Property<int>("SupplementalO2Score")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("supplemental_o2_score");
|
||||
|
||||
b.Property<int>("SystolicBpScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("systolic_bp_score");
|
||||
|
||||
b.Property<int>("TemperatureScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("temperature_score");
|
||||
|
||||
b.Property<int>("TotalScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("total_score");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EncounterId", "CalculatedAt");
|
||||
|
||||
b.HasIndex("PatientId", "CalculatedAt");
|
||||
|
||||
b.ToTable("news2_scores", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_news2_scores_risk_level", "risk_level IN ('LOW', 'LOW_MEDIUM', 'MEDIUM', 'HIGH')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Observation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("idempotency_key");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
b.Property<DateTimeOffset>("RecordedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("recorded_at");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("source")
|
||||
.HasDefaultValueSql("'MANUAL'");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<decimal>("Value")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IdempotencyKey")
|
||||
.IsUnique()
|
||||
.HasFilter("idempotency_key IS NOT NULL");
|
||||
|
||||
b.HasIndex("EncounterId", "ObservationCode", "RecordedAt");
|
||||
|
||||
b.ToTable("observations", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Order", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("OrderType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("order_type");
|
||||
|
||||
b.Property<DateTimeOffset>("OrderedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("ordered_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("OrderedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("ordered_by");
|
||||
|
||||
b.Property<string>("ResultSummary")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("result_summary");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResultedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("resulted_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'PENDING'");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EncounterId", "OrderedAt");
|
||||
|
||||
b.HasIndex("Status", "OrderedAt")
|
||||
.HasFilter("status IN ('PENDING', 'IN_PROGRESS')");
|
||||
|
||||
b.ToTable("orders", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')");
|
||||
|
||||
t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OutboxEvent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("PartitionKey")
|
||||
.HasMaxLength(36)
|
||||
.HasColumnType("character varying(36)")
|
||||
.HasColumnName("partition_key");
|
||||
|
||||
b.Property<string>("Payload")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<DateTimeOffset?>("ProcessedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("processed_at");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("topic");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasFilter("processed_at IS NULL");
|
||||
|
||||
b.ToTable("outbox_events", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Patient", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("Allergies")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("allergies");
|
||||
|
||||
b.Property<string>("BloodType")
|
||||
.HasMaxLength(5)
|
||||
.HasColumnType("character varying(5)")
|
||||
.HasColumnName("blood_type");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<DateOnly>("DateOfBirth")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("date_of_birth");
|
||||
|
||||
b.Property<string>("EmergencyContactName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("emergency_contact_name");
|
||||
|
||||
b.Property<string>("EmergencyContactPhone")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("emergency_contact_phone");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("first_name");
|
||||
|
||||
b.Property<string>("Gender")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasColumnName("gender");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("last_name");
|
||||
|
||||
b.Property<string>("Mrn")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("mrn");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Mrn")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("patients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReconciliationAlert", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("CheckType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("check_type");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("details");
|
||||
|
||||
b.Property<Guid?>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<Guid?>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("resolved_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EncounterId");
|
||||
|
||||
b.HasIndex("PatientId");
|
||||
|
||||
b.HasIndex("CheckType", "EncounterId")
|
||||
.HasFilter("resolved_at IS NULL");
|
||||
|
||||
b.ToTable("reconciliation_alerts", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClinicalAlert", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany("Alerts")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Encounter", b =>
|
||||
{
|
||||
b.HasOne("Patient", "Patient")
|
||||
.WithMany("Encounters")
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("News2Score", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany()
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Observation", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany("Observations")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Order", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany("Orders")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReconciliationAlert", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany()
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Patient", "Patient")
|
||||
.WithMany()
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Encounter");
|
||||
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Encounter", b =>
|
||||
{
|
||||
b.Navigation("Alerts");
|
||||
|
||||
b.Navigation("Observations");
|
||||
|
||||
b.Navigation("Orders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Patient", b =>
|
||||
{
|
||||
b.Navigation("Encounters");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNews2ScoresTable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "news2_scores",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
total_score = table.Column<int>(type: "integer", nullable: false),
|
||||
risk_level = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
resp_rate_score = table.Column<int>(type: "integer", nullable: false),
|
||||
spo2_score = table.Column<int>(type: "integer", nullable: false),
|
||||
systolic_bp_score = table.Column<int>(type: "integer", nullable: false),
|
||||
heart_rate_score = table.Column<int>(type: "integer", nullable: false),
|
||||
consciousness_score = table.Column<int>(type: "integer", nullable: false),
|
||||
temperature_score = table.Column<int>(type: "integer", nullable: false),
|
||||
supplemental_o2_score = table.Column<int>(type: "integer", nullable: false),
|
||||
has_single_param_three = table.Column<bool>(type: "boolean", nullable: false),
|
||||
calculated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_news2_scores", x => x.id);
|
||||
table.CheckConstraint("chk_news2_scores_risk_level", "risk_level IN ('LOW', 'LOW_MEDIUM', 'MEDIUM', 'HIGH')");
|
||||
table.ForeignKey(
|
||||
name: "FK_news2_scores_encounters_encounter_id",
|
||||
column: x => x.encounter_id,
|
||||
principalTable: "encounters",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_news2_scores_encounter_id_calculated_at",
|
||||
table: "news2_scores",
|
||||
columns: new[] { "encounter_id", "calculated_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_news2_scores_patient_id_calculated_at",
|
||||
table: "news2_scores",
|
||||
columns: new[] { "patient_id", "calculated_at" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "news2_scores");
|
||||
}
|
||||
}
|
||||
}
|
||||
+716
@@ -0,0 +1,716 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260618083259_AddNews2AlertTypes")]
|
||||
partial class AddNews2AlertTypes
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("AlertThreshold", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<decimal?>("CriticalHigh")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("critical_high");
|
||||
|
||||
b.Property<decimal?>("CriticalLow")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("critical_low");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<decimal?>("WarningHigh")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("warning_high");
|
||||
|
||||
b.Property<decimal?>("WarningLow")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("warning_low");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ObservationCode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("alert_thresholds", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClinicalAlert", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset?>("AcknowledgedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("acknowledged_at");
|
||||
|
||||
b.Property<string>("AcknowledgedBy")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("acknowledged_by");
|
||||
|
||||
b.Property<string>("AlertType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("alert_type");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("details");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<Guid?>("ObservationId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("observation_id");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("resolved_at");
|
||||
|
||||
b.Property<string>("Severity")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("severity");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'OPEN'");
|
||||
|
||||
b.Property<DateTimeOffset>("TriggeredAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("triggered_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EncounterId", "TriggeredAt");
|
||||
|
||||
b.HasIndex("PatientId", "TriggeredAt");
|
||||
|
||||
b.HasIndex("Severity", "TriggeredAt")
|
||||
.HasFilter("status = 'OPEN'");
|
||||
|
||||
b.ToTable("clinical_alerts", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL')");
|
||||
|
||||
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
|
||||
|
||||
t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Encounter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("AdmissionReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("admission_reason");
|
||||
|
||||
b.Property<DateTimeOffset>("AdmittedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("admitted_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("AttendingPhysician")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("attending_physician");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Department")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("department");
|
||||
|
||||
b.Property<string>("DischargeDiagnosis")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("discharge_diagnosis");
|
||||
|
||||
b.Property<DateTimeOffset?>("DischargedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("discharged_at");
|
||||
|
||||
b.Property<string>("EncounterType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("encounter_type");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<string>("RoomBed")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("room_bed");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'SCHEDULED'");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PatientId", "AdmittedAt");
|
||||
|
||||
b.HasIndex("Status", "AdmittedAt")
|
||||
.HasFilter("status = 'ACTIVE'");
|
||||
|
||||
b.ToTable("encounters", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')");
|
||||
|
||||
t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
|
||||
|
||||
t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("News2Score", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CalculatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("calculated_at");
|
||||
|
||||
b.Property<int>("ConsciousnessScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("consciousness_score");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<bool>("HasSingleParamThree")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_single_param_three");
|
||||
|
||||
b.Property<int>("HeartRateScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("heart_rate_score");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<int>("RespRateScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("resp_rate_score");
|
||||
|
||||
b.Property<string>("RiskLevel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("risk_level");
|
||||
|
||||
b.Property<int>("Spo2Score")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("spo2_score");
|
||||
|
||||
b.Property<int>("SupplementalO2Score")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("supplemental_o2_score");
|
||||
|
||||
b.Property<int>("SystolicBpScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("systolic_bp_score");
|
||||
|
||||
b.Property<int>("TemperatureScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("temperature_score");
|
||||
|
||||
b.Property<int>("TotalScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("total_score");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EncounterId", "CalculatedAt");
|
||||
|
||||
b.HasIndex("PatientId", "CalculatedAt");
|
||||
|
||||
b.ToTable("news2_scores", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_news2_scores_risk_level", "risk_level IN ('LOW', 'LOW_MEDIUM', 'MEDIUM', 'HIGH')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Observation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("idempotency_key");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
b.Property<DateTimeOffset>("RecordedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("recorded_at");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("source")
|
||||
.HasDefaultValueSql("'MANUAL'");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<decimal>("Value")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IdempotencyKey")
|
||||
.IsUnique()
|
||||
.HasFilter("idempotency_key IS NOT NULL");
|
||||
|
||||
b.HasIndex("EncounterId", "ObservationCode", "RecordedAt");
|
||||
|
||||
b.ToTable("observations", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Order", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("OrderType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("order_type");
|
||||
|
||||
b.Property<DateTimeOffset>("OrderedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("ordered_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("OrderedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("ordered_by");
|
||||
|
||||
b.Property<string>("ResultSummary")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("result_summary");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResultedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("resulted_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'PENDING'");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EncounterId", "OrderedAt");
|
||||
|
||||
b.HasIndex("Status", "OrderedAt")
|
||||
.HasFilter("status IN ('PENDING', 'IN_PROGRESS')");
|
||||
|
||||
b.ToTable("orders", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')");
|
||||
|
||||
t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OutboxEvent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("PartitionKey")
|
||||
.HasMaxLength(36)
|
||||
.HasColumnType("character varying(36)")
|
||||
.HasColumnName("partition_key");
|
||||
|
||||
b.Property<string>("Payload")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<DateTimeOffset?>("ProcessedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("processed_at");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("topic");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasFilter("processed_at IS NULL");
|
||||
|
||||
b.ToTable("outbox_events", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Patient", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("Allergies")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("allergies");
|
||||
|
||||
b.Property<string>("BloodType")
|
||||
.HasMaxLength(5)
|
||||
.HasColumnType("character varying(5)")
|
||||
.HasColumnName("blood_type");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<DateOnly>("DateOfBirth")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("date_of_birth");
|
||||
|
||||
b.Property<string>("EmergencyContactName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("emergency_contact_name");
|
||||
|
||||
b.Property<string>("EmergencyContactPhone")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("emergency_contact_phone");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("first_name");
|
||||
|
||||
b.Property<string>("Gender")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasColumnName("gender");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("last_name");
|
||||
|
||||
b.Property<string>("Mrn")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("mrn");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Mrn")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("patients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReconciliationAlert", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("CheckType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("check_type");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("details");
|
||||
|
||||
b.Property<Guid?>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<Guid?>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("resolved_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EncounterId");
|
||||
|
||||
b.HasIndex("PatientId");
|
||||
|
||||
b.HasIndex("CheckType", "EncounterId")
|
||||
.HasFilter("resolved_at IS NULL");
|
||||
|
||||
b.ToTable("reconciliation_alerts", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClinicalAlert", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany("Alerts")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Encounter", b =>
|
||||
{
|
||||
b.HasOne("Patient", "Patient")
|
||||
.WithMany("Encounters")
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("News2Score", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany()
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Observation", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany("Observations")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Order", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany("Orders")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReconciliationAlert", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany()
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Patient", "Patient")
|
||||
.WithMany()
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Encounter");
|
||||
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Encounter", b =>
|
||||
{
|
||||
b.Navigation("Alerts");
|
||||
|
||||
b.Navigation("Observations");
|
||||
|
||||
b.Navigation("Orders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Patient", b =>
|
||||
{
|
||||
b.Navigation("Encounters");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNews2AlertTypes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
ALTER TABLE clinical_alerts DROP CONSTRAINT chk_clinical_alerts_alert_type;
|
||||
ALTER TABLE clinical_alerts ADD CONSTRAINT chk_clinical_alerts_alert_type
|
||||
CHECK (alert_type IN (
|
||||
'SEPSIS_WARNING',
|
||||
'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L',
|
||||
'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL',
|
||||
'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L',
|
||||
'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL',
|
||||
'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L',
|
||||
'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL',
|
||||
'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L',
|
||||
'WARNING_GLUCOSE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY'
|
||||
));
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,6 +246,80 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("News2Score", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CalculatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("calculated_at");
|
||||
|
||||
b.Property<int>("ConsciousnessScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("consciousness_score");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<bool>("HasSingleParamThree")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_single_param_three");
|
||||
|
||||
b.Property<int>("HeartRateScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("heart_rate_score");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<int>("RespRateScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("resp_rate_score");
|
||||
|
||||
b.Property<string>("RiskLevel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("risk_level");
|
||||
|
||||
b.Property<int>("Spo2Score")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("spo2_score");
|
||||
|
||||
b.Property<int>("SupplementalO2Score")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("supplemental_o2_score");
|
||||
|
||||
b.Property<int>("SystolicBpScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("systolic_bp_score");
|
||||
|
||||
b.Property<int>("TemperatureScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("temperature_score");
|
||||
|
||||
b.Property<int>("TotalScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("total_score");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EncounterId", "CalculatedAt");
|
||||
|
||||
b.HasIndex("PatientId", "CalculatedAt");
|
||||
|
||||
b.ToTable("news2_scores", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_news2_scores_risk_level", "risk_level IN ('LOW', 'LOW_MEDIUM', 'MEDIUM', 'HIGH')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Observation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -570,6 +644,17 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("News2Score", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany()
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Observation", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
public record News2CachedParam(decimal Value, int Score, DateTimeOffset RecordedAt);
|
||||
@@ -0,0 +1,6 @@
|
||||
public record News2ObservationEvent(
|
||||
Guid ObservationId,
|
||||
Guid EncounterId,
|
||||
Guid PatientId,
|
||||
string ObservationCode,
|
||||
decimal Value);
|
||||
@@ -0,0 +1,13 @@
|
||||
public record News2Result(
|
||||
News2Outcome Outcome,
|
||||
int? TotalScore = null,
|
||||
string? RiskLevel = null,
|
||||
bool AlertCreated = false,
|
||||
int PresentParameters = 0,
|
||||
bool HasSingleParamThree = false)
|
||||
{
|
||||
public static readonly News2Result NotNews2Code = new(News2Outcome.NotNews2Code);
|
||||
|
||||
public static News2Result IncompleteParameters(int presentCount) =>
|
||||
new(News2Outcome.IncompleteParameters, PresentParameters: presentCount);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
public record News2ScoreCursor(DateTimeOffset CalculatedAt, Guid Id)
|
||||
{
|
||||
public string Encode()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(this);
|
||||
return Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
|
||||
}
|
||||
|
||||
public static News2ScoreCursor? Decode(string? encoded)
|
||||
{
|
||||
if (string.IsNullOrEmpty(encoded)) return null;
|
||||
try
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
|
||||
return JsonSerializer.Deserialize<News2ScoreCursor>(json);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using StackExchange.Redis;
|
||||
|
||||
public static class News2Calculator
|
||||
{
|
||||
// The 7 NEWS2 parameter codes. Order is stable for MGET.
|
||||
public static readonly IReadOnlyList<string> ParameterCodes = new[]
|
||||
{
|
||||
"RESP_RATE", "SPO2", "SYSTOLIC_BP", "HEART_RATE", "AVPU", "TEMP_C", "SUPPLEMENTAL_O2"
|
||||
};
|
||||
|
||||
public static RedisKey[] AllParameterKeys(Guid encounterId) =>
|
||||
ParameterCodes
|
||||
.Select(code => (RedisKey)$"news2:{encounterId}:{code}")
|
||||
.ToArray();
|
||||
|
||||
public static string ParameterKey(Guid encounterId, string code) =>
|
||||
$"news2:{encounterId}:{code}";
|
||||
|
||||
public static bool IsNews2Code(string observationCode) =>
|
||||
ParameterCodes.Contains(observationCode);
|
||||
|
||||
// --- Individual parameter scoring ---
|
||||
// Each method returns 0-3 per the official NEWS2 scoring table.
|
||||
|
||||
public static int ScoreRespRate(decimal value) => value switch
|
||||
{
|
||||
<= 8 => 3,
|
||||
<= 11 => 1,
|
||||
<= 20 => 0,
|
||||
<= 24 => 2,
|
||||
_ => 3 // >= 25
|
||||
};
|
||||
|
||||
// Scale 1 (standard). Scale 2 (hypercapnic respiratory failure) is not implemented.
|
||||
public static int ScoreSpo2(decimal value) => value switch
|
||||
{
|
||||
<= 91 => 3,
|
||||
<= 93 => 2,
|
||||
<= 95 => 1,
|
||||
_ => 0 // >= 96
|
||||
};
|
||||
|
||||
public static int ScoreSystolicBp(decimal value) => value switch
|
||||
{
|
||||
<= 90 => 3,
|
||||
<= 100 => 2,
|
||||
<= 110 => 1,
|
||||
<= 219 => 0,
|
||||
_ => 3 // >= 220
|
||||
};
|
||||
|
||||
public static int ScoreHeartRate(decimal value) => value switch
|
||||
{
|
||||
<= 40 => 3,
|
||||
<= 50 => 1,
|
||||
<= 90 => 0,
|
||||
<= 110 => 1,
|
||||
<= 130 => 2,
|
||||
_ => 3 // >= 131
|
||||
};
|
||||
|
||||
// AVPU: Alert=0, Voice/Pain/Unresponsive=3 (any non-Alert scores 3)
|
||||
public static int ScoreConsciousness(decimal value) => value switch
|
||||
{
|
||||
0 => 0, // Alert
|
||||
_ => 3 // Voice (1), Pain (2), Unresponsive (3)
|
||||
};
|
||||
|
||||
public static int ScoreTemperature(decimal value) => value switch
|
||||
{
|
||||
<= 35.0m => 3,
|
||||
<= 36.0m => 1,
|
||||
<= 38.0m => 0,
|
||||
<= 39.0m => 1,
|
||||
_ => 2 // >= 39.1
|
||||
};
|
||||
|
||||
// 0 = room air, 1 = on supplemental oxygen
|
||||
public static int ScoreSupplementalO2(decimal value) =>
|
||||
value >= 1 ? 2 : 0;
|
||||
|
||||
// Dispatch to the correct scoring function by observation code.
|
||||
public static int ScoreParameter(string observationCode, decimal value) =>
|
||||
observationCode switch
|
||||
{
|
||||
"RESP_RATE" => ScoreRespRate(value),
|
||||
"SPO2" => ScoreSpo2(value),
|
||||
"SYSTOLIC_BP" => ScoreSystolicBp(value),
|
||||
"HEART_RATE" => ScoreHeartRate(value),
|
||||
"AVPU" => ScoreConsciousness(value),
|
||||
"TEMP_C" => ScoreTemperature(value),
|
||||
"SUPPLEMENTAL_O2" => ScoreSupplementalO2(value),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(observationCode))
|
||||
};
|
||||
|
||||
// Determine risk level from total score and single-param-3 flag.
|
||||
public static string DetermineRiskLevel(int totalScore, bool hasSingleParamThree) =>
|
||||
totalScore switch
|
||||
{
|
||||
>= 7 => "HIGH",
|
||||
>= 5 => "MEDIUM",
|
||||
_ when hasSingleParamThree => "LOW_MEDIUM",
|
||||
_ => "LOW"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prometheus;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class News2Detector
|
||||
{
|
||||
private const int News2TtlSeconds = 14400; // 4 hours
|
||||
|
||||
private static readonly JsonSerializerOptions CachedParamJsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ClinicalMetrics _metrics;
|
||||
private readonly ILogger<News2Detector> _logger;
|
||||
|
||||
public News2Detector(
|
||||
IConnectionMultiplexer redis,
|
||||
IServiceProvider services,
|
||||
ClinicalMetrics metrics,
|
||||
ILogger<News2Detector> logger)
|
||||
{
|
||||
_redis = redis;
|
||||
_services = services;
|
||||
_metrics = metrics;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<News2Result> ProcessObservationAsync(
|
||||
Guid encounterId,
|
||||
Guid patientId,
|
||||
string observationCode,
|
||||
decimal value,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!News2Calculator.IsNews2Code(observationCode))
|
||||
return News2Result.NotNews2Code;
|
||||
|
||||
using var timer = _metrics.News2ScoringDuration.NewTimer();
|
||||
|
||||
var cache = _redis.GetDatabase();
|
||||
|
||||
// Compute the individual score and store in Redis
|
||||
var individualScore = News2Calculator.ScoreParameter(observationCode, value);
|
||||
var paramData = JsonSerializer.Serialize(new
|
||||
{
|
||||
value,
|
||||
score = individualScore,
|
||||
recordedAt = DateTimeOffset.UtcNow
|
||||
}, CachedParamJsonOptions);
|
||||
await cache.StringSetAsync(
|
||||
News2Calculator.ParameterKey(encounterId, observationCode),
|
||||
paramData,
|
||||
TimeSpan.FromSeconds(News2TtlSeconds));
|
||||
|
||||
// Fetch all 7 parameter keys in one MGET round-trip
|
||||
var allKeys = News2Calculator.AllParameterKeys(encounterId);
|
||||
var allValues = await cache.StringGetAsync(allKeys);
|
||||
|
||||
// Check completeness — all 7 must be present
|
||||
var scores = new int?[7];
|
||||
for (int i = 0; i < 7; i++)
|
||||
{
|
||||
if (!allValues[i].HasValue)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"NEWS2 incomplete for encounter {Id}: {Code} missing ({Present}/7 present)",
|
||||
encounterId, News2Calculator.ParameterCodes[i],
|
||||
allValues.Count(v => v.HasValue));
|
||||
return News2Result.IncompleteParameters(allValues.Count(v => v.HasValue));
|
||||
}
|
||||
|
||||
var cached = JsonSerializer.Deserialize<News2CachedParam>(allValues[i]!, CachedParamJsonOptions);
|
||||
scores[i] = cached?.Score;
|
||||
}
|
||||
|
||||
// All 7 present — compute aggregate
|
||||
var paramScores = scores.Select(s => s!.Value).ToArray();
|
||||
var totalScore = paramScores.Sum();
|
||||
var hasSingleParamThree = paramScores.Any(s => s == 3);
|
||||
var riskLevel = News2Calculator.DetermineRiskLevel(totalScore, hasSingleParamThree);
|
||||
|
||||
// Persist the score to PostgreSQL
|
||||
var scoreId = await PersistScoreAsync(
|
||||
encounterId, patientId, totalScore, riskLevel,
|
||||
paramScores, hasSingleParamThree, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"NEWS2 score {Score} ({Risk}) for encounter {Id} — components: {Components}",
|
||||
totalScore, riskLevel, encounterId,
|
||||
string.Join(",", News2Calculator.ParameterCodes.Zip(paramScores, (c, s) => $"{c}={s}")));
|
||||
|
||||
// Create alert if warranted
|
||||
var alertCreated = false;
|
||||
if (riskLevel == "HIGH")
|
||||
{
|
||||
alertCreated = await TryCreateAlertAsync(
|
||||
encounterId, patientId, AlertType.News2Emergency, AlertSeverity.Critical,
|
||||
totalScore, riskLevel, paramScores, ct);
|
||||
}
|
||||
else if (riskLevel == "MEDIUM" || riskLevel == "LOW_MEDIUM")
|
||||
{
|
||||
alertCreated = await TryCreateAlertAsync(
|
||||
encounterId, patientId, AlertType.News2Warning, AlertSeverity.Warning,
|
||||
totalScore, riskLevel, paramScores, ct);
|
||||
}
|
||||
|
||||
return new News2Result(
|
||||
News2Outcome.ScoreComputed, totalScore, riskLevel, alertCreated, 7, hasSingleParamThree);
|
||||
}
|
||||
|
||||
private async Task<Guid> PersistScoreAsync(
|
||||
Guid encounterId, Guid patientId,
|
||||
int totalScore, string riskLevel,
|
||||
int[] paramScores, bool hasSingleParamThree,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var score = new News2Score
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EncounterId = encounterId,
|
||||
PatientId = patientId,
|
||||
TotalScore = totalScore,
|
||||
RiskLevel = riskLevel,
|
||||
RespRateScore = paramScores[0],
|
||||
Spo2Score = paramScores[1],
|
||||
SystolicBpScore = paramScores[2],
|
||||
HeartRateScore = paramScores[3],
|
||||
ConsciousnessScore = paramScores[4],
|
||||
TemperatureScore = paramScores[5],
|
||||
SupplementalO2Score = paramScores[6],
|
||||
HasSingleParamThree = hasSingleParamThree,
|
||||
CalculatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
db.News2Scores.Add(score);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
_metrics.News2ScoresTotal.WithLabels(riskLevel).Inc();
|
||||
|
||||
return score.Id;
|
||||
}
|
||||
|
||||
private async Task<bool> TryCreateAlertAsync(
|
||||
Guid encounterId, Guid patientId,
|
||||
AlertType alertType, AlertSeverity severity,
|
||||
int totalScore, string riskLevel, int[] paramScores,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync(ct);
|
||||
|
||||
var alertId = Guid.NewGuid();
|
||||
var triggeredAt = DateTimeOffset.UtcNow;
|
||||
var details = BuildDetails(totalScore, riskLevel, paramScores);
|
||||
|
||||
var affected = await db.Database.ExecuteSqlInterpolatedAsync($"""
|
||||
INSERT INTO clinical_alerts
|
||||
(id, encounter_id, patient_id, alert_type, severity, details, status, triggered_at)
|
||||
SELECT {alertId}, {encounterId}, {patientId},
|
||||
{alertType.ToDbString()}, {severity.ToDbString()}, {details}, 'OPEN', {triggeredAt}
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM clinical_alerts
|
||||
WHERE encounter_id = {encounterId}
|
||||
AND alert_type = {alertType.ToDbString()}
|
||||
AND status IN ('OPEN', 'ESCALATED')
|
||||
)
|
||||
""", ct);
|
||||
|
||||
if (affected == 0)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
db.OutboxEvents.Add(new OutboxEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Topic = "alert.generated",
|
||||
Payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
alertId,
|
||||
encounterId,
|
||||
patientId,
|
||||
alertType = alertType.ToDbString(),
|
||||
severity = severity.ToDbString(),
|
||||
triggeredAt,
|
||||
news2Score = totalScore,
|
||||
news2RiskLevel = riskLevel,
|
||||
partitionKey = encounterId.ToString()
|
||||
}),
|
||||
PartitionKey = encounterId.ToString(),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
|
||||
_metrics.ClinicalAlertsTotal
|
||||
.WithLabels(alertType.ToDbString(), severity.ToDbString()).Inc();
|
||||
|
||||
_logger.LogWarning(
|
||||
"NEWS2 alert {AlertType} created for encounter {EncounterId} — score={Score} risk={Risk}",
|
||||
alertType.ToDbString(), encounterId, totalScore, riskLevel);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string BuildDetails(int totalScore, string riskLevel, int[] paramScores)
|
||||
{
|
||||
var components = News2Calculator.ParameterCodes
|
||||
.Zip(paramScores, (code, score) => $"{code}={score}")
|
||||
.ToArray();
|
||||
return $"NEWS2 score {totalScore} ({riskLevel}): {string.Join(", ", components)}.";
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,11 @@ public sealed class ClinicalMetrics
|
||||
"sirs_detections_total",
|
||||
"Total SEPSIS_WARNING alerts generated by the sepsis detection engine.");
|
||||
|
||||
public readonly Counter News2ScoresTotal = Metrics.CreateCounter(
|
||||
"news2_scores_total",
|
||||
"Total NEWS2 scores computed, labeled by risk level.",
|
||||
labelNames: new[] { "risk_level" });
|
||||
|
||||
// Incremented by EscalationWorkerService when it processes a message from
|
||||
// alerts.escalation.queue. A rising escalations_total is the strongest operational
|
||||
// signal that critical alerts are not being acknowledged by the attending physician.
|
||||
@@ -44,6 +49,14 @@ public sealed class ClinicalMetrics
|
||||
Buckets = new[] { 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0 }
|
||||
});
|
||||
|
||||
public readonly Histogram News2ScoringDuration = Metrics.CreateHistogram(
|
||||
"news2_scoring_duration_seconds",
|
||||
"Time to compute a NEWS2 score from Redis state.",
|
||||
new HistogramConfiguration
|
||||
{
|
||||
Buckets = new[] { 0.001, 0.005, 0.01, 0.025, 0.05, 0.1 }
|
||||
});
|
||||
|
||||
// --- Gauges (set by background collectors, not incremented inline) ---
|
||||
|
||||
// The most clinically significant panel. A non-zero value means a patient's
|
||||
|
||||
@@ -73,12 +73,14 @@ try
|
||||
builder.Services.AddScoped<IAlertService, AlertService>();
|
||||
builder.Services.AddScoped<IOrderService, OrderService>();
|
||||
builder.Services.AddScoped<IAnalyticsService, AnalyticsService>();
|
||||
builder.Services.AddScoped<INews2Service, News2Service>();
|
||||
builder.Services.AddScoped<SirsDetector>();
|
||||
builder.Services.AddScoped<UnacknowledgedAlertsCheck>();
|
||||
builder.Services.AddScoped<PendingOrdersCheck>();
|
||||
builder.Services.AddScoped<DisconnectedMonitorsCheck>();
|
||||
builder.Services.AddScoped<ReconciliationPublisher>();
|
||||
builder.Services.AddScoped<WarningEvaluator>();
|
||||
builder.Services.AddScoped<News2Detector>();
|
||||
|
||||
builder.Services.AddHostedService<ThresholdCacheLoader>();
|
||||
builder.Services.AddHostedService<KafkaTopicProvisioner>();
|
||||
@@ -97,6 +99,7 @@ try
|
||||
builder.Services.AddHostedService<KafkaConsumerLagCollector>();
|
||||
builder.Services.AddHostedService<DataLakeWriterService>();
|
||||
builder.Services.AddHostedService<WarningAlertService>();
|
||||
builder.Services.AddHostedService<News2ScoringService>();
|
||||
|
||||
|
||||
builder.Services.AddControllers()
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
public interface INews2Service
|
||||
{
|
||||
Task<News2Score?> GetCurrentAsync(Guid encounterId);
|
||||
Task<CursorPage<News2Score>> GetHistoryAsync(
|
||||
Guid encounterId, int limit, string? cursorToken);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class News2Service : INews2Service
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public News2Service(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<News2Score?> GetCurrentAsync(Guid encounterId)
|
||||
{
|
||||
return await _db.News2Scores
|
||||
.AsNoTracking()
|
||||
.Where(s => s.EncounterId == encounterId)
|
||||
.OrderByDescending(s => s.CalculatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<CursorPage<News2Score>> GetHistoryAsync(
|
||||
Guid encounterId, int limit, string? cursorToken)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
var cursor = News2ScoreCursor.Decode(cursorToken);
|
||||
|
||||
var query = _db.News2Scores
|
||||
.AsNoTracking()
|
||||
.Where(s => s.EncounterId == encounterId);
|
||||
|
||||
if (cursor is not null)
|
||||
{
|
||||
query = query.Where(s =>
|
||||
s.CalculatedAt < cursor.CalculatedAt ||
|
||||
(s.CalculatedAt == cursor.CalculatedAt && s.Id.CompareTo(cursor.Id) < 0));
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(s => s.CalculatedAt)
|
||||
.ThenByDescending(s => s.Id)
|
||||
.Take(limit + 1)
|
||||
.ToListAsync();
|
||||
|
||||
var hasMore = items.Count > limit;
|
||||
if (hasMore) items.RemoveAt(limit);
|
||||
|
||||
var nextCursor = hasMore
|
||||
? new News2ScoreCursor(items[^1].CalculatedAt, items[^1].Id).Encode()
|
||||
: null;
|
||||
|
||||
return new CursorPage<News2Score>(items, nextCursor, hasMore);
|
||||
}
|
||||
}
|
||||
@@ -67,13 +67,34 @@ fires approximately once per minute. At 50 obs/sec peak it fires every 20 second
|
||||
monitors active), the count threshold might not fire for hours. A 5-minute ceiling
|
||||
means the most recent data is always queryable within 5 minutes of arriving in Kafka.
|
||||
|
||||
**At-least-once delivery:** Kafka offsets are committed only after the Parquet file
|
||||
is successfully uploaded. A process crash between buffering and uploading produces
|
||||
duplicate rows on the next startup — the same observation appears in two files with
|
||||
**At-least-once delivery:** Kafka offsets are committed only after at least one Parquet file
|
||||
is successfully uploaded in a flush cycle. A process crash between buffering and uploading
|
||||
produces duplicate rows on the next startup — the same observation appears in two files with
|
||||
different `kafka_offset` values. For a regulatory archive this is acceptable.
|
||||
Downstream queries can deduplicate on `observation_id`. Data loss is not acceptable;
|
||||
duplicates are.
|
||||
|
||||
**Graceful shutdown:** On host stop (Ctrl+C), the writer performs a final flush with an
|
||||
uncanceled token so in-flight MinIO uploads complete. If every file in a flush fails,
|
||||
offsets are not committed and events are re-read on the next start.
|
||||
|
||||
## `alert.generated` Parquet mapping
|
||||
|
||||
`DataLakeWriterService.ParseAlertRow` reads these JSON fields from Kafka:
|
||||
|
||||
| Parquet column | JSON field | Notes |
|
||||
|---|---|---|
|
||||
| `alert_id` | `alertId` | Required |
|
||||
| `encounter_id` | `encounterId` | Required |
|
||||
| `patient_id` | `patientId` | Required |
|
||||
| `alert_type` | `alertType` | e.g. `WARNING_HEART_RATE`, `CRITICAL_HEART_RATE` |
|
||||
| `severity` | `severity` | e.g. `Warning`, `Critical` |
|
||||
| `details` | `details` | Required on all new alert producers; parser defaults to `""` if absent |
|
||||
| `triggered_at` | `triggeredAt` | ISO-8601 |
|
||||
|
||||
Phase 11 aligned warning and sepsis outbox producers with critical ingest alerts by
|
||||
always including `details`.
|
||||
|
||||
## Date partition structure
|
||||
|
||||
Files are partitioned by the **event timestamp** from the payload, not by wall clock
|
||||
|
||||
Executable
+339
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-${ROOT_DIR}/docker-compose.yml}"
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost:5270}"
|
||||
ES_URL="${ES_URL:-http://localhost:9200}"
|
||||
REDIS_PORT="${REDIS_PORT:-6382}"
|
||||
|
||||
PGHOST="${PGHOST:-localhost}"
|
||||
PGPORT="${PGPORT:-5436}"
|
||||
PGDATABASE="${PGDATABASE:-vigilcare}"
|
||||
PGUSER="${PGUSER:-postgres}"
|
||||
PGPASSWORD="${PGPASSWORD:-password}"
|
||||
|
||||
TEST_PROJECT="${TEST_PROJECT:-${ROOT_DIR}/VigilCareClinicalAPI.Tests/VigilCareClinicalAPI.Tests.csproj}"
|
||||
TEST_FILTER="${TEST_FILTER:-FullyQualifiedName~News2}"
|
||||
FULL_TEST="${FULL_TEST:-0}"
|
||||
|
||||
NEWS2_CONSUMER_GROUP="${NEWS2_CONSUMER_GROUP:-news2-scoring}"
|
||||
ES_CONSUMER_GROUP="${ES_CONSUMER_GROUP:-es-indexer}"
|
||||
OUTBOX_RELAY_GROUP="${OUTBOX_RELAY_GROUP:-}"
|
||||
NEWS2_WAIT_SECS="${NEWS2_WAIT_SECS:-60}"
|
||||
INDEX_WAIT_SECS="${INDEX_WAIT_SECS:-60}"
|
||||
|
||||
SCRIPT_RUN_ID="$(date -u +"%Y%m%d%H%M%S")"
|
||||
RECORDED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
|
||||
TMP_FILES=()
|
||||
cleanup() {
|
||||
local f
|
||||
for f in "${TMP_FILES[@]}"; do
|
||||
rm -f "${f}" "${f}.status" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
need() {
|
||||
command -v "$1" >/dev/null 2>&1 || {
|
||||
echo "Missing dependency: $1"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
need curl
|
||||
need jq
|
||||
need dotnet
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1 || [[ ! -f "${COMPOSE_FILE}" ]]; then
|
||||
echo "Missing dependency: docker compose (${COMPOSE_FILE})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
compose() {
|
||||
docker compose -f "${COMPOSE_FILE}" "$@"
|
||||
}
|
||||
|
||||
kafka_exec() {
|
||||
compose exec -T kafka "$@"
|
||||
}
|
||||
|
||||
redis_cmd() {
|
||||
if command -v redis-cli >/dev/null 2>&1; then
|
||||
redis-cli -p "${REDIS_PORT}" "$@"
|
||||
else
|
||||
compose exec -T redis redis-cli "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
psql_cmd() {
|
||||
local sql="$1"
|
||||
if command -v psql >/dev/null 2>&1; then
|
||||
PGPASSWORD="${PGPASSWORD}" psql -h "${PGHOST}" -p "${PGPORT}" -U "${PGUSER}" -d "${PGDATABASE}" -tAc "${sql}"
|
||||
else
|
||||
compose exec -T postgres psql -U "${PGUSER}" -d "${PGDATABASE}" -tAc "${sql}"
|
||||
fi
|
||||
}
|
||||
|
||||
request() {
|
||||
local method="$1"
|
||||
local url="$2"
|
||||
local body="${3:-}"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
TMP_FILES+=("${tmp}")
|
||||
local status
|
||||
|
||||
if [[ -n "${body}" ]]; then
|
||||
status="$(curl -sS -o "${tmp}" -w "%{http_code}" -X "${method}" "${url}" \
|
||||
-H "Content-Type: application/json" -d "${body}")"
|
||||
else
|
||||
status="$(curl -sS -o "${tmp}" -w "%{http_code}" -X "${method}" "${url}")"
|
||||
fi
|
||||
|
||||
echo "${status}" > "${tmp}.status"
|
||||
echo "${tmp}"
|
||||
}
|
||||
|
||||
assert_status() {
|
||||
local expected="$1"
|
||||
local body_file="$2"
|
||||
local status
|
||||
status="$(<"${body_file}.status")"
|
||||
if [[ "${status}" != "${expected}" ]]; then
|
||||
echo "Expected HTTP ${expected}, got ${status}"
|
||||
cat "${body_file}"
|
||||
echo
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
consumer_group_lag() {
|
||||
local group="$1"
|
||||
kafka_exec /opt/kafka/bin/kafka-consumer-groups.sh \
|
||||
--bootstrap-server localhost:9092 \
|
||||
--describe \
|
||||
--group "${group}" 2>/dev/null | \
|
||||
awk 'NR > 1 && $1 != "" { sum += $6 } END { print sum + 0 }'
|
||||
}
|
||||
|
||||
wait_for_consumer_lag_zero() {
|
||||
local group="$1"
|
||||
local max_secs="$2"
|
||||
local elapsed=0
|
||||
local lag="unknown"
|
||||
while (( elapsed < max_secs )); do
|
||||
lag="$(consumer_group_lag "${group}")"
|
||||
if [[ "${lag}" == "0" ]]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
echo "Consumer group ${group} lag did not reach zero within ${max_secs}s (lag=${lag})"
|
||||
exit 1
|
||||
}
|
||||
|
||||
wait_for_news2_score() {
|
||||
local encounter_id="$1"
|
||||
local max_secs="$2"
|
||||
local elapsed=0
|
||||
local row=""
|
||||
while (( elapsed < max_secs )); do
|
||||
row="$(psql_cmd "SELECT total_score, risk_level FROM news2_scores WHERE encounter_id = '${encounter_id}' ORDER BY calculated_at DESC LIMIT 1")"
|
||||
if [[ -n "${row}" ]]; then
|
||||
echo "${row}"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
echo "No news2_scores row for encounter ${encounter_id} within ${max_secs}s"
|
||||
exit 1
|
||||
}
|
||||
|
||||
wait_for_es_news2_fields() {
|
||||
local encounter_id="$1"
|
||||
local max_secs="$2"
|
||||
local elapsed=0
|
||||
local score=""
|
||||
local risk=""
|
||||
while (( elapsed < max_secs )); do
|
||||
local body
|
||||
body="$(curl -sS "${ES_URL}/patient_encounters/_source/${encounter_id}" 2>/dev/null || true)"
|
||||
if [[ -n "${body}" && "${body}" != *"\"found\":false"* ]]; then
|
||||
score="$(jq -r '.news2Score // empty' <<< "${body}")"
|
||||
risk="$(jq -r '.news2RiskLevel // empty' <<< "${body}")"
|
||||
if [[ -n "${score}" && -n "${risk}" ]]; then
|
||||
echo "${score}"$'\t'"${risk}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
echo "patient_encounters document missing news2Score/news2RiskLevel for ${encounter_id} within ${max_secs}s"
|
||||
exit 1
|
||||
}
|
||||
|
||||
TOTAL_STEPS=8
|
||||
echo "Phase 12 verification starting..."
|
||||
echo "Repo root: ${ROOT_DIR}"
|
||||
echo "API: ${BASE_URL}"
|
||||
|
||||
echo "[1/${TOTAL_STEPS}] Preflight API, Elasticsearch, PostgreSQL, Redis, Kafka"
|
||||
api_status="$(curl -sS -o /dev/null -w "%{http_code}" "${BASE_URL}/api/v1/alert-thresholds" || true)"
|
||||
es_status="$(curl -sS -o /dev/null -w "%{http_code}" "${ES_URL}/_cluster/health" || true)"
|
||||
[[ "${api_status}" == "200" ]] || { echo "API not ready (${api_status}) — run docker compose up -d and dotnet run"; exit 1; }
|
||||
[[ "${es_status}" == "200" ]] || { echo "Elasticsearch not ready (${es_status})"; exit 1; }
|
||||
redis_cmd PING >/dev/null || { echo "Redis not reachable on port ${REDIS_PORT}"; exit 1; }
|
||||
psql_cmd "SELECT 1" >/dev/null || { echo "PostgreSQL not reachable"; exit 1; }
|
||||
kafka_exec /opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 >/dev/null || {
|
||||
echo "Kafka not ready"
|
||||
exit 1
|
||||
}
|
||||
redis_cmd EXISTS "threshold:HEART_RATE" | grep -q '^1$' || {
|
||||
echo "Missing Redis threshold:HEART_RATE — restart API to run ThresholdCacheLoader"
|
||||
exit 1
|
||||
}
|
||||
echo "OK: infrastructure preflight passed"
|
||||
|
||||
echo "[2/${TOTAL_STEPS}] Create patient and active encounter"
|
||||
patient_payload="$(jq -nc \
|
||||
--arg fn "Phase12" \
|
||||
--arg ln "Verify${SCRIPT_RUN_ID}" \
|
||||
'{firstName:$fn,lastName:$ln,dateOfBirth:"1985-06-01",gender:"M"}')"
|
||||
patient_resp="$(request POST "${BASE_URL}/api/v1/patients" "${patient_payload}")"
|
||||
assert_status "201" "${patient_resp}"
|
||||
patient_id="$(jq -r '.data.id' "${patient_resp}")"
|
||||
|
||||
enc_payload='{"encounterType":"Inpatient","department":"ICU","attendingPhysician":"Dr. Phase12"}'
|
||||
enc_resp="$(request POST "${BASE_URL}/api/v1/patients/${patient_id}/encounters" "${enc_payload}")"
|
||||
assert_status "201" "${enc_resp}"
|
||||
encounter_id="$(jq -r '.data.id' "${enc_resp}")"
|
||||
echo "OK: patient=${patient_id} encounter=${encounter_id}"
|
||||
|
||||
echo "[3/${TOTAL_STEPS}] Ingest all 7 NEWS2 parameters (medium risk, total score 6)"
|
||||
news2_payload="$(jq -nc \
|
||||
--arg recordedAt "${RECORDED_AT}" \
|
||||
'{observations:[
|
||||
{"observationCode":"RESP_RATE","value":22,"unit":"breaths/min","source":"DEVICE","recordedAt":$recordedAt},
|
||||
{"observationCode":"SPO2","value":93,"unit":"%","source":"DEVICE","recordedAt":$recordedAt},
|
||||
{"observationCode":"SYSTOLIC_BP","value":105,"unit":"mmHg","source":"DEVICE","recordedAt":$recordedAt},
|
||||
{"observationCode":"HEART_RATE","value":95,"unit":"bpm","source":"DEVICE","recordedAt":$recordedAt},
|
||||
{"observationCode":"AVPU","value":0,"unit":"score","source":"MANUAL","recordedAt":$recordedAt},
|
||||
{"observationCode":"TEMP_C","value":37.0,"unit":"°C","source":"DEVICE","recordedAt":$recordedAt},
|
||||
{"observationCode":"SUPPLEMENTAL_O2","value":0,"unit":"flag","source":"MANUAL","recordedAt":$recordedAt}
|
||||
]}')"
|
||||
obs_resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${news2_payload}")"
|
||||
assert_status "201" "${obs_resp}"
|
||||
echo "OK: 7 observations ingested"
|
||||
|
||||
echo "[4/${TOTAL_STEPS}] Wait for news2-scoring and verify PostgreSQL score + NEWS2_WARNING alert"
|
||||
wait_for_consumer_lag_zero "${NEWS2_CONSUMER_GROUP}" "${NEWS2_WAIT_SECS}"
|
||||
|
||||
score_row="$(wait_for_news2_score "${encounter_id}" "${NEWS2_WAIT_SECS}")"
|
||||
total_score="${score_row%%|*}"
|
||||
risk_level="$(echo "${score_row}" | cut -d'|' -f2)"
|
||||
[[ "${total_score}" == "6" ]] || {
|
||||
echo "Expected total_score=6 in PostgreSQL, got ${total_score}"
|
||||
exit 1
|
||||
}
|
||||
[[ "${risk_level}" == "MEDIUM" ]] || {
|
||||
echo "Expected risk_level=MEDIUM in PostgreSQL, got ${risk_level}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
warning_count="$(psql_cmd "SELECT COUNT(*) FROM clinical_alerts WHERE encounter_id = '${encounter_id}' AND alert_type = 'NEWS2_WARNING' AND severity = 'WARNING' AND status = 'OPEN'")"
|
||||
[[ "${warning_count}" == "1" ]] || {
|
||||
echo "Expected 1 NEWS2_WARNING alert in PostgreSQL, found ${warning_count}"
|
||||
psql_cmd "SELECT alert_type, severity, status FROM clinical_alerts WHERE encounter_id = '${encounter_id}'" || true
|
||||
exit 1
|
||||
}
|
||||
echo "OK: PostgreSQL news2_scores total=6 risk=MEDIUM and NEWS2_WARNING alert exists"
|
||||
|
||||
echo "[5/${TOTAL_STEPS}] Verify NEWS2 API endpoints (current + history)"
|
||||
current_resp="$(request GET "${BASE_URL}/api/v1/encounters/${encounter_id}/news2/current")"
|
||||
assert_status "200" "${current_resp}"
|
||||
[[ "$(jq -r '.data.totalScore' "${current_resp}")" == "6" ]] || {
|
||||
echo "Expected current totalScore=6"
|
||||
cat "${current_resp}"
|
||||
exit 1
|
||||
}
|
||||
[[ "$(jq -r '.data.riskLevel' "${current_resp}")" == "MEDIUM" ]] || {
|
||||
echo "Expected current riskLevel=MEDIUM"
|
||||
cat "${current_resp}"
|
||||
exit 1
|
||||
}
|
||||
[[ "$(jq -r '.data.respRateScore' "${current_resp}")" == "2" ]] || {
|
||||
echo "Expected respRateScore=2"
|
||||
cat "${current_resp}"
|
||||
exit 1
|
||||
}
|
||||
[[ "$(jq -r '.data.spo2Score' "${current_resp}")" == "2" ]] || {
|
||||
echo "Expected spo2Score=2"
|
||||
cat "${current_resp}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
history_resp="$(request GET "${BASE_URL}/api/v1/encounters/${encounter_id}/news2/history")"
|
||||
assert_status "200" "${history_resp}"
|
||||
history_count="$(jq -r '.data.items | length' "${history_resp}")"
|
||||
[[ "${history_count}" -ge 1 ]] || {
|
||||
echo "Expected at least one history entry"
|
||||
cat "${history_resp}"
|
||||
exit 1
|
||||
}
|
||||
echo "OK: news2/current and news2/history return expected data"
|
||||
|
||||
echo "[6/${TOTAL_STEPS}] Wait for es-indexer and verify Elasticsearch projection"
|
||||
wait_for_consumer_lag_zero "${ES_CONSUMER_GROUP}" "${INDEX_WAIT_SECS}"
|
||||
es_row="$(wait_for_es_news2_fields "${encounter_id}" "${INDEX_WAIT_SECS}")"
|
||||
es_score="${es_row%%$'\t'*}"
|
||||
es_risk="${es_row#*$'\t'}"
|
||||
[[ "${es_score}" == "6" ]] || {
|
||||
echo "Expected Elasticsearch news2Score=6, got ${es_score}"
|
||||
exit 1
|
||||
}
|
||||
[[ "${es_risk}" == "MEDIUM" ]] || {
|
||||
echo "Expected Elasticsearch news2RiskLevel=MEDIUM, got ${es_risk}"
|
||||
exit 1
|
||||
}
|
||||
echo "OK: patient_encounters has news2Score=6 and news2RiskLevel=MEDIUM"
|
||||
|
||||
echo "[7/${TOTAL_STEPS}] Verify Prometheus NEWS2 metrics"
|
||||
metrics_file="$(mktemp)"
|
||||
TMP_FILES+=("${metrics_file}")
|
||||
curl -sS "${BASE_URL}/metrics" -o "${metrics_file}"
|
||||
|
||||
grep -q '^news2_scores_total{' "${metrics_file}" || {
|
||||
echo "Expected news2_scores_total counter line in /metrics"
|
||||
grep 'news2' "${metrics_file}" || true
|
||||
exit 1
|
||||
}
|
||||
grep -q '^news2_scoring_duration_seconds_bucket{' "${metrics_file}" || {
|
||||
echo "Expected news2_scoring_duration_seconds_bucket in /metrics"
|
||||
exit 1
|
||||
}
|
||||
grep -q 'news2_scores_total{risk_level="MEDIUM"}' "${metrics_file}" || {
|
||||
echo "Expected news2_scores_total{risk_level=\"MEDIUM\"} in /metrics"
|
||||
grep 'news2_scores_total' "${metrics_file}" || true
|
||||
exit 1
|
||||
}
|
||||
echo "OK: NEWS2 Prometheus metrics exposed"
|
||||
|
||||
echo "[8/${TOTAL_STEPS}] Run NEWS2 test suite"
|
||||
dotnet test "${TEST_PROJECT}" --filter "${TEST_FILTER}"
|
||||
|
||||
if [[ "${FULL_TEST}" == "1" ]]; then
|
||||
echo "Running full test suite (FULL_TEST=1)"
|
||||
dotnet test "${ROOT_DIR}/VigilCareClinicalAPI.sln" 2>/dev/null || dotnet test "${ROOT_DIR}"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Phase 12 verification checks passed."
|
||||
echo "Encounter id: ${encounter_id}"
|
||||
Reference in New Issue
Block a user