diff --git a/README.md b/README.md index f2a7a3c..e54934f 100644 --- a/README.md +++ b/README.md @@ -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 1–9 implementation and verification guides +├── plans/ # Phase 1–10 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. diff --git a/VigilCareClinicalAPI.Tests/OrderLifecycleTests.cs b/VigilCareClinicalAPI.Tests/OrderLifecycleTests.cs new file mode 100644 index 0000000..f7fdb2e --- /dev/null +++ b/VigilCareClinicalAPI.Tests/OrderLifecycleTests.cs @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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())! + .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); + } +} diff --git a/VigilCareClinicalAPI.Tests/ValidationTests.cs b/VigilCareClinicalAPI.Tests/ValidationTests.cs new file mode 100644 index 0000000..109a6fb --- /dev/null +++ b/VigilCareClinicalAPI.Tests/ValidationTests.cs @@ -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); + } +} diff --git a/VigilCareClinicalAPI.Tests/WarningAlertTests.cs b/VigilCareClinicalAPI.Tests/WarningAlertTests.cs new file mode 100644 index 0000000..00c36d4 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/WarningAlertTests.cs @@ -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(); + 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(); + 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(); + + var created = await evaluator.EvaluateAsync( + Guid.NewGuid(), _encounterId, _patientId, "HEART_RATE", 105m); + + created.Should().BeTrue(); + + var db = scope.ServiceProvider.GetRequiredService(); + 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(); + + var created = await evaluator.EvaluateAsync( + Guid.NewGuid(), _encounterId, _patientId, "HEART_RATE", 78m); + + created.Should().BeFalse(); + + var db = scope.ServiceProvider.GetRequiredService(); + (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(); + + var created = await evaluator.EvaluateAsync( + Guid.NewGuid(), _encounterId, _patientId, "HEART_RATE", 160m); + + created.Should().BeFalse(); + + var db = scope.ServiceProvider.GetRequiredService(); + (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(); + + 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(); + (await db.ClinicalAlerts.CountAsync()).Should().Be(1, + "second warning for same type must be idempotent while first is still open"); + } +} diff --git a/VigilCareClinicalAPI/BackgroundServices/WarningAlertService.cs b/VigilCareClinicalAPI/BackgroundServices/WarningAlertService.cs new file mode 100644 index 0000000..65cfa73 --- /dev/null +++ b/VigilCareClinicalAPI/BackgroundServices/WarningAlertService.cs @@ -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 _logger; + + public WarningAlertService( + IServiceProvider services, + IOptions kafkaOptions, + ILogger 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(config).Build(); + consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded); + + _logger.LogInformation("WarningAlertService started — consumer group: warning-evaluator"); + + try + { + while (!stoppingToken.IsCancellationRequested) + { + ConsumeResult? result = null; + try + { + result = consumer.Consume(stoppingToken); + + var evt = JsonSerializer.Deserialize( + result.Message.Value, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + + using var scope = _services.CreateScope(); + var evaluator = scope.ServiceProvider.GetRequiredService(); + + 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(); + } + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Controllers/OrdersController.cs b/VigilCareClinicalAPI/Controllers/OrdersController.cs new file mode 100644 index 0000000..3ebb969 --- /dev/null +++ b/VigilCareClinicalAPI/Controllers/OrdersController.cs @@ -0,0 +1,101 @@ +using Microsoft.AspNetCore.Mvc; + + +/// +/// Clinical order management: create, list, status transitions, and result recording. +/// +[ApiController] +[Produces("application/json")] +public class OrdersController : ControllerBase +{ + private readonly IOrderService _orders; + + public OrdersController(IOrderService orders) => _orders = orders; + + /// + /// Creates a new clinical order for an encounter. + /// + [HttpPost("api/v1/encounters/{encounterId:guid}/orders")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + public async Task Create(Guid encounterId, [FromBody] CreateOrderRequest req) + { + var order = await _orders.CreateAsync(encounterId, req); + return StatusCode(201, ApiResponse.Created(order)); + } + + /// + /// Lists orders for an encounter with optional status filter. + /// + [HttpGet("api/v1/encounters/{encounterId:guid}/orders")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + public async Task 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.Fail(400, "Invalid status filter.", "INVALID_STATUS")); + } + } + + var result = await _orders.ListByEncounterAsync(encounterId, parsedStatus, page, pageSize); + return Ok(ApiResponse.Ok(new + { + items = result.Items, + page = result.Page, + pageSize = result.PageSize, + totalCount = result.TotalCount, + totalPages = result.TotalPages + })); + } + + /// + /// Gets a single order by id with its encounter. + /// + [HttpGet("api/v1/orders/{id:guid}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task Get(Guid id) + { + var order = await _orders.GetByIdAsync(id); + return Ok(ApiResponse.Ok(order)); + } + + /// + /// Transitions an order to a new status. + /// + [HttpPatch("api/v1/orders/{id:guid}/status")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + public async Task TransitionStatus(Guid id, [FromBody] TransitionOrderStatusRequest req) + { + var order = await _orders.TransitionStatusAsync(id, req.Status); + return Ok(ApiResponse.Ok(order)); + } + + /// + /// Records a result for an order, transitioning it to Resulted status. + /// + [HttpPatch("api/v1/orders/{id:guid}/result")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + public async Task RecordResult(Guid id, [FromBody] RecordOrderResultRequest req) + { + var order = await _orders.RecordResultAsync(id, req); + return Ok(ApiResponse.Ok(order)); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Data/Configurations/OrderConfiguration.cs b/VigilCareClinicalAPI/Data/Configurations/OrderConfiguration.cs index 2b40f0d..a434e2f 100644 --- a/VigilCareClinicalAPI/Data/Configurations/OrderConfiguration.cs +++ b/VigilCareClinicalAPI/Data/Configurations/OrderConfiguration.cs @@ -32,6 +32,7 @@ public class OrderConfiguration : IEntityTypeConfiguration 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"); diff --git a/VigilCareClinicalAPI/Domains/Entities/Order.cs b/VigilCareClinicalAPI/Domains/Entities/Order.cs index 7908897..e364a6a 100644 --- a/VigilCareClinicalAPI/Domains/Entities/Order.cs +++ b/VigilCareClinicalAPI/Domains/Entities/Order.cs @@ -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!; } \ No newline at end of file diff --git a/VigilCareClinicalAPI/Domains/Enums/AlertType.cs b/VigilCareClinicalAPI/Domains/Enums/AlertType.cs index c8ea714..af76124 100644 --- a/VigilCareClinicalAPI/Domains/Enums/AlertType.cs +++ b/VigilCareClinicalAPI/Domains/Enums/AlertType.cs @@ -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}'") + }; } diff --git a/VigilCareClinicalAPI/Migrations/20260618072515_AddWarningAlertTypes.Designer.cs b/VigilCareClinicalAPI/Migrations/20260618072515_AddWarningAlertTypes.Designer.cs new file mode 100644 index 0000000..288525f --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260618072515_AddWarningAlertTypes.Designer.cs @@ -0,0 +1,627 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("CriticalHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_high"); + + b.Property("CriticalLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_low"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("WarningHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_high"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acknowledged_at"); + + b.Property("AcknowledgedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("acknowledged_by"); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alert_type"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("ObservationId") + .HasColumnType("uuid") + .HasColumnName("observation_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'OPEN'"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("AdmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("admitted_at") + .HasDefaultValueSql("NOW()"); + + b.Property("AttendingPhysician") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("attending_physician"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("DischargedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("discharged_at"); + + b.Property("EncounterType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("encounter_type"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("room_bed"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IdempotencyKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("source") + .HasDefaultValueSql("'MANUAL'"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("OrderType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("order_type"); + + b.Property("OrderedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("ordered_at") + .HasDefaultValueSql("NOW()"); + + b.Property("OrderedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("ordered_by"); + + b.Property("ResultedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resulted_at"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("PartitionKey") + .HasMaxLength(36) + .HasColumnType("character varying(36)") + .HasColumnName("partition_key"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Allergies") + .HasColumnType("text") + .HasColumnName("allergies"); + + b.Property("BloodType") + .HasMaxLength(5) + .HasColumnType("character varying(5)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("emergency_contact_name"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("emergency_contact_phone"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("first_name"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("gender"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_name"); + + b.Property("Mrn") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mrn"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("check_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("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 + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/20260618072515_AddWarningAlertTypes.cs b/VigilCareClinicalAPI/Migrations/20260618072515_AddWarningAlertTypes.cs new file mode 100644 index 0000000..9f9595c --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260618072515_AddWarningAlertTypes.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + /// + public partial class AddWarningAlertTypes : Migration + { + /// + 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' + )); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/20260618073333_AddOrderResultSummary.Designer.cs b/VigilCareClinicalAPI/Migrations/20260618073333_AddOrderResultSummary.Designer.cs new file mode 100644 index 0000000..76db610 --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260618073333_AddOrderResultSummary.Designer.cs @@ -0,0 +1,631 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("CriticalHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_high"); + + b.Property("CriticalLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_low"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("WarningHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_high"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acknowledged_at"); + + b.Property("AcknowledgedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("acknowledged_by"); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alert_type"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("ObservationId") + .HasColumnType("uuid") + .HasColumnName("observation_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'OPEN'"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("AdmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("admitted_at") + .HasDefaultValueSql("NOW()"); + + b.Property("AttendingPhysician") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("attending_physician"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("DischargedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("discharged_at"); + + b.Property("EncounterType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("encounter_type"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("room_bed"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IdempotencyKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("source") + .HasDefaultValueSql("'MANUAL'"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("OrderType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("order_type"); + + b.Property("OrderedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("ordered_at") + .HasDefaultValueSql("NOW()"); + + b.Property("OrderedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("ordered_by"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("ResultedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resulted_at"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("PartitionKey") + .HasMaxLength(36) + .HasColumnType("character varying(36)") + .HasColumnName("partition_key"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Allergies") + .HasColumnType("text") + .HasColumnName("allergies"); + + b.Property("BloodType") + .HasMaxLength(5) + .HasColumnType("character varying(5)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("emergency_contact_name"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("emergency_contact_phone"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("first_name"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("gender"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_name"); + + b.Property("Mrn") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mrn"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("check_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("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 + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/20260618073333_AddOrderResultSummary.cs b/VigilCareClinicalAPI/Migrations/20260618073333_AddOrderResultSummary.cs new file mode 100644 index 0000000..ba1e79d --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260618073333_AddOrderResultSummary.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + /// + public partial class AddOrderResultSummary : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "result_summary", + table: "orders", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "result_summary", + table: "orders"); + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs index 4a2f091..170f6b5 100644 --- a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs +++ b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs @@ -346,6 +346,10 @@ namespace VigilCareClinicalAPI.Migrations .HasColumnType("character varying(200)") .HasColumnName("ordered_by"); + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + b.Property("ResultedAt") .HasColumnType("timestamp with time zone") .HasColumnName("resulted_at"); diff --git a/VigilCareClinicalAPI/Models/Records/Alert/WarningObservationEvent.cs b/VigilCareClinicalAPI/Models/Records/Alert/WarningObservationEvent.cs new file mode 100644 index 0000000..849cfd7 --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Alert/WarningObservationEvent.cs @@ -0,0 +1,6 @@ +public record WarningObservationEvent( + Guid ObservationId, + Guid EncounterId, + Guid PatientId, + string ObservationCode, + decimal Value); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Models/Records/Order/CreateOrderRequest.cs b/VigilCareClinicalAPI/Models/Records/Order/CreateOrderRequest.cs new file mode 100644 index 0000000..3a7f850 --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Order/CreateOrderRequest.cs @@ -0,0 +1,4 @@ +public record CreateOrderRequest( + OrderType OrderType, + string Description, + string OrderedBy); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Models/Records/Order/RecordOrderResultRequest.cs b/VigilCareClinicalAPI/Models/Records/Order/RecordOrderResultRequest.cs new file mode 100644 index 0000000..9a4452d --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Order/RecordOrderResultRequest.cs @@ -0,0 +1 @@ +public record RecordOrderResultRequest(string? ResultSummary); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Models/Records/Order/TransitionOrderStatusRequest.cs b/VigilCareClinicalAPI/Models/Records/Order/TransitionOrderStatusRequest.cs new file mode 100644 index 0000000..123859a --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Order/TransitionOrderStatusRequest.cs @@ -0,0 +1 @@ +public record TransitionOrderStatusRequest(OrderStatus Status); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Program.cs b/VigilCareClinicalAPI/Program.cs index a57656b..d339d07 100644 --- a/VigilCareClinicalAPI/Program.cs +++ b/VigilCareClinicalAPI/Program.cs @@ -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(); + // 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(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); - + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); @@ -88,6 +96,8 @@ try builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + 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(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.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(); diff --git a/VigilCareClinicalAPI/Services/Interfaces/IOrderService.cs b/VigilCareClinicalAPI/Services/Interfaces/IOrderService.cs new file mode 100644 index 0000000..ce565eb --- /dev/null +++ b/VigilCareClinicalAPI/Services/Interfaces/IOrderService.cs @@ -0,0 +1,8 @@ +public interface IOrderService +{ + Task CreateAsync(Guid encounterId, CreateOrderRequest req); + Task> ListByEncounterAsync(Guid encounterId, OrderStatus? status, int page, int pageSize); + Task GetByIdAsync(Guid id); + Task TransitionStatusAsync(Guid id, OrderStatus targetStatus); + Task RecordResultAsync(Guid id, RecordOrderResultRequest req); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/OrderService.cs b/VigilCareClinicalAPI/Services/OrderService.cs new file mode 100644 index 0000000..5ca112e --- /dev/null +++ b/VigilCareClinicalAPI/Services/OrderService.cs @@ -0,0 +1,114 @@ +using Microsoft.EntityFrameworkCore; + +public class OrderService : IOrderService +{ + private static readonly Dictionary> _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 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> 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(orders, page, pageSize, total); + } + + public async Task 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 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 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; + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/WarningEvaluator.cs b/VigilCareClinicalAPI/Services/WarningEvaluator.cs new file mode 100644 index 0000000..cbb03d7 --- /dev/null +++ b/VigilCareClinicalAPI/Services/WarningEvaluator.cs @@ -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 _logger; + + public WarningEvaluator( + IConnectionMultiplexer redis, + IServiceProvider services, + ILogger logger) + { + _redis = redis; + _services = services; + _logger = logger; + } + + public async Task 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 LoadThresholdAsync(string observationCode) + { + var cache = _redis.GetDatabase(); + var cached = await cache.StringGetAsync($"threshold:{observationCode}"); + if (cached.HasValue) + return JsonSerializer.Deserialize(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 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(); + + 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}."; + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Validators/AcknowledgeAlertRequestValidator.cs b/VigilCareClinicalAPI/Validators/AcknowledgeAlertRequestValidator.cs new file mode 100644 index 0000000..440fd43 --- /dev/null +++ b/VigilCareClinicalAPI/Validators/AcknowledgeAlertRequestValidator.cs @@ -0,0 +1,9 @@ +using FluentValidation; + +public class AcknowledgeAlertRequestValidator : AbstractValidator +{ + public AcknowledgeAlertRequestValidator() + { + RuleFor(x => x.ClinicianId).NotEmpty().MaximumLength(200); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Validators/AlertThresholdRequestValidator.cs b/VigilCareClinicalAPI/Validators/AlertThresholdRequestValidator.cs new file mode 100644 index 0000000..80a48fe --- /dev/null +++ b/VigilCareClinicalAPI/Validators/AlertThresholdRequestValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; + +public class AlertThresholdRequestValidator : AbstractValidator +{ + 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."); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Validators/CreateOrderRequestValidator.cs b/VigilCareClinicalAPI/Validators/CreateOrderRequestValidator.cs new file mode 100644 index 0000000..0e1b48f --- /dev/null +++ b/VigilCareClinicalAPI/Validators/CreateOrderRequestValidator.cs @@ -0,0 +1,10 @@ +using FluentValidation; + +public class CreateOrderRequestValidator : AbstractValidator +{ + public CreateOrderRequestValidator() + { + RuleFor(x => x.Description).NotEmpty(); + RuleFor(x => x.OrderedBy).NotEmpty().MaximumLength(200); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Validators/IngestObservationRequestValidator.cs b/VigilCareClinicalAPI/Validators/IngestObservationRequestValidator.cs new file mode 100644 index 0000000..eb4bb20 --- /dev/null +++ b/VigilCareClinicalAPI/Validators/IngestObservationRequestValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +public class IngestObservationRequestValidator : AbstractValidator +{ + 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."); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Validators/OpenEncounterRequestValidator.cs b/VigilCareClinicalAPI/Validators/OpenEncounterRequestValidator.cs new file mode 100644 index 0000000..2db5ab0 --- /dev/null +++ b/VigilCareClinicalAPI/Validators/OpenEncounterRequestValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +public class OpenEncounterRequestValidator : AbstractValidator +{ + 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); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Validators/RegisterPatientRequestValidator.cs b/VigilCareClinicalAPI/Validators/RegisterPatientRequestValidator.cs new file mode 100644 index 0000000..2459946 --- /dev/null +++ b/VigilCareClinicalAPI/Validators/RegisterPatientRequestValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; + +public class RegisterPatientRequestValidator : AbstractValidator +{ + 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); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Validators/TransitionOrderStatusRequestValidator.cs b/VigilCareClinicalAPI/Validators/TransitionOrderStatusRequestValidator.cs new file mode 100644 index 0000000..ee9a1fd --- /dev/null +++ b/VigilCareClinicalAPI/Validators/TransitionOrderStatusRequestValidator.cs @@ -0,0 +1,9 @@ +using FluentValidation; + +public class TransitionOrderStatusRequestValidator : AbstractValidator +{ + public TransitionOrderStatusRequestValidator() + { + RuleFor(x => x.Status).IsInEnum(); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj b/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj index d6ac878..e82390a 100644 --- a/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj +++ b/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj @@ -11,6 +11,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive