feature: Warning Alert Consumer, Orders API & Input Validation
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A production-quality clinical backend built with ASP.NET Core 8, PostgreSQL, Apache Kafka, RabbitMQ, Elasticsearch, Redis, and MinIO. The domain models the observe-alert-acknowledge lifecycle at the center of any clinical monitoring system: patient encounters, continuous vital sign and lab result ingest, real-time sepsis detection, and clinician notification with automatic escalation.
|
A production-quality clinical backend built with ASP.NET Core 8, PostgreSQL, Apache Kafka, RabbitMQ, Elasticsearch, Redis, and MinIO. The domain models the observe-alert-acknowledge lifecycle at the center of any clinical monitoring system: patient encounters, continuous vital sign and lab result ingest, real-time sepsis detection, and clinician notification with automatic escalation.
|
||||||
|
|
||||||
**Implementation status:** All nine planned phases are complete — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, and the MinIO Parquet data lake. See [Implemented Phases](#implemented-phases) for the full breakdown.
|
**Implementation status:** All ten planned phases are complete — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, and clinical data model expansion (patient demographics, encounter enrichment, 12 observation codes). See [Implemented Phases](#implemented-phases) for the full breakdown.
|
||||||
|
|
||||||
## Domain Model — How It Maps to a Real Clinical System
|
## Domain Model — How It Maps to a Real Clinical System
|
||||||
|
|
||||||
@@ -19,11 +19,11 @@ Patient ────────────────────────
|
|||||||
|
|
||||||
### Patient
|
### Patient
|
||||||
|
|
||||||
A `Patient` is registered with demographic information and assigned a Medical Record Number (MRN) — a stable identifier that never changes across encounters. Patient search supports both MRN exact match and name partial match (`ILIKE`).
|
A `Patient` is registered with demographic information and assigned a Medical Record Number (MRN) — a stable identifier that never changes across encounters. Optional clinical fields include blood type (`A+`, `O-`, etc.), known allergies, and emergency contact name/phone. Patient search supports both MRN exact match and name partial match (`ILIKE`).
|
||||||
|
|
||||||
### Encounter
|
### Encounter
|
||||||
|
|
||||||
An `Encounter` is a single clinical episode. Status follows a controlled machine: `scheduled → active → discharged` (or `cancelled` from any pre-discharged state). Observations, alerts, and orders belong to an encounter, not directly to a patient — this bounds queries naturally and mirrors real clinical data ownership. Discharge triggers a RabbitMQ job to generate a discharge summary.
|
An `Encounter` is a single clinical episode. Status follows a controlled machine: `scheduled → active → discharged` (or `cancelled` from any pre-discharged state). Optional `roomBed` and `admissionReason` fields support ward assignment and clinical context; `dischargeDiagnosis` is set on discharge. Observations, alerts, and orders belong to an encounter, not directly to a patient — this bounds queries naturally and mirrors real clinical data ownership. Discharge triggers a RabbitMQ job to generate a discharge summary.
|
||||||
|
|
||||||
### AlertThreshold
|
### AlertThreshold
|
||||||
|
|
||||||
@@ -47,9 +47,9 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Patient Registration** — register patients with MRN generation; paginated list with name (`ILIKE`) and MRN (exact) search; patient detail with active encounter summary
|
- **Patient Registration** — register patients with MRN generation; optional blood type, allergies, and emergency contact; paginated list with name (`ILIKE`) and MRN (exact) search; patient detail with active encounter summary
|
||||||
- **Encounter Management** — open encounters against a patient; encounter status state machine (`scheduled → active → discharged / cancelled`) with 409 on illegal transitions; encounter timeline as a merged chronological view across status changes, observation summaries, and alerts
|
- **Encounter Management** — open encounters against a patient with optional room/bed and admission reason; encounter status state machine (`scheduled → active → discharged / cancelled`) with 409 on illegal transitions; optional discharge diagnosis on discharge; encounter timeline as a merged chronological view across status changes, observation summaries, and alerts
|
||||||
- **Alert Threshold Management** — configure per-observation-code numeric bounds (`criticalLow`, `warningLow`, `warningHigh`, `criticalHigh`); thresholds pre-loaded into Redis on startup; write-through cache invalidation on update
|
- **Alert Threshold Management** — configure per-observation-code numeric bounds (`criticalLow`, `warningLow`, `warningHigh`, `criticalHigh`) for 12 observation codes; thresholds pre-loaded into Redis on startup; write-through cache invalidation on update
|
||||||
- **Observation Ingest** — `POST /encounters/:id/observations` accepts single or small batch (up to 10); idempotency via `Idempotency-Key` header (partial unique index); plausibility validation per observation code; synchronous critical alert creation within the ingest transaction; warning breach deferred to Kafka consumer; outbox event written in the same commit; cursor-paginated history on `(encounter_id, observation_code, recorded_at DESC)`
|
- **Observation Ingest** — `POST /encounters/:id/observations` accepts single or small batch (up to 10); idempotency via `Idempotency-Key` header (partial unique index); plausibility validation per observation code; synchronous critical alert creation within the ingest transaction; warning breach deferred to Kafka consumer; outbox event written in the same commit; cursor-paginated history on `(encounter_id, observation_code, recorded_at DESC)`
|
||||||
- **Clinical Alert Lifecycle** — paginated alert list per encounter and globally; acknowledge with clinician ID and optional note; resolve (must be acknowledged first); global list filterable by status, severity, and department
|
- **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
|
- **Outbox Relay** — `IHostedService` polling every 500ms; reads unprocessed outbox rows, publishes to Kafka, marks processed; partitioned by `encounterId` for per-encounter ordering
|
||||||
@@ -149,12 +149,14 @@ VigilCareClinicalAPI/
|
|||||||
│ ├── EncounterType.cs # Inpatient, Outpatient, Emergency
|
│ ├── EncounterType.cs # Inpatient, Outpatient, Emergency
|
||||||
│ ├── AlertSeverity.cs # Warning, Critical
|
│ ├── AlertSeverity.cs # Warning, Critical
|
||||||
│ ├── AlertStatus.cs # Open, Acknowledged, Resolved, Escalated
|
│ ├── AlertStatus.cs # Open, Acknowledged, Resolved, Escalated
|
||||||
│ ├── AlertType.cs # ThresholdBreach, SepsisWarning, …
|
│ ├── AlertType.cs # Threshold breach, sepsis, systolic BP, AVPU, glucose, …
|
||||||
|
│ ├── BloodType.cs # A+, O-, AB-, … with ToDbString/FromDbString
|
||||||
│ ├── ObservationSource.cs # Device, Manual, Lab
|
│ ├── ObservationSource.cs # Device, Manual, Lab
|
||||||
│ └── OrderType.cs / ReconciliationCheckType.cs / Department.cs / OrderStatus.cs
|
│ └── OrderType.cs / ReconciliationCheckType.cs / Department.cs / OrderStatus.cs
|
||||||
│ └── Json/
|
│ └── Json/
|
||||||
│ ├── ObservationSourceJsonConverter.cs
|
│ ├── ObservationSourceJsonConverter.cs
|
||||||
│ └── DepartmentJsonConverter.cs
|
│ ├── DepartmentJsonConverter.cs
|
||||||
|
│ └── BloodTypeJsonConverter.cs # Clinical notation (A+, AB-) in JSON API
|
||||||
├── Services/
|
├── Services/
|
||||||
│ ├── Interfaces/ # IPatientService, IEncounterService, …
|
│ ├── Interfaces/ # IPatientService, IEncounterService, …
|
||||||
│ ├── PatientService.cs
|
│ ├── PatientService.cs
|
||||||
@@ -247,7 +249,8 @@ tests/
|
|||||||
├── NotificationPipelineTests.cs # RabbitMQ topology, DLQ routing
|
├── NotificationPipelineTests.cs # RabbitMQ topology, DLQ routing
|
||||||
├── ReconciliationTests.cs # Three reconciliation checks, deduplication, RabbitMQ publish
|
├── ReconciliationTests.cs # Three reconciliation checks, deduplication, RabbitMQ publish
|
||||||
├── ObservabilityPhase8Tests.cs # /metrics families and correlation header behavior
|
├── ObservabilityPhase8Tests.cs # /metrics families and correlation header behavior
|
||||||
└── DataLakePhase9Tests.cs # Kafka → MinIO Parquet flow and schema checks
|
├── DataLakePhase9Tests.cs # Kafka → MinIO Parquet flow and schema checks
|
||||||
|
└── ClinicalDemographicsAndObservationTests.cs # Patient/encounter enrichment, expanded observation alerts
|
||||||
|
|
||||||
scripts/
|
scripts/
|
||||||
├── run-api-redis-tests.sh # Phase 1 — patient/encounter/threshold + Redis cache
|
├── run-api-redis-tests.sh # Phase 1 — patient/encounter/threshold + Redis cache
|
||||||
@@ -257,10 +260,11 @@ scripts/
|
|||||||
├── run-notification-pipeline-tests.sh # Phase 6 — RabbitMQ paging, DLQ, discharge summary
|
├── run-notification-pipeline-tests.sh # Phase 6 — RabbitMQ paging, DLQ, discharge summary
|
||||||
├── run-reconciliation-tests.sh # Phase 7 — reconciliation scheduler checks
|
├── run-reconciliation-tests.sh # Phase 7 — reconciliation scheduler checks
|
||||||
├── run-phase8-verification.sh # Phase 8 — Prometheus metrics, alerts_unacknowledged_gauge, correlation headers
|
├── run-phase8-verification.sh # Phase 8 — Prometheus metrics, alerts_unacknowledged_gauge, correlation headers
|
||||||
└── run-phase9-verification.sh # Phase 9 — data lake tests, Kafka offsets, MinIO Parquet, DuckDB schema
|
├── run-phase9-verification.sh # Phase 9 — data lake tests, Kafka offsets, MinIO Parquet, DuckDB schema
|
||||||
|
└── run-phase10-verification.sh # Phase 10 — 12 Redis thresholds, clinical enrichment, ES pipeline, integration tests
|
||||||
|
|
||||||
docs/
|
docs/
|
||||||
├── plans/ # Phase 1–9 implementation and verification guides
|
├── plans/ # Phase 1–10 implementation and verification guides
|
||||||
├── decisions/
|
├── decisions/
|
||||||
│ ├── data-lake-design.md # Parquet vs JSON, partitioning, replay rationale
|
│ ├── data-lake-design.md # Parquet vs JSON, partitioning, replay rationale
|
||||||
│ └── sepsis-engine-design.md # SIRS sliding window and idempotent alert design
|
│ └── sepsis-engine-design.md # SIRS sliding window and idempotent alert design
|
||||||
@@ -374,7 +378,7 @@ dotnet run
|
|||||||
|
|
||||||
On startup the application:
|
On startup the application:
|
||||||
1. Runs EF Core migrations
|
1. Runs EF Core migrations
|
||||||
2. Seeds two patients, one active inpatient encounter each, four alert thresholds, and sample observations
|
2. Seeds two patients (with blood type, allergies, emergency contact), one active inpatient encounter each (with room/bed and admission reason), twelve alert thresholds, and sample observations
|
||||||
3. Pre-loads all thresholds into Redis
|
3. Pre-loads all thresholds into Redis
|
||||||
4. Provisions Kafka topics and Elasticsearch indices
|
4. Provisions Kafka topics and Elasticsearch indices
|
||||||
5. Declares the RabbitMQ exchange and queue topology
|
5. Declares the RabbitMQ exchange and queue topology
|
||||||
@@ -400,6 +404,7 @@ Integration tests use `WebApplicationFactory` with a `Testing` environment and T
|
|||||||
| `ReconciliationTests` | 7 | Three reconciliation checks, deduplication, RabbitMQ publish |
|
| `ReconciliationTests` | 7 | Three reconciliation checks, deduplication, RabbitMQ publish |
|
||||||
| `ObservabilityPhase8Tests` | 8 | All eight `/metrics` families, correlation headers, ingest counter increment |
|
| `ObservabilityPhase8Tests` | 8 | All eight `/metrics` families, correlation headers, ingest counter increment |
|
||||||
| `DataLakePhase9Tests` | 9 | Kafka → MinIO Parquet flow and schema checks |
|
| `DataLakePhase9Tests` | 9 | Kafka → MinIO Parquet flow and schema checks |
|
||||||
|
| `ClinicalDemographicsAndObservationTests` | 10 | Patient clinical fields, encounter enrichment, expanded observation codes, critical glucose alert |
|
||||||
|
|
||||||
### Verification Scripts
|
### Verification Scripts
|
||||||
|
|
||||||
@@ -408,6 +413,7 @@ With the API running (`dotnet run`) and Docker Compose up:
|
|||||||
```bash
|
```bash
|
||||||
./scripts/run-phase8-verification.sh # Prometheus target UP, eight metrics, alerts_unacknowledged_gauge live update
|
./scripts/run-phase8-verification.sh # Prometheus target UP, eight metrics, alerts_unacknowledged_gauge live update
|
||||||
./scripts/run-phase9-verification.sh # DataLakePhase9Tests, Kafka consumer group, MinIO Parquet, DuckDB schema
|
./scripts/run-phase9-verification.sh # DataLakePhase9Tests, Kafka consumer group, MinIO Parquet, DuckDB schema
|
||||||
|
./scripts/run-phase10-verification.sh # 12 Redis thresholds, clinical enrichment, ES pipeline, Phase 10 integration tests
|
||||||
```
|
```
|
||||||
|
|
||||||
Per-phase test runners (subset of `dotnet test`):
|
Per-phase test runners (subset of `dotnet test`):
|
||||||
@@ -434,7 +440,7 @@ curl https://install.duckdb.org | sh
|
|||||||
export PATH="$HOME/.duckdb/cli/latest:$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.duckdb/cli/latest:$HOME/.local/bin:$PATH"
|
||||||
```
|
```
|
||||||
|
|
||||||
See `docs/plans/phase-8-plan.md` and `docs/plans/phase-9-plan.md` for manual Grafana, Seq, Kafka replay, and DuckDB query examples.
|
See `docs/plans/phase-8-plan.md`, `docs/plans/phase-9-plan.md`, and `docs/plans/phase-10-plan.md` for manual Grafana, Seq, Kafka replay, and DuckDB query examples.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -495,6 +501,10 @@ Error response:
|
|||||||
| `lastName` | string | yes | |
|
| `lastName` | string | yes | |
|
||||||
| `dateOfBirth` | date | yes | |
|
| `dateOfBirth` | date | yes | |
|
||||||
| `gender` | string | yes | |
|
| `gender` | string | yes | |
|
||||||
|
| `bloodType` | string | no | Clinical notation: `A+`, `O-`, `AB-`, etc. |
|
||||||
|
| `allergies` | string | no | Free-text allergy list |
|
||||||
|
| `emergencyContactName` | string | no | |
|
||||||
|
| `emergencyContactPhone` | string | no | |
|
||||||
|
|
||||||
### Encounters
|
### Encounters
|
||||||
|
|
||||||
@@ -521,6 +531,10 @@ scheduled → active → discharged
|
|||||||
| `encounterType` | string | yes | `INPATIENT`, `OUTPATIENT`, `EMERGENCY` |
|
| `encounterType` | string | yes | `INPATIENT`, `OUTPATIENT`, `EMERGENCY` |
|
||||||
| `department` | string | yes | |
|
| `department` | string | yes | |
|
||||||
| `attendingPhysician` | string | yes | |
|
| `attendingPhysician` | string | yes | |
|
||||||
|
| `roomBed` | string | no | Ward and bed assignment (e.g. `ICU-1A`) |
|
||||||
|
| `admissionReason` | string | no | Clinical reason for admission |
|
||||||
|
|
||||||
|
**PATCH `/encounters/{id}/status` body** — optional `dischargeDiagnosis` when transitioning to `DISCHARGED`.
|
||||||
|
|
||||||
### Alert Thresholds
|
### Alert Thresholds
|
||||||
|
|
||||||
@@ -535,7 +549,7 @@ scheduled → active → discharged
|
|||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `observationCode` | string | yes | e.g. `HEART_RATE`, `TEMP_C`, `POTASSIUM_MEQ_L` |
|
| `observationCode` | string | yes | e.g. `HEART_RATE`, `SYSTOLIC_BP`, `AVPU`, `GLUCOSE_MG_DL` |
|
||||||
| `displayName` | string | yes | Human-readable label |
|
| `displayName` | string | yes | Human-readable label |
|
||||||
| `unit` | string | yes | e.g. `bpm`, `°C`, `mEq/L` |
|
| `unit` | string | yes | e.g. `bpm`, `°C`, `mEq/L` |
|
||||||
| `criticalLow` | decimal | no | |
|
| `criticalLow` | decimal | no | |
|
||||||
@@ -543,14 +557,22 @@ scheduled → active → discharged
|
|||||||
| `warningHigh` | decimal | no | |
|
| `warningHigh` | decimal | no | |
|
||||||
| `criticalHigh` | decimal | no | |
|
| `criticalHigh` | decimal | no | |
|
||||||
|
|
||||||
Seeded thresholds:
|
Seeded thresholds (12 codes):
|
||||||
|
|
||||||
| Code | Display | Unit | Critical Low | Warning Low | Warning High | Critical High |
|
| Code | Display | Unit | Critical Low | Warning Low | Warning High | Critical High |
|
||||||
|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| `HEART_RATE` | Heart Rate | bpm | 30 | 50 | 100 | 150 |
|
| `HEART_RATE` | Heart Rate | bpm | 30 | 50 | 100 | 150 |
|
||||||
| `TEMP_C` | Temperature | °C | 35.0 | 36.0 | 38.3 | 40.0 |
|
| `TEMP_C` | Temperature | °C | 35.0 | 36.0 | 38.3 | 40.0 |
|
||||||
| `POTASSIUM_MEQ_L` | Serum Potassium | mEq/L | 2.5 | 3.5 | 5.0 | 6.5 |
|
| `POTASSIUM_MEQ_L` | Serum Potassium | mEq/L | 2.5 | 3.5 | 5.0 | 6.5 |
|
||||||
| `SPO2_PCT` | Oxygen Saturation | % | 85 | 90 | — | — |
|
| `SPO2` | Oxygen Saturation | % | 88 | 92 | — | — |
|
||||||
|
| `RESP_RATE` | Respiratory Rate | breaths/min | — | 12 | 20 | 30 |
|
||||||
|
| `WBC_K_UL` | White Blood Cell Count | k/µL | 2.0 | 4.0 | 12.0 | 20.0 |
|
||||||
|
| `SYSTOLIC_BP` | Systolic Blood Pressure | mmHg | 70 | 90 | 160 | 180 |
|
||||||
|
| `DIASTOLIC_BP` | Diastolic Blood Pressure | mmHg | 40 | 60 | 90 | 110 |
|
||||||
|
| `LACTATE_MMOL_L` | Serum Lactate | mmol/L | — | — | 2.0 | 4.0 |
|
||||||
|
| `AVPU` | AVPU Consciousness | score | — | — | — | 2 |
|
||||||
|
| `SUPPLEMENTAL_O2` | Supplemental Oxygen | flag | — | — | — | — |
|
||||||
|
| `GLUCOSE_MG_DL` | Blood Glucose | mg/dL | 40 | 70 | 180 | 400 |
|
||||||
|
|
||||||
### Observations
|
### Observations
|
||||||
|
|
||||||
@@ -666,6 +688,10 @@ firstName string required (max 100)
|
|||||||
lastName string required (max 100)
|
lastName string required (max 100)
|
||||||
dateOfBirth Date required
|
dateOfBirth Date required
|
||||||
gender string required (max 10)
|
gender string required (max 10)
|
||||||
|
bloodType string? A+ | A- | B+ | B- | AB+ | AB- | O+ | O-
|
||||||
|
allergies string? free text
|
||||||
|
emergencyContactName string? (max 200)
|
||||||
|
emergencyContactPhone string? (max 30)
|
||||||
status string active | inactive (default: active)
|
status string active | inactive (default: active)
|
||||||
createdAt DateTimeOffset
|
createdAt DateTimeOffset
|
||||||
```
|
```
|
||||||
@@ -679,6 +705,9 @@ encounterType string INPATIENT | OUTPATIENT | EMERGENCY
|
|||||||
status string scheduled | active | discharged | cancelled (default: scheduled)
|
status string scheduled | active | discharged | cancelled (default: scheduled)
|
||||||
department string required (max 100)
|
department string required (max 100)
|
||||||
attendingPhysician string required (max 200)
|
attendingPhysician string required (max 200)
|
||||||
|
roomBed string? ward/bed assignment (max 50)
|
||||||
|
admissionReason string? clinical reason for admission
|
||||||
|
dischargeDiagnosis string? set on discharge
|
||||||
admittedAt DateTimeOffset
|
admittedAt DateTimeOffset
|
||||||
dischargedAt DateTimeOffset?
|
dischargedAt DateTimeOffset?
|
||||||
createdAt DateTimeOffset
|
createdAt DateTimeOffset
|
||||||
@@ -790,6 +819,8 @@ createdAt DateTimeOffset
|
|||||||
"department": "ICU",
|
"department": "ICU",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"attendingPhysician": "Dr. Osei",
|
"attendingPhysician": "Dr. Osei",
|
||||||
|
"roomBed": "ICU-4B",
|
||||||
|
"admissionReason": "Chest pain, rule out MI",
|
||||||
"admittedAt": "2025-01-01T08:00:00Z",
|
"admittedAt": "2025-01-01T08:00:00Z",
|
||||||
"openAlertCount": 2,
|
"openAlertCount": 2,
|
||||||
"lastObservationAt": "2025-01-01T09:45:00Z"
|
"lastObservationAt": "2025-01-01T09:45:00Z"
|
||||||
@@ -948,7 +979,7 @@ Observation history uses cursor pagination on `(recorded_at DESC, id DESC)`. Off
|
|||||||
|
|
||||||
## Implemented Phases
|
## Implemented Phases
|
||||||
|
|
||||||
All nine phases from the project roadmap are implemented and covered by integration tests and/or verification scripts.
|
All ten phases from the project roadmap are implemented and covered by integration tests and/or verification scripts.
|
||||||
|
|
||||||
| Phase | Feature | Status |
|
| Phase | Feature | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -961,5 +992,6 @@ All nine phases from the project roadmap are implemented and covered by integrat
|
|||||||
| 7 | Reconciliation scheduler — unacknowledged critical alerts, stale pending orders, disconnected monitors; `reconciliation_alerts` table; RabbitMQ publish; integration tests | Done |
|
| 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`); eight metric families and three collectors; Grafana clinical dashboard; `ObservabilityPhase8Tests`; `run-phase8-verification.sh` | Done |
|
||||||
| 9 | Data lake writer — `data-lake-writer` consumer group; date-partitioned Parquet flush to MinIO; `DataLakePhase9Tests`; `run-phase9-verification.sh`; design doc in `docs/decisions/data-lake-design.md` | Done |
|
| 9 | Data lake writer — `data-lake-writer` consumer group; date-partitioned Parquet flush to MinIO; `DataLakePhase9Tests`; `run-phase9-verification.sh`; design doc in `docs/decisions/data-lake-design.md` | Done |
|
||||||
|
| 10 | Clinical data model expansion — `BloodType`, patient allergies/emergency contact, encounter room/bed/admission/discharge fields; five new observation codes (`SYSTOLIC_BP`, `DIASTOLIC_BP`, `LACTATE_MMOL_L`, `AVPU`, `SUPPLEMENTAL_O2`); `GLUCOSE_MG_DL` threshold fix; 12 seeded thresholds; `ClinicalDemographicsAndObservationTests`; `run-phase10-verification.sh` | Done |
|
||||||
|
|
||||||
**Optional follow-up:** execute and document the Kafka replay demonstration for the data lake (reset `data-lake-writer` offsets, clear MinIO prefixes, restart API, confirm Parquet rebuild). See `docs/plans/phase-9-plan.md` § Replay demonstration.
|
**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.
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
[Collection("Integration")]
|
||||||
|
public class OrderLifecycleTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly ApiFixture _fixture;
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private Guid _encounterId;
|
||||||
|
|
||||||
|
public OrderLifecycleTests(ApiFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
_client = fixture.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync() => await ResetAndSeedAsync();
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
private async Task ResetAndSeedAsync()
|
||||||
|
{
|
||||||
|
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-ORD-001", FirstName = "Order", LastName = "Test",
|
||||||
|
DateOfBirth = new DateOnly(1988, 2, 10), Gender = "F", CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
var encounter = new Encounter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
|
||||||
|
Status = EncounterStatus.Active, Department = Department.GeneralMedicine,
|
||||||
|
AttendingPhysician = "Dr. Order", AdmittedAt = DateTimeOffset.UtcNow,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
db.Patients.Add(patient);
|
||||||
|
db.Encounters.Add(encounter);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_encounterId = encounter.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateOrder_Returns201()
|
||||||
|
{
|
||||||
|
await ResetAndSeedAsync();
|
||||||
|
|
||||||
|
var resp = await _client.PostAsJsonAsync(
|
||||||
|
$"/api/v1/encounters/{_encounterId}/orders",
|
||||||
|
new CreateOrderRequest(OrderType.Lab, "CBC with differential", "Dr. Test"));
|
||||||
|
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||||
|
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||||
|
body!.RootElement.GetProperty("data").GetProperty("status").GetString()
|
||||||
|
.Should().Be("Pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ListOrders_ReturnsPaginated()
|
||||||
|
{
|
||||||
|
await ResetAndSeedAsync();
|
||||||
|
|
||||||
|
await _client.PostAsJsonAsync(
|
||||||
|
$"/api/v1/encounters/{_encounterId}/orders",
|
||||||
|
new CreateOrderRequest(OrderType.Lab, "BMP", "Dr. A"));
|
||||||
|
await _client.PostAsJsonAsync(
|
||||||
|
$"/api/v1/encounters/{_encounterId}/orders",
|
||||||
|
new CreateOrderRequest(OrderType.Imaging, "Chest X-ray", "Dr. B"));
|
||||||
|
|
||||||
|
var resp = await _client.GetAsync($"/api/v1/encounters/{_encounterId}/orders");
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||||
|
body!.RootElement.GetProperty("data").GetProperty("totalCount").GetInt32()
|
||||||
|
.Should().Be(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordResult_TransitionsToResulted()
|
||||||
|
{
|
||||||
|
await ResetAndSeedAsync();
|
||||||
|
|
||||||
|
var createResp = await _client.PostAsJsonAsync(
|
||||||
|
$"/api/v1/encounters/{_encounterId}/orders",
|
||||||
|
new CreateOrderRequest(OrderType.Lab, "Potassium", "Dr. Test"));
|
||||||
|
var createBody = await createResp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||||
|
var orderId = createBody!.RootElement.GetProperty("data").GetProperty("id").GetGuid();
|
||||||
|
|
||||||
|
var resultResp = await _client.PatchAsJsonAsync(
|
||||||
|
$"/api/v1/orders/{orderId}/result",
|
||||||
|
new RecordOrderResultRequest("Potassium 4.2 mEq/L — within normal limits"));
|
||||||
|
|
||||||
|
resultResp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var body = await resultResp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||||
|
body!.RootElement.GetProperty("data").GetProperty("status").GetString()
|
||||||
|
.Should().Be("Resulted");
|
||||||
|
body.RootElement.GetProperty("data").GetProperty("resultSummary").GetString()
|
||||||
|
.Should().Contain("4.2");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CancelResultedOrder_Returns409()
|
||||||
|
{
|
||||||
|
await ResetAndSeedAsync();
|
||||||
|
|
||||||
|
var createResp = await _client.PostAsJsonAsync(
|
||||||
|
$"/api/v1/encounters/{_encounterId}/orders",
|
||||||
|
new CreateOrderRequest(OrderType.Lab, "Glucose", "Dr. Test"));
|
||||||
|
var orderId = (await createResp.Content.ReadFromJsonAsync<JsonDocument>())!
|
||||||
|
.RootElement.GetProperty("data").GetProperty("id").GetGuid();
|
||||||
|
|
||||||
|
await _client.PatchAsJsonAsync(
|
||||||
|
$"/api/v1/orders/{orderId}/result",
|
||||||
|
new RecordOrderResultRequest("Normal"));
|
||||||
|
|
||||||
|
var resp = await _client.PatchAsJsonAsync(
|
||||||
|
$"/api/v1/orders/{orderId}/status",
|
||||||
|
new TransitionOrderStatusRequest(OrderStatus.Cancelled));
|
||||||
|
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
|
||||||
|
[Collection("Integration")]
|
||||||
|
public class ValidationTests
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public ValidationTests(ApiFixture fixture) => _client = fixture.CreateClient();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EmptyFirstName_Returns400()
|
||||||
|
{
|
||||||
|
var resp = await _client.PostAsJsonAsync("/api/v1/patients", new
|
||||||
|
{
|
||||||
|
firstName = "",
|
||||||
|
lastName = "Valid",
|
||||||
|
dateOfBirth = "1990-01-01",
|
||||||
|
gender = "M"
|
||||||
|
});
|
||||||
|
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ThresholdInvalidOrder_Returns400()
|
||||||
|
{
|
||||||
|
var resp = await _client.PostAsJsonAsync("/api/v1/alert-thresholds", new
|
||||||
|
{
|
||||||
|
observationCode = "TEST_CODE",
|
||||||
|
displayName = "Test",
|
||||||
|
unit = "units",
|
||||||
|
criticalLow = 50,
|
||||||
|
warningLow = 30
|
||||||
|
});
|
||||||
|
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OrderEmptyDescription_Returns400()
|
||||||
|
{
|
||||||
|
var resp = await _client.PostAsJsonAsync(
|
||||||
|
$"/api/v1/encounters/{Guid.NewGuid()}/orders",
|
||||||
|
new { orderType = "Lab", description = "", orderedBy = "Dr. Test" });
|
||||||
|
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
[Collection("Integration")]
|
||||||
|
public class WarningAlertTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly ApiFixture _fixture;
|
||||||
|
private Guid _patientId;
|
||||||
|
private Guid _encounterId;
|
||||||
|
|
||||||
|
public WarningAlertTests(ApiFixture fixture) => _fixture = fixture;
|
||||||
|
|
||||||
|
public async Task InitializeAsync() => await ResetAndSeedAsync();
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
private async Task ResetAndSeedAsync()
|
||||||
|
{
|
||||||
|
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-WA-001", FirstName = "Warning", LastName = "Test",
|
||||||
|
DateOfBirth = new DateOnly(1975, 5, 20), 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. Warning", AdmittedAt = DateTimeOffset.UtcNow,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
db.Patients.Add(patient);
|
||||||
|
db.Encounters.Add(encounter);
|
||||||
|
db.AlertThresholds.Add(new AlertThreshold
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), ObservationCode = "HEART_RATE",
|
||||||
|
DisplayName = "Heart Rate", Unit = "bpm",
|
||||||
|
CriticalLow = 30, WarningLow = 50, WarningHigh = 100, CriticalHigh = 150,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||||
|
var cache = redis.GetDatabase(1);
|
||||||
|
await cache.StringSetAsync("threshold:HEART_RATE",
|
||||||
|
"""{"ObservationCode":"HEART_RATE","CriticalLow":30,"WarningLow":50,"WarningHigh":100,"CriticalHigh":150}""");
|
||||||
|
|
||||||
|
_patientId = patient.Id;
|
||||||
|
_encounterId = encounter.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WarningHeartRate_AlertCreated()
|
||||||
|
{
|
||||||
|
await ResetAndSeedAsync();
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var evaluator = scope.ServiceProvider.GetRequiredService<WarningEvaluator>();
|
||||||
|
|
||||||
|
var created = await evaluator.EvaluateAsync(
|
||||||
|
Guid.NewGuid(), _encounterId, _patientId, "HEART_RATE", 105m);
|
||||||
|
|
||||||
|
created.Should().BeTrue();
|
||||||
|
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var alert = await db.ClinicalAlerts.SingleAsync();
|
||||||
|
alert.AlertType.Should().Be(AlertType.WarningHeartRate);
|
||||||
|
alert.Severity.Should().Be(AlertSeverity.Warning);
|
||||||
|
alert.Status.Should().Be(AlertStatus.Open);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NormalHeartRate_NoAlert()
|
||||||
|
{
|
||||||
|
await ResetAndSeedAsync();
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var evaluator = scope.ServiceProvider.GetRequiredService<WarningEvaluator>();
|
||||||
|
|
||||||
|
var created = await evaluator.EvaluateAsync(
|
||||||
|
Guid.NewGuid(), _encounterId, _patientId, "HEART_RATE", 78m);
|
||||||
|
|
||||||
|
created.Should().BeFalse();
|
||||||
|
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
(await db.ClinicalAlerts.CountAsync()).Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CriticalHeartRate_NoWarningAlert()
|
||||||
|
{
|
||||||
|
await ResetAndSeedAsync();
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var evaluator = scope.ServiceProvider.GetRequiredService<WarningEvaluator>();
|
||||||
|
|
||||||
|
var created = await evaluator.EvaluateAsync(
|
||||||
|
Guid.NewGuid(), _encounterId, _patientId, "HEART_RATE", 160m);
|
||||||
|
|
||||||
|
created.Should().BeFalse();
|
||||||
|
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
(await db.ClinicalAlerts.CountAsync()).Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DuplicateWarning_Idempotent()
|
||||||
|
{
|
||||||
|
await ResetAndSeedAsync();
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var evaluator = scope.ServiceProvider.GetRequiredService<WarningEvaluator>();
|
||||||
|
|
||||||
|
await evaluator.EvaluateAsync(Guid.NewGuid(), _encounterId, _patientId, "HEART_RATE", 105m);
|
||||||
|
await evaluator.EvaluateAsync(Guid.NewGuid(), _encounterId, _patientId, "HEART_RATE", 108m);
|
||||||
|
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
(await db.ClinicalAlerts.CountAsync()).Should().Be(1,
|
||||||
|
"second warning for same type must be idempotent while first is still open");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Confluent.Kafka;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
public class WarningAlertService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly KafkaOptions _kafkaOptions;
|
||||||
|
private readonly ILogger<WarningAlertService> _logger;
|
||||||
|
|
||||||
|
public WarningAlertService(
|
||||||
|
IServiceProvider services,
|
||||||
|
IOptions<KafkaOptions> kafkaOptions,
|
||||||
|
ILogger<WarningAlertService> logger)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_kafkaOptions = kafkaOptions.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var config = new ConsumerConfig
|
||||||
|
{
|
||||||
|
BootstrapServers = _kafkaOptions.BootstrapServers,
|
||||||
|
GroupId = "warning-evaluator",
|
||||||
|
AutoOffsetReset = AutoOffsetReset.Earliest,
|
||||||
|
EnableAutoCommit = false
|
||||||
|
};
|
||||||
|
|
||||||
|
using var consumer = new ConsumerBuilder<string, string>(config).Build();
|
||||||
|
consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded);
|
||||||
|
|
||||||
|
_logger.LogInformation("WarningAlertService started — consumer group: warning-evaluator");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
ConsumeResult<string, string>? result = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = consumer.Consume(stoppingToken);
|
||||||
|
|
||||||
|
var evt = JsonSerializer.Deserialize<WarningObservationEvent>(
|
||||||
|
result.Message.Value,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||||
|
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var evaluator = scope.ServiceProvider.GetRequiredService<WarningEvaluator>();
|
||||||
|
|
||||||
|
await evaluator.EvaluateAsync(
|
||||||
|
evt.ObservationId,
|
||||||
|
evt.EncounterId,
|
||||||
|
evt.PatientId,
|
||||||
|
evt.ObservationCode,
|
||||||
|
evt.Value,
|
||||||
|
stoppingToken);
|
||||||
|
|
||||||
|
consumer.Commit(result);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex,
|
||||||
|
"WarningAlertService failed on topic={Topic} offset={Offset} — not committing",
|
||||||
|
result?.Topic, result?.Offset.Value);
|
||||||
|
await Task.Delay(2000, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
consumer.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clinical order management: create, list, status transitions, and result recording.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public class OrdersController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IOrderService _orders;
|
||||||
|
|
||||||
|
public OrdersController(IOrderService orders) => _orders = orders;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new clinical order for an encounter.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("api/v1/encounters/{encounterId:guid}/orders")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<Order>), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Create(Guid encounterId, [FromBody] CreateOrderRequest req)
|
||||||
|
{
|
||||||
|
var order = await _orders.CreateAsync(encounterId, req);
|
||||||
|
return StatusCode(201, ApiResponse<Order>.Created(order));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists orders for an encounter with optional status filter.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("api/v1/encounters/{encounterId:guid}/orders")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> ListByEncounter(
|
||||||
|
Guid encounterId,
|
||||||
|
[FromQuery] string? status,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
OrderStatus? parsedStatus = null;
|
||||||
|
if (!string.IsNullOrEmpty(status))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
parsedStatus = OrderStatusExtensions.FromDbString(status);
|
||||||
|
}
|
||||||
|
catch (ArgumentOutOfRangeException)
|
||||||
|
{
|
||||||
|
return BadRequest(ApiResponse<object>.Fail(400, "Invalid status filter.", "INVALID_STATUS"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _orders.ListByEncounterAsync(encounterId, parsedStatus, page, pageSize);
|
||||||
|
return Ok(ApiResponse<object>.Ok(new
|
||||||
|
{
|
||||||
|
items = result.Items,
|
||||||
|
page = result.Page,
|
||||||
|
pageSize = result.PageSize,
|
||||||
|
totalCount = result.TotalCount,
|
||||||
|
totalPages = result.TotalPages
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a single order by id with its encounter.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("api/v1/orders/{id:guid}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<Order>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Get(Guid id)
|
||||||
|
{
|
||||||
|
var order = await _orders.GetByIdAsync(id);
|
||||||
|
return Ok(ApiResponse<Order>.Ok(order));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transitions an order to a new status.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPatch("api/v1/orders/{id:guid}/status")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<Order>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> TransitionStatus(Guid id, [FromBody] TransitionOrderStatusRequest req)
|
||||||
|
{
|
||||||
|
var order = await _orders.TransitionStatusAsync(id, req.Status);
|
||||||
|
return Ok(ApiResponse<Order>.Ok(order));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a result for an order, transitioning it to Resulted status.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPatch("api/v1/orders/{id:guid}/result")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<Order>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> RecordResult(Guid id, [FromBody] RecordOrderResultRequest req)
|
||||||
|
{
|
||||||
|
var order = await _orders.RecordResultAsync(id, req);
|
||||||
|
return Ok(ApiResponse<Order>.Ok(order));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ public class OrderConfiguration : IEntityTypeConfiguration<Order>
|
|||||||
v => OrderStatusExtensions.FromDbString(v))
|
v => OrderStatusExtensions.FromDbString(v))
|
||||||
.HasDefaultValueSql("'PENDING'")
|
.HasDefaultValueSql("'PENDING'")
|
||||||
.HasSentinel((OrderStatus)(-1));
|
.HasSentinel((OrderStatus)(-1));
|
||||||
|
builder.Property(o => o.ResultSummary).HasColumnName("result_summary");
|
||||||
builder.Property(o => o.OrderedAt).HasColumnName("ordered_at").HasDefaultValueSql("NOW()");
|
builder.Property(o => o.OrderedAt).HasColumnName("ordered_at").HasDefaultValueSql("NOW()");
|
||||||
builder.Property(o => o.ResultedAt).HasColumnName("resulted_at");
|
builder.Property(o => o.ResultedAt).HasColumnName("resulted_at");
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public class Order
|
|||||||
public OrderStatus Status { get; set; } = OrderStatus.Pending;
|
public OrderStatus Status { get; set; } = OrderStatus.Pending;
|
||||||
public DateTimeOffset OrderedAt { get; set; }
|
public DateTimeOffset OrderedAt { get; set; }
|
||||||
public DateTimeOffset? ResultedAt { get; set; }
|
public DateTimeOffset? ResultedAt { get; set; }
|
||||||
|
public string? ResultSummary { get; set; }
|
||||||
|
|
||||||
public Encounter Encounter { get; set; } = null!;
|
public Encounter Encounter { get; set; } = null!;
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,19 @@ public enum AlertType
|
|||||||
CriticalDiastolicBp,
|
CriticalDiastolicBp,
|
||||||
CriticalLactateMmolL,
|
CriticalLactateMmolL,
|
||||||
CriticalAvpu,
|
CriticalAvpu,
|
||||||
CriticalGlucoseMgDl
|
CriticalGlucoseMgDl,
|
||||||
|
|
||||||
|
// New — warning-level threshold alerts
|
||||||
|
WarningHeartRate,
|
||||||
|
WarningTempC,
|
||||||
|
WarningPotassiumMeqL,
|
||||||
|
WarningSpo2,
|
||||||
|
WarningRespRate,
|
||||||
|
WarningWbcKUl,
|
||||||
|
WarningSystolicBp,
|
||||||
|
WarningDiastolicBp,
|
||||||
|
WarningLactateMmolL,
|
||||||
|
WarningGlucoseMgDl
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class AlertTypeExtensions
|
public static class AlertTypeExtensions
|
||||||
@@ -30,6 +42,16 @@ public static class AlertTypeExtensions
|
|||||||
AlertType.CriticalLactateMmolL => "CRITICAL_LACTATE_MMOL_L",
|
AlertType.CriticalLactateMmolL => "CRITICAL_LACTATE_MMOL_L",
|
||||||
AlertType.CriticalAvpu => "CRITICAL_AVPU",
|
AlertType.CriticalAvpu => "CRITICAL_AVPU",
|
||||||
AlertType.CriticalGlucoseMgDl => "CRITICAL_GLUCOSE_MG_DL",
|
AlertType.CriticalGlucoseMgDl => "CRITICAL_GLUCOSE_MG_DL",
|
||||||
|
AlertType.WarningHeartRate => "WARNING_HEART_RATE",
|
||||||
|
AlertType.WarningTempC => "WARNING_TEMP_C",
|
||||||
|
AlertType.WarningPotassiumMeqL => "WARNING_POTASSIUM_MEQ_L",
|
||||||
|
AlertType.WarningSpo2 => "WARNING_SPO2",
|
||||||
|
AlertType.WarningRespRate => "WARNING_RESP_RATE",
|
||||||
|
AlertType.WarningWbcKUl => "WARNING_WBC_K_UL",
|
||||||
|
AlertType.WarningSystolicBp => "WARNING_SYSTOLIC_BP",
|
||||||
|
AlertType.WarningDiastolicBp => "WARNING_DIASTOLIC_BP",
|
||||||
|
AlertType.WarningLactateMmolL => "WARNING_LACTATE_MMOL_L",
|
||||||
|
AlertType.WarningGlucoseMgDl => "WARNING_GLUCOSE_MG_DL",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,6 +69,16 @@ public static class AlertTypeExtensions
|
|||||||
"CRITICAL_LACTATE_MMOL_L" => AlertType.CriticalLactateMmolL,
|
"CRITICAL_LACTATE_MMOL_L" => AlertType.CriticalLactateMmolL,
|
||||||
"CRITICAL_AVPU" => AlertType.CriticalAvpu,
|
"CRITICAL_AVPU" => AlertType.CriticalAvpu,
|
||||||
"CRITICAL_GLUCOSE_MG_DL" => AlertType.CriticalGlucoseMgDl,
|
"CRITICAL_GLUCOSE_MG_DL" => AlertType.CriticalGlucoseMgDl,
|
||||||
|
"WARNING_HEART_RATE" => AlertType.WarningHeartRate,
|
||||||
|
"WARNING_TEMP_C" => AlertType.WarningTempC,
|
||||||
|
"WARNING_POTASSIUM_MEQ_L" => AlertType.WarningPotassiumMeqL,
|
||||||
|
"WARNING_SPO2" => AlertType.WarningSpo2,
|
||||||
|
"WARNING_RESP_RATE" => AlertType.WarningRespRate,
|
||||||
|
"WARNING_WBC_K_UL" => AlertType.WarningWbcKUl,
|
||||||
|
"WARNING_SYSTOLIC_BP" => AlertType.WarningSystolicBp,
|
||||||
|
"WARNING_DIASTOLIC_BP" => AlertType.WarningDiastolicBp,
|
||||||
|
"WARNING_LACTATE_MMOL_L" => AlertType.WarningLactateMmolL,
|
||||||
|
"WARNING_GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert type: '{v}'")
|
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert type: '{v}'")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,4 +99,20 @@ public static class AlertTypeExtensions
|
|||||||
_ => throw new ArgumentOutOfRangeException(
|
_ => throw new ArgumentOutOfRangeException(
|
||||||
nameof(observationCode), $"No critical alert type for observation code '{observationCode}'")
|
nameof(observationCode), $"No critical alert type for observation code '{observationCode}'")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public static AlertType WarningFor(string observationCode) => observationCode switch
|
||||||
|
{
|
||||||
|
"HEART_RATE" => AlertType.WarningHeartRate,
|
||||||
|
"TEMP_C" => AlertType.WarningTempC,
|
||||||
|
"POTASSIUM_MEQ_L" => AlertType.WarningPotassiumMeqL,
|
||||||
|
"SPO2" => AlertType.WarningSpo2,
|
||||||
|
"RESP_RATE" => AlertType.WarningRespRate,
|
||||||
|
"WBC_K_UL" => AlertType.WarningWbcKUl,
|
||||||
|
"SYSTOLIC_BP" => AlertType.WarningSystolicBp,
|
||||||
|
"DIASTOLIC_BP" => AlertType.WarningDiastolicBp,
|
||||||
|
"LACTATE_MMOL_L" => AlertType.WarningLactateMmolL,
|
||||||
|
"GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(observationCode), $"No warning alert type for observation code '{observationCode}'")
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+627
@@ -0,0 +1,627 @@
|
|||||||
|
// <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("20260618072515_AddWarningAlertTypes")]
|
||||||
|
partial class AddWarningAlertTypes
|
||||||
|
{
|
||||||
|
/// <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("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<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("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 AddWarningAlertTypes : 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'
|
||||||
|
));
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+631
@@ -0,0 +1,631 @@
|
|||||||
|
// <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("20260618073333_AddOrderResultSummary")]
|
||||||
|
partial class AddOrderResultSummary
|
||||||
|
{
|
||||||
|
/// <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("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("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,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace VigilCareClinicalAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddOrderResultSummary : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "result_summary",
|
||||||
|
table: "orders",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "result_summary",
|
||||||
|
table: "orders");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -346,6 +346,10 @@ namespace VigilCareClinicalAPI.Migrations
|
|||||||
.HasColumnType("character varying(200)")
|
.HasColumnType("character varying(200)")
|
||||||
.HasColumnName("ordered_by");
|
.HasColumnName("ordered_by");
|
||||||
|
|
||||||
|
b.Property<string>("ResultSummary")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("result_summary");
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("ResultedAt")
|
b.Property<DateTimeOffset?>("ResultedAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("resulted_at");
|
.HasColumnName("resulted_at");
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
public record WarningObservationEvent(
|
||||||
|
Guid ObservationId,
|
||||||
|
Guid EncounterId,
|
||||||
|
Guid PatientId,
|
||||||
|
string ObservationCode,
|
||||||
|
decimal Value);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
public record CreateOrderRequest(
|
||||||
|
OrderType OrderType,
|
||||||
|
string Description,
|
||||||
|
string OrderedBy);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
public record RecordOrderResultRequest(string? ResultSummary);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
public record TransitionOrderStatusRequest(OrderStatus Status);
|
||||||
@@ -4,6 +4,10 @@ using Prometheus;
|
|||||||
using Serilog;
|
using Serilog;
|
||||||
using StackExchange.Redis;
|
using StackExchange.Redis;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using FluentValidation;
|
||||||
|
using FluentValidation.AspNetCore;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
Log.Logger = new LoggerConfiguration()
|
Log.Logger = new LoggerConfiguration()
|
||||||
.WriteTo.Console()
|
.WriteTo.Console()
|
||||||
@@ -13,6 +17,9 @@ try
|
|||||||
{
|
{
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddFluentValidationAutoValidation();
|
||||||
|
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||||
|
|
||||||
// Serilog's reloadable logger can only be frozen once per process; skip in
|
// Serilog's reloadable logger can only be frozen once per process; skip in
|
||||||
// integration tests where WebApplicationFactory may build multiple hosts.
|
// integration tests where WebApplicationFactory may build multiple hosts.
|
||||||
if (!builder.Environment.IsEnvironment("Testing"))
|
if (!builder.Environment.IsEnvironment("Testing"))
|
||||||
@@ -64,13 +71,14 @@ try
|
|||||||
builder.Services.AddScoped<IObservationService, ObservationService>();
|
builder.Services.AddScoped<IObservationService, ObservationService>();
|
||||||
builder.Services.AddScoped<IObservationQueryService, ObservationQueryService>();
|
builder.Services.AddScoped<IObservationQueryService, ObservationQueryService>();
|
||||||
builder.Services.AddScoped<IAlertService, AlertService>();
|
builder.Services.AddScoped<IAlertService, AlertService>();
|
||||||
|
builder.Services.AddScoped<IOrderService, OrderService>();
|
||||||
builder.Services.AddScoped<IAnalyticsService, AnalyticsService>();
|
builder.Services.AddScoped<IAnalyticsService, AnalyticsService>();
|
||||||
builder.Services.AddScoped<SirsDetector>();
|
builder.Services.AddScoped<SirsDetector>();
|
||||||
builder.Services.AddScoped<UnacknowledgedAlertsCheck>();
|
builder.Services.AddScoped<UnacknowledgedAlertsCheck>();
|
||||||
builder.Services.AddScoped<PendingOrdersCheck>();
|
builder.Services.AddScoped<PendingOrdersCheck>();
|
||||||
builder.Services.AddScoped<DisconnectedMonitorsCheck>();
|
builder.Services.AddScoped<DisconnectedMonitorsCheck>();
|
||||||
builder.Services.AddScoped<ReconciliationPublisher>();
|
builder.Services.AddScoped<ReconciliationPublisher>();
|
||||||
|
builder.Services.AddScoped<WarningEvaluator>();
|
||||||
|
|
||||||
builder.Services.AddHostedService<ThresholdCacheLoader>();
|
builder.Services.AddHostedService<ThresholdCacheLoader>();
|
||||||
builder.Services.AddHostedService<KafkaTopicProvisioner>();
|
builder.Services.AddHostedService<KafkaTopicProvisioner>();
|
||||||
@@ -88,6 +96,8 @@ try
|
|||||||
builder.Services.AddHostedService<OutboxPendingCollector>();
|
builder.Services.AddHostedService<OutboxPendingCollector>();
|
||||||
builder.Services.AddHostedService<KafkaConsumerLagCollector>();
|
builder.Services.AddHostedService<KafkaConsumerLagCollector>();
|
||||||
builder.Services.AddHostedService<DataLakeWriterService>();
|
builder.Services.AddHostedService<DataLakeWriterService>();
|
||||||
|
builder.Services.AddHostedService<WarningAlertService>();
|
||||||
|
|
||||||
|
|
||||||
builder.Services.AddControllers()
|
builder.Services.AddControllers()
|
||||||
.AddJsonOptions(opts =>
|
.AddJsonOptions(opts =>
|
||||||
@@ -99,7 +109,38 @@ try
|
|||||||
opts.JsonSerializerOptions.Converters.Add(new DepartmentJsonConverter());
|
opts.JsonSerializerOptions.Converters.Add(new DepartmentJsonConverter());
|
||||||
});
|
});
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen(options =>
|
||||||
|
{
|
||||||
|
var xmlPath = Path.Combine(AppContext.BaseDirectory,
|
||||||
|
$"{Assembly.GetExecutingAssembly().GetName().Name}.xml");
|
||||||
|
options.IncludeXmlComments(xmlPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.Configure<ApiBehaviorOptions>(options =>
|
||||||
|
{
|
||||||
|
options.InvalidModelStateResponseFactory = context =>
|
||||||
|
{
|
||||||
|
var errors = context.ModelState
|
||||||
|
.Where(e => e.Value?.Errors.Count > 0)
|
||||||
|
.SelectMany(e => e.Value!.Errors.Select(err => new
|
||||||
|
{
|
||||||
|
field = e.Key,
|
||||||
|
message = err.ErrorMessage
|
||||||
|
}))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var response = ApiResponse<object>.Fail(400,
|
||||||
|
"One or more validation errors occurred.", "VALIDATION_ERROR");
|
||||||
|
|
||||||
|
return new BadRequestObjectResult(new
|
||||||
|
{
|
||||||
|
response.Success,
|
||||||
|
response.StatusCode,
|
||||||
|
data = (object?)null,
|
||||||
|
error = new { message = "One or more validation errors occurred.", code = "VALIDATION_ERROR", details = errors }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
public interface IOrderService
|
||||||
|
{
|
||||||
|
Task<Order> CreateAsync(Guid encounterId, CreateOrderRequest req);
|
||||||
|
Task<PagedResult<Order>> ListByEncounterAsync(Guid encounterId, OrderStatus? status, int page, int pageSize);
|
||||||
|
Task<Order> GetByIdAsync(Guid id);
|
||||||
|
Task<Order> TransitionStatusAsync(Guid id, OrderStatus targetStatus);
|
||||||
|
Task<Order> RecordResultAsync(Guid id, RecordOrderResultRequest req);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
public class OrderService : IOrderService
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<OrderStatus, HashSet<OrderStatus>> _allowedTransitions = new()
|
||||||
|
{
|
||||||
|
[OrderStatus.Pending] = new() { OrderStatus.InProgress, OrderStatus.Cancelled },
|
||||||
|
[OrderStatus.InProgress] = new() { OrderStatus.Resulted, OrderStatus.Cancelled },
|
||||||
|
[OrderStatus.Resulted] = new(),
|
||||||
|
[OrderStatus.Cancelled] = new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public OrderService(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<Order> CreateAsync(Guid encounterId, CreateOrderRequest req)
|
||||||
|
{
|
||||||
|
var encounter = await _db.Encounters.FindAsync(encounterId);
|
||||||
|
if (encounter is null)
|
||||||
|
throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND");
|
||||||
|
|
||||||
|
if (encounter.Status != EncounterStatus.Active)
|
||||||
|
throw new ConflictException(
|
||||||
|
"Cannot create orders for a non-active encounter.",
|
||||||
|
"ENCOUNTER_NOT_ACTIVE");
|
||||||
|
|
||||||
|
var order = new Order
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EncounterId = encounterId,
|
||||||
|
OrderType = req.OrderType,
|
||||||
|
Description = req.Description,
|
||||||
|
OrderedBy = req.OrderedBy,
|
||||||
|
Status = OrderStatus.Pending,
|
||||||
|
OrderedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
_db.Orders.Add(order);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResult<Order>> ListByEncounterAsync(
|
||||||
|
Guid encounterId, OrderStatus? status, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var query = _db.Orders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(o => o.EncounterId == encounterId);
|
||||||
|
|
||||||
|
if (status.HasValue)
|
||||||
|
query = query.Where(o => o.Status == status.Value);
|
||||||
|
|
||||||
|
var total = await query.CountAsync();
|
||||||
|
var orders = await query
|
||||||
|
.OrderByDescending(o => o.OrderedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return new PagedResult<Order>(orders, page, pageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Order> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var order = await _db.Orders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(o => o.Encounter)
|
||||||
|
.FirstOrDefaultAsync(o => o.Id == id);
|
||||||
|
|
||||||
|
if (order is null)
|
||||||
|
throw new NotFoundException("Order not found.", "ORDER_NOT_FOUND");
|
||||||
|
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Order> TransitionStatusAsync(Guid id, OrderStatus targetStatus)
|
||||||
|
{
|
||||||
|
var order = await _db.Orders.FindAsync(id);
|
||||||
|
if (order is null)
|
||||||
|
throw new NotFoundException("Order not found.", "ORDER_NOT_FOUND");
|
||||||
|
|
||||||
|
if (!_allowedTransitions[order.Status].Contains(targetStatus))
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Transition to '{targetStatus}' is not permitted from status '{order.Status}'.",
|
||||||
|
"ILLEGAL_ORDER_STATUS_TRANSITION");
|
||||||
|
|
||||||
|
order.Status = targetStatus;
|
||||||
|
if (targetStatus == OrderStatus.Resulted)
|
||||||
|
order.ResultedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Order> RecordResultAsync(Guid id, RecordOrderResultRequest req)
|
||||||
|
{
|
||||||
|
var order = await _db.Orders.FindAsync(id);
|
||||||
|
if (order is null)
|
||||||
|
throw new NotFoundException("Order not found.", "ORDER_NOT_FOUND");
|
||||||
|
|
||||||
|
if (order.Status == OrderStatus.Resulted)
|
||||||
|
throw new ConflictException("Order already resulted.", "ORDER_ALREADY_RESULTED");
|
||||||
|
|
||||||
|
if (order.Status == OrderStatus.Cancelled)
|
||||||
|
throw new ConflictException("Cannot result a cancelled order.", "ORDER_CANCELLED");
|
||||||
|
|
||||||
|
order.Status = OrderStatus.Resulted;
|
||||||
|
order.ResultedAt = DateTimeOffset.UtcNow;
|
||||||
|
order.ResultSummary = req.ResultSummary;
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
public class WarningEvaluator
|
||||||
|
{
|
||||||
|
private readonly IConnectionMultiplexer _redis;
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<WarningEvaluator> _logger;
|
||||||
|
|
||||||
|
public WarningEvaluator(
|
||||||
|
IConnectionMultiplexer redis,
|
||||||
|
IServiceProvider services,
|
||||||
|
ILogger<WarningEvaluator> logger)
|
||||||
|
{
|
||||||
|
_redis = redis;
|
||||||
|
_services = services;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> EvaluateAsync(
|
||||||
|
Guid observationId,
|
||||||
|
Guid encounterId,
|
||||||
|
Guid patientId,
|
||||||
|
string observationCode,
|
||||||
|
decimal value,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var threshold = await LoadThresholdAsync(observationCode);
|
||||||
|
if (threshold is null) return false;
|
||||||
|
|
||||||
|
if (!IsWarningBreach(value, threshold)) return false;
|
||||||
|
|
||||||
|
// Do not create a warning if the value is also a critical breach —
|
||||||
|
// critical alerts are created synchronously by the ingest path.
|
||||||
|
if (IsCriticalBreach(value, threshold)) return false;
|
||||||
|
|
||||||
|
return await TryCreateWarningAlertAsync(
|
||||||
|
observationId, encounterId, patientId, observationCode, value, threshold, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsWarningBreach(decimal value, ThresholdCacheEntry t) =>
|
||||||
|
(t.WarningHigh.HasValue && value > t.WarningHigh.Value) ||
|
||||||
|
(t.WarningLow.HasValue && value < t.WarningLow.Value);
|
||||||
|
|
||||||
|
private static bool IsCriticalBreach(decimal value, ThresholdCacheEntry t) =>
|
||||||
|
(t.CriticalLow.HasValue && value < t.CriticalLow.Value) ||
|
||||||
|
(t.CriticalHigh.HasValue && value > t.CriticalHigh.Value);
|
||||||
|
|
||||||
|
private async Task<ThresholdCacheEntry?> LoadThresholdAsync(string observationCode)
|
||||||
|
{
|
||||||
|
var cache = _redis.GetDatabase();
|
||||||
|
var cached = await cache.StringGetAsync($"threshold:{observationCode}");
|
||||||
|
if (cached.HasValue)
|
||||||
|
return JsonSerializer.Deserialize<ThresholdCacheEntry>(cached!);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent INSERT: prevents duplicate warning alerts for the same observation.
|
||||||
|
// The WHERE NOT EXISTS checks for an open warning alert of the same type for the
|
||||||
|
// same encounter. Unlike critical alerts (one per encounter), warning alerts are
|
||||||
|
// expected to recur — but not for every single observation in a series. If the
|
||||||
|
// patient's heart rate stays at 105 bpm for an hour, one WARNING_HEART_RATE is
|
||||||
|
// sufficient until acknowledged or resolved.
|
||||||
|
private async Task<bool> TryCreateWarningAlertAsync(
|
||||||
|
Guid observationId,
|
||||||
|
Guid encounterId,
|
||||||
|
Guid patientId,
|
||||||
|
string observationCode,
|
||||||
|
decimal value,
|
||||||
|
ThresholdCacheEntry threshold,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
await using var tx = await db.Database.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
var alertType = AlertTypeExtensions.WarningFor(observationCode);
|
||||||
|
var alertId = Guid.NewGuid();
|
||||||
|
var triggeredAt = DateTimeOffset.UtcNow;
|
||||||
|
var details = BuildWarningDetails(observationCode, value, threshold);
|
||||||
|
|
||||||
|
var affected = await db.Database.ExecuteSqlInterpolatedAsync($"""
|
||||||
|
INSERT INTO clinical_alerts
|
||||||
|
(id, encounter_id, patient_id, observation_id, alert_type, severity, details, status, triggered_at)
|
||||||
|
SELECT {alertId}, {encounterId}, {patientId}, {observationId},
|
||||||
|
{alertType.ToDbString()}, 'WARNING', {details}, 'OPEN', {triggeredAt}
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM clinical_alerts
|
||||||
|
WHERE encounter_id = {encounterId}
|
||||||
|
AND alert_type = {alertType.ToDbString()}
|
||||||
|
AND status IN ('OPEN', 'ACKNOWLEDGED')
|
||||||
|
)
|
||||||
|
""", 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 = "Warning",
|
||||||
|
triggeredAt,
|
||||||
|
partitionKey = encounterId.ToString()
|
||||||
|
}),
|
||||||
|
PartitionKey = encounterId.ToString(),
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
await tx.CommitAsync(ct);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"WARNING alert {AlertId} created for encounter {EncounterId} — {Code}={Value}",
|
||||||
|
alertId, encounterId, observationCode, value);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildWarningDetails(
|
||||||
|
string code, decimal value, ThresholdCacheEntry t)
|
||||||
|
{
|
||||||
|
if (t.WarningHigh.HasValue && value > t.WarningHigh.Value)
|
||||||
|
return $"{code} value {value} is above warning high of {t.WarningHigh}.";
|
||||||
|
return $"{code} value {value} is below warning low of {t.WarningLow}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
public class AcknowledgeAlertRequestValidator : AbstractValidator<AcknowledgeAlertRequest>
|
||||||
|
{
|
||||||
|
public AcknowledgeAlertRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.ClinicianId).NotEmpty().MaximumLength(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
public class AlertThresholdRequestValidator : AbstractValidator<AlertThresholdRequest>
|
||||||
|
{
|
||||||
|
public AlertThresholdRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.ObservationCode).NotEmpty().MaximumLength(50);
|
||||||
|
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(200);
|
||||||
|
RuleFor(x => x.Unit).NotEmpty().MaximumLength(20);
|
||||||
|
|
||||||
|
// Threshold ordering: CriticalLow < WarningLow < WarningHigh < CriticalHigh
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => !x.CriticalLow.HasValue || !x.WarningLow.HasValue || x.CriticalLow < x.WarningLow)
|
||||||
|
.WithMessage("CriticalLow must be less than WarningLow.");
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => !x.WarningLow.HasValue || !x.WarningHigh.HasValue || x.WarningLow < x.WarningHigh)
|
||||||
|
.WithMessage("WarningLow must be less than WarningHigh.");
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => !x.WarningHigh.HasValue || !x.CriticalHigh.HasValue || x.WarningHigh < x.CriticalHigh)
|
||||||
|
.WithMessage("WarningHigh must be less than CriticalHigh.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
|
||||||
|
{
|
||||||
|
public CreateOrderRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Description).NotEmpty();
|
||||||
|
RuleFor(x => x.OrderedBy).NotEmpty().MaximumLength(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
public class IngestObservationRequestValidator : AbstractValidator<IngestObservationRequest>
|
||||||
|
{
|
||||||
|
public IngestObservationRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.ObservationCode).NotEmpty().MaximumLength(50);
|
||||||
|
RuleFor(x => x.Unit).NotEmpty().MaximumLength(20);
|
||||||
|
RuleFor(x => x.RecordedAt)
|
||||||
|
.LessThanOrEqualTo(DateTimeOffset.UtcNow.AddMinutes(5))
|
||||||
|
.WithMessage("RecordedAt cannot be more than 5 minutes in the future.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
public class OpenEncounterRequestValidator : AbstractValidator<OpenEncounterRequest>
|
||||||
|
{
|
||||||
|
public OpenEncounterRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.EncounterType).IsInEnum();
|
||||||
|
RuleFor(x => x.Department).IsInEnum();
|
||||||
|
RuleFor(x => x.AttendingPhysician).NotEmpty().MaximumLength(200);
|
||||||
|
RuleFor(x => x.RoomBed).MaximumLength(20)
|
||||||
|
.When(x => x.RoomBed is not null);
|
||||||
|
RuleFor(x => x.AdmissionReason).MaximumLength(500)
|
||||||
|
.When(x => x.AdmissionReason is not null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
public class RegisterPatientRequestValidator : AbstractValidator<RegisterPatientRequest>
|
||||||
|
{
|
||||||
|
public RegisterPatientRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.FirstName).NotEmpty().MaximumLength(100);
|
||||||
|
RuleFor(x => x.LastName).NotEmpty().MaximumLength(100);
|
||||||
|
RuleFor(x => x.Gender).NotEmpty().MaximumLength(10);
|
||||||
|
RuleFor(x => x.DateOfBirth).NotEmpty()
|
||||||
|
.LessThanOrEqualTo(DateOnly.FromDateTime(DateTime.UtcNow))
|
||||||
|
.WithMessage("Date of birth cannot be in the future.");
|
||||||
|
RuleFor(x => x.BloodType).IsInEnum()
|
||||||
|
.When(x => x.BloodType is not null);
|
||||||
|
RuleFor(x => x.EmergencyContactName).MaximumLength(200)
|
||||||
|
.When(x => x.EmergencyContactName is not null);
|
||||||
|
RuleFor(x => x.EmergencyContactPhone).MaximumLength(20)
|
||||||
|
.When(x => x.EmergencyContactPhone is not null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
public class TransitionOrderStatusRequestValidator : AbstractValidator<TransitionOrderStatusRequest>
|
||||||
|
{
|
||||||
|
public TransitionOrderStatusRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Status).IsInEnum();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Confluent.Kafka" Version="2.14.0" />
|
<PackageReference Include="Confluent.Kafka" Version="2.14.0" />
|
||||||
<PackageReference Include="Elastic.Clients.Elasticsearch" Version="8.13.12" />
|
<PackageReference Include="Elastic.Clients.Elasticsearch" Version="8.13.12" />
|
||||||
|
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|||||||
Reference in New Issue
Block a user