feature: Warning Alert Consumer, Orders API & Input Validation

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