diff --git a/README.md b/README.md index c7667c3..f2a7a3c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ 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. + ## Domain Model — How It Maps to a Real Clinical System In a hospital, a patient presents for care and an encounter is opened. Bedside monitors and lab systems post observations continuously against that encounter. A rules engine evaluates each observation against configured thresholds and flags abnormal values as clinical alerts. Clinicians acknowledge and resolve alerts. If a critical alert goes unacknowledged for five minutes, the system escalates to the on-call backup. All events flow through Kafka so the Elasticsearch dashboard, sepsis engine, and data lake writer consume the same stream independently. @@ -58,7 +60,7 @@ An `OutboxEvent` is written in the same transaction as any observation or alert, - **Data Lake Writer** — `DataLakeWriterService` (consumer group `data-lake-writer`) buffers `observation.recorded`, `alert.generated`, and `encounter.status.changed` events, flushes date-partitioned Parquet files to MinIO (`/observations/`, `/alerts/`, `/encounters/`), and commits Kafka offsets only after successful uploads; `kafka_partition` and `kafka_offset` columns provide audit lineage - **Reconciliation Jobs** — three scheduled checks: (1) unacknowledged CRITICAL alerts older than 30 minutes, (2) pending orders without results after 4 hours, (3) active inpatients with no observation in 2 hours; each finding creates a `reconciliation_alerts` row and publishes to RabbitMQ - **Standard Envelope** — all responses use a consistent `{ success, statusCode, data, error }` wrapper; validation errors use the same shape; `ApiBehaviorOptions` overridden so model validation also produces the standard envelope -- **Observability** — Serilog structured logging enriched with `correlationId`, `encounterId`, `patientId` on alert paths; Seq sink (`http://localhost:5345`); Prometheus (`http://localhost:9101`) scrapes `GET /metrics`; Grafana dashboards (`http://localhost:3101`, admin/admin) for clinical metrics including `alerts_unacknowledged_gauge`; per-request correlation IDs in request logs and response headers +- **Observability** — Serilog structured logging enriched with `correlationId`, `encounterId`, `patientId` on alert paths; Seq sink (`http://localhost:5345`); Prometheus (`http://localhost:9101`) scrapes `GET /metrics`; eight application metric families via `ClinicalMetrics` and three background collectors (`AlertsUnacknowledgedCollector`, `OutboxPendingCollector`, `KafkaConsumerLagCollector`); Grafana clinical dashboard (`http://localhost:3101`, admin/admin) with `alerts_unacknowledged_gauge` as the primary safety panel; per-request correlation IDs in request logs and `X-Correlation-Id` response headers - **Swagger UI** — OpenAPI spec via Swashbuckle (Development only) --- @@ -89,6 +91,10 @@ IHostedServices (background): DischargeSummaryWorkerService → RabbitMQ discharge.queue → MinIO PDF DataLakeWriterService → Kafka (data-lake-writer) → Parquet files in MinIO ReconciliationScheduler → three scheduled safety checks → reconciliation_alerts + RabbitMQ + AlertsUnacknowledgedCollector → polls PostgreSQL every 30s → alerts_unacknowledged_gauge + OutboxPendingCollector → polls outbox every 30s → outbox_pending_events + KafkaConsumerLagCollector → polls four consumer groups every 30s → kafka_consumer_lag + ClinicalMetrics (singleton) → inline counters/histogram from ingest, SIRS, escalation paths ``` **Why Kafka and RabbitMQ coexist:** Kafka is an append-only log — the same observation event reaches the Elasticsearch indexer, the sepsis engine, and the data lake independently without coordination. Each consumer holds its own offset and can replay from the beginning. RabbitMQ handles the action side: one message, one worker, one page. A duplicate page at 3am is a patient safety concern, not a minor inconvenience — RabbitMQ's acknowledgment-then-delete model is correct here. The DLQ TTL-based escalation has no equivalent in Kafka. @@ -107,9 +113,11 @@ IHostedServices (background): | Search / analytics | Elasticsearch 8.13 (CQRS read projection) | | Data lake | MinIO (Parquet, S3-compatible) | | Logging | Serilog + Seq sink | -| Metrics / dashboards | Prometheus 2.52 + Grafana 10.4 | +| Metrics | prometheus-net.AspNetCore (`GET /metrics`) | +| Dashboards | Prometheus 2.52 + Grafana 10.4 | +| Data lake format | Parquet.Net 4.x | | Docs | Swagger / OpenAPI (Swashbuckle) | -| Testing | xUnit + Testcontainers | +| Testing | xUnit + Testcontainers + WebApplicationFactory | --- @@ -144,19 +152,30 @@ VigilCareClinicalAPI/ │ ├── AlertType.cs # ThresholdBreach, SepsisWarning, … │ ├── ObservationSource.cs # Device, Manual, Lab │ └── OrderType.cs / ReconciliationCheckType.cs / Department.cs / OrderStatus.cs +│ └── Json/ +│ ├── ObservationSourceJsonConverter.cs +│ └── DepartmentJsonConverter.cs ├── Services/ +│ ├── Interfaces/ # IPatientService, IEncounterService, … │ ├── PatientService.cs │ ├── EncounterService.cs # Status state machine + ConflictException on invalid transitions │ ├── AlertThresholdService.cs # CRUD + Redis write-through invalidation -│ ├── ObservationService.cs # Ingest transaction: idempotency → plausibility → threshold → alert → outbox +│ ├── ObservationService.cs # Ingest transaction: idempotency → plausibility → threshold → alert → outbox; emits Prometheus counters │ ├── ObservationQueryService.cs # Cursor-paginated history │ ├── AlertService.cs # Acknowledge, resolve, list │ ├── AnalyticsService.cs # Elasticsearch query wrappers │ └── PlausibilityValidator.cs # Per-code numeric range guard +├── Observability/ +│ └── Metrics/ +│ └── ClinicalMetrics.cs # Eight Prometheus metric families (counters, histogram, gauges) ├── BackgroundServices/ │ ├── ThresholdCacheLoader.cs # Pre-loads all thresholds into Redis on startup │ ├── KafkaTopicProvisioner.cs # Creates topics with NumPartitions from config │ ├── OutboxRelayService.cs # Polls outbox every 500ms; publishes to Kafka; marks processed +│ ├── Metrics/ +│ │ ├── AlertsUnacknowledgedCollector.cs # Polls open CRITICAL alerts > 5 min → alerts_unacknowledged_gauge +│ │ ├── OutboxPendingCollector.cs # Polls unprocessed outbox rows → outbox_pending_events +│ │ └── KafkaConsumerLagCollector.cs # Lag for es-indexer, sepsis-engine, notification-publisher, data-lake-writer │ ├── ElasticsSearch/ │ │ ├── ElasticIndexProvisioner.cs # Creates patient_encounters, observations, clinical_alerts indices │ │ └── EsIndexerService.cs # consumer group: es-indexer; upserts Elasticsearch documents @@ -172,6 +191,10 @@ VigilCareClinicalAPI/ │ ├── PendingOrdersCheck.cs # Pending orders without results > 4 hours │ ├── DisconnectedMonitorsCheck.cs # Active inpatients with no observation > 2 hours │ └── ReconciliationPublisher.cs # Publishes findings to notifications.reconciliation.queue +├── Configuration/ +│ ├── KafkaOptions.cs / KafkaTopicOptions.cs +│ ├── RabbitMqOptions.cs / MinioOptions.cs +│ └── ReconciliationJobOptions.cs ├── Sepsis/ │ ├── SirsDetector.cs # Redis SIRS state management (SET/DEL/MGET) │ └── SirsEvaluator.cs # Per-code criterion evaluation @@ -193,7 +216,7 @@ VigilCareClinicalAPI/ │ └── Encounter/EncounterStatusRow.cs # Parquet row contract for encounter status events ├── Data/ │ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints -│ ├── Configurations/ # IEntityTypeConfiguration per entity +│ ├── Configurations/ # IEntityTypeConfiguration per entity; ElasticsearchOptions, ElasticIndexOptions │ └── Seed/DataSeeder.cs # Seeds patients, encounters, thresholds, observations ├── Common/ │ ├── ApiResponse.cs # { success, statusCode, data, error } envelope @@ -227,12 +250,22 @@ tests/ └── DataLakePhase9Tests.cs # Kafka → MinIO Parquet flow and schema checks scripts/ -├── run-phase8-verification.sh # Prometheus + alerts_unacknowledged_gauge checks -└── run-phase9-verification.sh # Data lake integration tests + Kafka + MinIO + DuckDB +├── run-api-redis-tests.sh # Phase 1 — patient/encounter/threshold + Redis cache +├── run-kafka-outbox-tests.sh # Phase 3 — outbox relay and Kafka topics +├── run-elasticsearch-analytics-tests.sh # Phase 4 — Elasticsearch CQRS projection +├── run-sepsis-sirs-tests.sh # Phase 5 — SIRS detector and sepsis engine +├── 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 docs/ -├── plans/phase-9-plan.md # Phase 9 implementation and verification guide -└── decisions/data-lake-design.md # Parquet vs JSON, partitioning, replay rationale +├── plans/ # Phase 1–9 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 +├── docker-compose-usage-and-troubleshooting.md +└── vigilcare-clinical-api-prd.md # Product requirements and phase roadmap ``` --- @@ -345,10 +378,10 @@ On startup the application: 3. Pre-loads all thresholds into Redis 4. Provisions Kafka topics and Elasticsearch indices 5. Declares the RabbitMQ exchange and queue topology -6. Starts the data lake writer (`data-lake-writer` → Parquet in MinIO) -7. Starts the reconciliation scheduler (three safety checks on a configurable interval) +6. Starts all background consumers (outbox relay, ES indexer, sepsis engine, notification workers, data lake writer, reconciliation scheduler) +7. Starts Prometheus metric collectors (unacknowledged alerts, outbox pending, Kafka consumer lag) -Swagger UI is available at `http://localhost:/swagger` in Development. +Swagger UI is available at `http://localhost:5270/swagger` in Development (API binds to `0.0.0.0:5270` per `launchSettings.json`). ### Run Tests @@ -356,15 +389,36 @@ Swagger UI is available at `http://localhost:/swagger` in Development. dotnet test ``` -Tests use Testcontainers to spin up a real PostgreSQL instance. No manual setup required. +Integration tests use `WebApplicationFactory` with a `Testing` environment and Testcontainers where needed (PostgreSQL, Redis, Kafka, RabbitMQ, Elasticsearch, MinIO). No manual infrastructure setup is required for `dotnet test`. + +| Test class | Phase | Coverage | +|---|---|---| +| `ObservationIngestTests` | 2 | Ingest happy path, critical alert creation, discharged encounter rejection, idempotency | +| `AlertLifecycleTests` | 2 | Acknowledge, resolve, escalation guard | +| `SirsDetectorTests` / `SirsEvaluatorTests` | 5 | Redis SIRS state and per-code criterion evaluation | +| `NotificationPipelineTests` | 6 | RabbitMQ topology, DLQ routing, paging | +| `ReconciliationTests` | 7 | Three reconciliation checks, deduplication, RabbitMQ publish | +| `ObservabilityPhase8Tests` | 8 | All eight `/metrics` families, correlation headers, ingest counter increment | +| `DataLakePhase9Tests` | 9 | Kafka → MinIO Parquet flow and schema checks | ### Verification Scripts -With the API running and Docker Compose up, run phase verification end-to-end: +With the API running (`dotnet run`) and Docker Compose up: ```bash -./scripts/run-phase8-verification.sh # Prometheus metrics, alerts_unacknowledged_gauge -./scripts/run-phase9-verification.sh # Data lake tests, Kafka offsets, MinIO Parquet, DuckDB schema +./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 +``` + +Per-phase test runners (subset of `dotnet test`): + +```bash +./scripts/run-api-redis-tests.sh +./scripts/run-kafka-outbox-tests.sh +./scripts/run-elasticsearch-analytics-tests.sh +./scripts/run-sepsis-sirs-tests.sh +./scripts/run-notification-pipeline-tests.sh +./scripts/run-reconciliation-tests.sh ``` Phase 9 optional tools (install without sudo): @@ -380,7 +434,26 @@ curl https://install.duckdb.org | sh export PATH="$HOME/.duckdb/cli/latest:$HOME/.local/bin:$PATH" ``` -See `docs/plans/phase-9-plan.md` for manual Kafka replay and DuckDB query examples. +See `docs/plans/phase-8-plan.md` and `docs/plans/phase-9-plan.md` for manual Grafana, Seq, Kafka replay, and DuckDB query examples. + +--- + +## Prometheus Metrics + +`GET /metrics` exposes eight application metric families registered in `ClinicalMetrics`. Three background collectors poll PostgreSQL and Kafka every 30 seconds; counters and the ingest histogram are updated inline during request handling. + +| Metric | Type | Labels | Source | +|---|---|---|---| +| `observations_ingested_total` | Counter | `observation_code`, `source` | `ObservationService` on each committed observation | +| `observation_ingest_duration_seconds` | Histogram | — | `ObservationService` — full ingest transaction to COMMIT | +| `clinical_alerts_total` | Counter | `alert_type`, `severity` | `ObservationService` (threshold breach), `SirsDetector` (sepsis) | +| `sirs_detections_total` | Counter | — | `SirsDetector` — only on successful idempotent insert | +| `escalations_total` | Counter | — | `EscalationWorkerService` on DLQ escalation | +| `alerts_unacknowledged_gauge` | Gauge | — | `AlertsUnacknowledgedCollector` — open CRITICAL alerts older than 5 minutes | +| `outbox_pending_events` | Gauge | — | `OutboxPendingCollector` — unprocessed outbox rows | +| `kafka_consumer_lag` | Gauge | `consumer_group` | `KafkaConsumerLagCollector` — `es-indexer`, `sepsis-engine`, `notification-publisher`, `data-lake-writer` | + +Prometheus scrapes the API via `infra/prometheus/prometheus.yml` (`job: vigilcare_api` → `host.docker.internal:5270`). Grafana loads the clinical dashboard from `infra/grafana/dashboards/vigilcare.json`. --- @@ -824,6 +897,30 @@ The indices rebuild from the full Kafka history. Document count should match Pos --- +## Data Lake Replay + +The MinIO Parquet archive is a pure Kafka projection — rebuildable without touching PostgreSQL. See `docs/plans/phase-9-plan.md` for the full procedure. Summary: + +```bash +# Reset data-lake-writer offsets to earliest +docker compose exec -T kafka /opt/kafka/bin/kafka-consumer-groups.sh \ + --bootstrap-server localhost:9092 \ + --group data-lake-writer \ + --reset-offsets --to-earliest --all-topics --execute + +# Clear Parquet prefixes in MinIO (mc alias localvc http://localhost:9005 minioadmin minioadmin) +mc rm --recursive --force localvc/vigilcare/observations/ +mc rm --recursive --force localvc/vigilcare/alerts/ +mc rm --recursive --force localvc/vigilcare/encounters/ + +# Restart the API — DataLakeWriterService replays from offset 0 +dotnet run +``` + +Verify with `./scripts/run-phase9-verification.sh`. + +--- + ## Pagination List endpoints use offset pagination: @@ -851,6 +948,8 @@ 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. + | Phase | Feature | Status | |---|---|---| | 1 | Schema, EF Core migrations, patient/encounter CRUD, alert threshold CRUD, encounter status machine, Redis threshold pre-load, seed data | Done | @@ -860,5 +959,7 @@ Observation history uses cursor pagination on `(recorded_at DESC, id DESC)`. Off | 5 | Sepsis detection engine (`SepsisEngineService`); Redis SIRS state with 30-min TTL; idempotent alert creation; integration tests | Done | | 6 | RabbitMQ exchange and queue topology; `NotificationPublisherService`; `PagingWorkerService`; DLQ escalation (`EscalationWorkerService`); discharge summary (`DischargeSummaryWorkerService` → MinIO); integration tests | Done | | 7 | Reconciliation scheduler — unacknowledged critical alerts, stale pending orders, disconnected monitors; `reconciliation_alerts` table; RabbitMQ publish; integration tests | Done | -| 8 | Prometheus metrics (`GET /metrics`); Grafana dashboards; eight application metric families | In progress | -| 9 | Data lake writer — `data-lake-writer` consumer group; Parquet flush to MinIO; `DataLakePhase9Tests`; `run-phase9-verification.sh` | Done | +| 8 | Prometheus metrics (`GET /metrics`); eight metric families and three collectors; Grafana clinical dashboard; `ObservabilityPhase8Tests`; `run-phase8-verification.sh` | Done | +| 9 | Data lake writer — `data-lake-writer` consumer group; date-partitioned Parquet flush to MinIO; `DataLakePhase9Tests`; `run-phase9-verification.sh`; design doc in `docs/decisions/data-lake-design.md` | Done | + +**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/ClinicalDemographicsAndObservationTests.cs b/VigilCareClinicalAPI.Tests/ClinicalDemographicsAndObservationTests.cs new file mode 100644 index 0000000..153b233 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/ClinicalDemographicsAndObservationTests.cs @@ -0,0 +1,221 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; + +[Collection("Integration")] +public class ClinicalDemographicsAndObservationTests : IAsyncLifetime +{ + private readonly ApiFixture _fixture; + private readonly HttpClient _client; + private Guid _patientId; + private Guid _encounterId; + + public ClinicalDemographicsAndObservationTests(ApiFixture fixture) + { + _fixture = fixture; + _client = fixture.CreateClient(); + } + + public async Task InitializeAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + + var patient = new Patient + { + Id = Guid.NewGuid(), Mrn = "MRN-P10-001", FirstName = "Phase10", LastName = "Test", + DateOfBirth = new DateOnly(1965, 3, 15), Gender = "F", + BloodType = BloodType.BPositive, Allergies = "Sulfa", + EmergencyContactName = "Test Contact", EmergencyContactPhone = "555-9999", + CreatedAt = DateTimeOffset.UtcNow + }; + var encounter = new Encounter + { + Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient, + Status = EncounterStatus.Active, Department = Department.Icu, + AttendingPhysician = "Dr. Phase10", + RoomBed = "ICU-7A", AdmissionReason = "Sepsis workup", + AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow + }; + db.Patients.Add(patient); + db.Encounters.Add(encounter); + + db.AlertThresholds.AddRange( + new AlertThreshold { Id = Guid.NewGuid(), ObservationCode = "HEART_RATE", + DisplayName = "Heart Rate", Unit = "bpm", + CriticalLow = 30, WarningLow = 50, WarningHigh = 100, CriticalHigh = 150, + CreatedAt = DateTimeOffset.UtcNow }, + new AlertThreshold { Id = Guid.NewGuid(), ObservationCode = "SYSTOLIC_BP", + DisplayName = "Systolic BP", Unit = "mmHg", + CriticalLow = 70, WarningLow = 90, WarningHigh = 160, CriticalHigh = 180, + CreatedAt = DateTimeOffset.UtcNow }, + new AlertThreshold { Id = Guid.NewGuid(), ObservationCode = "DIASTOLIC_BP", + DisplayName = "Diastolic BP", Unit = "mmHg", + CriticalLow = 40, WarningLow = 60, WarningHigh = 90, CriticalHigh = 110, + CreatedAt = DateTimeOffset.UtcNow }, + new AlertThreshold { Id = Guid.NewGuid(), ObservationCode = "LACTATE_MMOL_L", + DisplayName = "Serum Lactate", Unit = "mmol/L", + CriticalLow = null, WarningLow = null, WarningHigh = 2.0m, CriticalHigh = 4.0m, + CreatedAt = DateTimeOffset.UtcNow }, + new AlertThreshold { Id = Guid.NewGuid(), ObservationCode = "AVPU", + DisplayName = "AVPU Consciousness", Unit = "score", + CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = 2m, + CreatedAt = DateTimeOffset.UtcNow }, + new AlertThreshold { Id = Guid.NewGuid(), ObservationCode = "SUPPLEMENTAL_O2", + DisplayName = "Supplemental O2", Unit = "flag", + CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null, + CreatedAt = DateTimeOffset.UtcNow }, + new AlertThreshold { Id = Guid.NewGuid(), ObservationCode = "GLUCOSE_MG_DL", + DisplayName = "Blood Glucose", Unit = "mg/dL", + CriticalLow = 40, WarningLow = 70, WarningHigh = 180, CriticalHigh = 400, + CreatedAt = DateTimeOffset.UtcNow } + ); + await db.SaveChangesAsync(); + + var redis = scope.ServiceProvider.GetRequiredService(); + var cache = redis.GetDatabase(1); + foreach (var t in await db.AlertThresholds.ToListAsync()) + { + var json = JsonSerializer.Serialize(new + { + t.ObservationCode, t.CriticalLow, t.WarningLow, t.WarningHigh, t.CriticalHigh + }); + await cache.StringSetAsync($"threshold:{t.ObservationCode}", json); + } + + _patientId = patient.Id; + _encounterId = encounter.Id; + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task CriticalSystolicBp_AlertCreated() + { + var resp = await _client.PostAsJsonAsync( + $"/api/v1/encounters/{_encounterId}/observations", + new BatchIngestRequest(new List + { + new("SYSTOLIC_BP", 65, "mmHg", ObservationSource.Device, DateTimeOffset.UtcNow, null) + })); + + resp.StatusCode.Should().Be(HttpStatusCode.Created); + var body = await resp.Content.ReadFromJsonAsync(); + body!.RootElement.GetProperty("data").GetProperty("alertGenerated").GetBoolean() + .Should().BeTrue(); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var alert = await db.ClinicalAlerts.SingleAsync(); + alert.AlertType.Should().Be(AlertType.CriticalSystolicBp); + alert.Severity.Should().Be(AlertSeverity.Critical); + } + + [Fact] + public async Task AvpuUnresponsive_CriticalAlert() + { + var resp = await _client.PostAsJsonAsync( + $"/api/v1/encounters/{_encounterId}/observations", + new BatchIngestRequest(new List + { + new("AVPU", 3, "score", ObservationSource.Manual, DateTimeOffset.UtcNow, null) + })); + + resp.StatusCode.Should().Be(HttpStatusCode.Created); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var alert = await db.ClinicalAlerts.SingleAsync(); + alert.AlertType.Should().Be(AlertType.CriticalAvpu); + } + + [Fact] + public async Task SupplementalO2_NoAlert() + { + var resp = await _client.PostAsJsonAsync( + $"/api/v1/encounters/{_encounterId}/observations", + new BatchIngestRequest(new List + { + new("SUPPLEMENTAL_O2", 1, "flag", ObservationSource.Manual, DateTimeOffset.UtcNow, null) + })); + + resp.StatusCode.Should().Be(HttpStatusCode.Created); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + (await db.ClinicalAlerts.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task CriticalGlucose_AlertCreated() + { + var resp = await _client.PostAsJsonAsync( + $"/api/v1/encounters/{_encounterId}/observations", + new BatchIngestRequest(new List + { + new("GLUCOSE_MG_DL", 30, "mg/dL", ObservationSource.Lab, DateTimeOffset.UtcNow, null) + })); + + resp.StatusCode.Should().Be(HttpStatusCode.Created); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var alert = await db.ClinicalAlerts.SingleAsync(); + alert.AlertType.Should().Be(AlertType.CriticalGlucoseMgDl); + } + + [Fact] + public async Task PatientRegistration_ClinicalFields_RoundTrip() + { + var resp = await _client.PostAsJsonAsync("/api/v1/patients", new + { + firstName = "Demo", + lastName = "Patient", + dateOfBirth = "1990-06-15", + gender = "M", + bloodType = "AB-", + allergies = "Latex, Iodine", + emergencyContactName = "Demo Contact", + emergencyContactPhone = "555-1234" + }); + + resp.StatusCode.Should().Be(HttpStatusCode.Created); + var body = await resp.Content.ReadFromJsonAsync(); + var data = body!.RootElement.GetProperty("data"); + data.GetProperty("bloodType").GetString().Should().Be("AB-"); + data.GetProperty("allergies").GetString().Should().Be("Latex, Iodine"); + data.GetProperty("emergencyContactName").GetString().Should().Be("Demo Contact"); + } + + [Fact] + public async Task OpenEncounter_WithRoomBed_Success() + { + var patientResp = await _client.PostAsJsonAsync("/api/v1/patients", new + { + firstName = "Room", lastName = "Test", + dateOfBirth = "1985-01-01", gender = "F" + }); + var patientBody = await patientResp.Content.ReadFromJsonAsync(); + var newPatientId = patientBody!.RootElement.GetProperty("data").GetProperty("id").GetGuid(); + + var resp = await _client.PostAsJsonAsync($"/api/v1/patients/{newPatientId}/encounters", new + { + encounterType = "INPATIENT", + department = "ICU", + attendingPhysician = "Dr. Room", + roomBed = "ICU-3C", + admissionReason = "Acute respiratory distress" + }); + + resp.StatusCode.Should().Be(HttpStatusCode.Created); + var body = await resp.Content.ReadFromJsonAsync(); + var data = body!.RootElement.GetProperty("data"); + data.GetProperty("roomBed").GetString().Should().Be("ICU-3C"); + data.GetProperty("admissionReason").GetString().Should().Be("Acute respiratory distress"); + } +} diff --git a/VigilCareClinicalAPI.Tests/DataLakePhase9Tests.cs b/VigilCareClinicalAPI.Tests/DataLakePhase9Tests.cs index 899abee..c4f052c 100644 --- a/VigilCareClinicalAPI.Tests/DataLakePhase9Tests.cs +++ b/VigilCareClinicalAPI.Tests/DataLakePhase9Tests.cs @@ -7,7 +7,8 @@ using Minio.DataModel.Args; using Parquet; using StackExchange.Redis; -public class DataLakePhase9Tests : IClassFixture +[Collection("Integration")] +public class DataLakePhase9Tests { private readonly ApiFixture _fixture; private readonly HttpClient _http; @@ -208,7 +209,6 @@ public class DataLakePhase9Tests : IClassFixture await EnsureThresholdAsync(db, "HEART_RATE", "Heart Rate", "bpm", 30, 50, 100, 150); await EnsureThresholdAsync(db, "POTASSIUM_MEQ_L", "Serum Potassium", "mEq/L", 2.5m, 3.5m, 5.0m, 6.5m); await EnsureThresholdAsync(db, "TEMP_C", "Temperature", "°C", 34m, 36m, 37.8m, 40m); - await db.SaveChangesAsync(); var redis = scope.ServiceProvider.GetRequiredService(); var cache = redis.GetDatabase(1); @@ -245,5 +245,15 @@ public class DataLakePhase9Tests : IClassFixture CriticalHigh = criticalHigh, CreatedAt = DateTimeOffset.UtcNow }); + + try + { + await db.SaveChangesAsync(); + } + catch (DbUpdateException ex) when ( + ex.InnerException is Npgsql.PostgresException { SqlState: "23505" }) + { + // Another test inserted the same observation code concurrently. + } } } diff --git a/VigilCareClinicalAPI.Tests/ObservabilityPhase8Tests.cs b/VigilCareClinicalAPI.Tests/ObservabilityPhase8Tests.cs index ea10e08..3efee2a 100644 --- a/VigilCareClinicalAPI.Tests/ObservabilityPhase8Tests.cs +++ b/VigilCareClinicalAPI.Tests/ObservabilityPhase8Tests.cs @@ -4,7 +4,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using StackExchange.Redis; -public class ObservabilityPhase8Tests : IClassFixture +[Collection("Integration")] +public class ObservabilityPhase8Tests { private static readonly string[] ExpectedMetrics = { diff --git a/VigilCareClinicalAPI/BackgroundServices/ElasticsSearch/ElasticIndexProvisioner.cs b/VigilCareClinicalAPI/BackgroundServices/ElasticsSearch/ElasticIndexProvisioner.cs index e15b0b6..84ee3f9 100644 --- a/VigilCareClinicalAPI/BackgroundServices/ElasticsSearch/ElasticIndexProvisioner.cs +++ b/VigilCareClinicalAPI/BackgroundServices/ElasticsSearch/ElasticIndexProvisioner.cs @@ -58,6 +58,8 @@ public class ElasticIndexProvisioner : IHostedService .Keyword(k => k.Department) .Keyword(k => k.Status) .Keyword(k => k.AttendingPhysician) + .Keyword(k => k.RoomBed) + .Text(t => t.AdmissionReason) .Date(d => d.AdmittedAt) .IntegerNumber(i => i.OpenAlertCount) .Date(d => d.LastObservationAt!) diff --git a/VigilCareClinicalAPI/BackgroundServices/ElasticsSearch/EsIndexerService.cs b/VigilCareClinicalAPI/BackgroundServices/ElasticsSearch/EsIndexerService.cs index 4a51526..9d1fa84 100644 --- a/VigilCareClinicalAPI/BackgroundServices/ElasticsSearch/EsIndexerService.cs +++ b/VigilCareClinicalAPI/BackgroundServices/ElasticsSearch/EsIndexerService.cs @@ -111,7 +111,9 @@ public class EsIndexerService : BackgroundService Department = evt.Department, Status = evt.NewStatus, AttendingPhysician = evt.AttendingPhysician, - AdmittedAt = evt.AdmittedAt, + RoomBed = evt.RoomBed, + AdmissionReason = evt.AdmissionReason, + AdmittedAt = evt.AdmittedAt, OpenAlertCount = 0, LastObservationAt = null }; diff --git a/VigilCareClinicalAPI/Controllers/EncountersController.cs b/VigilCareClinicalAPI/Controllers/EncountersController.cs index 4c78a1a..d9ec91a 100644 --- a/VigilCareClinicalAPI/Controllers/EncountersController.cs +++ b/VigilCareClinicalAPI/Controllers/EncountersController.cs @@ -39,8 +39,8 @@ public class EncountersController : ControllerBase [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] public async Task TransitionStatus(Guid id, [FromBody] TransitionStatusRequest req) { - var result = await _encounters.TransitionStatusAsync(id, req.Status); - return Ok(ApiResponse.Ok(new { encounterId = result.EncounterId, newStatus = result.NewStatus })); + var result = await _encounters.TransitionStatusAsync(id, req.Status, req.DischargeDiagnosis); + return Ok(ApiResponse.Ok(new { encounterId = result.EncounterId, newStatus = result.NewStatus, dischargeDiagnosis = req.DischargeDiagnosis })); } /// diff --git a/VigilCareClinicalAPI/Data/Configurations/EncounterConfiguration.cs b/VigilCareClinicalAPI/Data/Configurations/EncounterConfiguration.cs index a695880..494ea2e 100644 --- a/VigilCareClinicalAPI/Data/Configurations/EncounterConfiguration.cs +++ b/VigilCareClinicalAPI/Data/Configurations/EncounterConfiguration.cs @@ -39,6 +39,9 @@ public class EncounterConfiguration : IEntityTypeConfiguration v => v.ToDbString(), v => DepartmentExtensions.FromDbString(v)) .IsRequired(); + builder.Property(e => e.RoomBed).HasColumnName("room_bed").HasMaxLength(20); + builder.Property(e => e.AdmissionReason).HasColumnName("admission_reason").HasMaxLength(500); + builder.Property(e => e.DischargeDiagnosis).HasColumnName("discharge_diagnosis").HasMaxLength(500); builder.Property(e => e.AttendingPhysician).HasColumnName("attending_physician").HasMaxLength(200).IsRequired(); builder.Property(e => e.AdmittedAt).HasColumnName("admitted_at").HasDefaultValueSql("NOW()"); builder.Property(e => e.DischargedAt).HasColumnName("discharged_at"); diff --git a/VigilCareClinicalAPI/Data/Configurations/PatientConfiguration.cs b/VigilCareClinicalAPI/Data/Configurations/PatientConfiguration.cs index 88e05c9..7e0e561 100644 --- a/VigilCareClinicalAPI/Data/Configurations/PatientConfiguration.cs +++ b/VigilCareClinicalAPI/Data/Configurations/PatientConfiguration.cs @@ -14,6 +14,13 @@ public class PatientConfiguration : IEntityTypeConfiguration builder.Property(p => p.DateOfBirth).HasColumnName("date_of_birth"); builder.Property(p => p.Gender).HasColumnName("gender").HasMaxLength(10).IsRequired(); builder.Property(p => p.Status).HasColumnName("status").HasMaxLength(20).HasDefaultValue("active"); + builder.Property(p => p.BloodType).HasColumnName("blood_type").HasMaxLength(5) + .HasConversion( + v => v!.Value.ToDbString(), + v => BloodTypeExtensions.FromDbString(v)); + builder.Property(p => p.Allergies).HasColumnName("allergies"); + builder.Property(p => p.EmergencyContactName).HasColumnName("emergency_contact_name").HasMaxLength(200); + builder.Property(p => p.EmergencyContactPhone).HasColumnName("emergency_contact_phone").HasMaxLength(20); builder.Property(p => p.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); // MRN uses exact-match unique index — MRN lookups are always equality checks, diff --git a/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs b/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs index 75ebaf4..287cabf 100644 --- a/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs +++ b/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs @@ -13,12 +13,16 @@ public static class DataSeeder { Id = Guid.NewGuid(), Mrn = "MRN-000001", FirstName = "Jane", LastName = "Smith", DateOfBirth = new DateOnly(1975, 4, 12), Gender = "F", + BloodType = BloodType.APositive, Allergies = "Penicillin", + EmergencyContactName = "John Smith", EmergencyContactPhone = "555-0101", CreatedAt = DateTimeOffset.UtcNow }; var patient2 = new Patient { Id = Guid.NewGuid(), Mrn = "MRN-000002", FirstName = "Robert", LastName = "Chen", DateOfBirth = new DateOnly(1962, 9, 3), Gender = "M", + BloodType = BloodType.ONegative, Allergies = null, + EmergencyContactName = "Linda Chen", EmergencyContactPhone = "555-0202", CreatedAt = DateTimeOffset.UtcNow }; db.Patients.AddRange(patient1, patient2); @@ -28,14 +32,18 @@ public static class DataSeeder { Id = Guid.NewGuid(), PatientId = patient1.Id, EncounterType = EncounterType.Inpatient, Status = EncounterStatus.Active, Department = Department.Icu, - AttendingPhysician = "Dr. Osei", AdmittedAt = DateTimeOffset.UtcNow.AddHours(-6), + AttendingPhysician = "Dr. Osei", + RoomBed = "ICU-4B", AdmissionReason = "Chest pain, rule out MI", + AdmittedAt = DateTimeOffset.UtcNow.AddHours(-6), CreatedAt = DateTimeOffset.UtcNow.AddHours(-6) }; var encounter2 = new Encounter { Id = Guid.NewGuid(), PatientId = patient2.Id, EncounterType = EncounterType.Inpatient, Status = EncounterStatus.Active, Department = Department.GeneralMedicine, - AttendingPhysician = "Dr. Patel", AdmittedAt = DateTimeOffset.UtcNow.AddHours(-12), + AttendingPhysician = "Dr. Patel", + RoomBed = "GM-12A", AdmissionReason = "Pneumonia, fever for 3 days", + AdmittedAt = DateTimeOffset.UtcNow.AddHours(-12), CreatedAt = DateTimeOffset.UtcNow.AddHours(-12) }; db.Encounters.AddRange(encounter1, encounter2); @@ -76,7 +84,49 @@ public static class DataSeeder DisplayName = "White Blood Cell Count", Unit = "k/µL", CriticalLow = 2.0m, WarningLow = 4.0m, WarningHigh = 12.0m, CriticalHigh = 20.0m, CreatedAt = DateTimeOffset.UtcNow - } + }, + new AlertThreshold + { + Id = Guid.NewGuid(), ObservationCode = "SYSTOLIC_BP", + DisplayName = "Systolic Blood Pressure", Unit = "mmHg", + CriticalLow = 70m, WarningLow = 90m, WarningHigh = 160m, CriticalHigh = 180m, + CreatedAt = DateTimeOffset.UtcNow + }, + new AlertThreshold + { + Id = Guid.NewGuid(), ObservationCode = "DIASTOLIC_BP", + DisplayName = "Diastolic Blood Pressure", Unit = "mmHg", + CriticalLow = 40m, WarningLow = 60m, WarningHigh = 90m, CriticalHigh = 110m, + CreatedAt = DateTimeOffset.UtcNow + }, + new AlertThreshold + { + Id = Guid.NewGuid(), ObservationCode = "LACTATE_MMOL_L", + DisplayName = "Serum Lactate", Unit = "mmol/L", + CriticalLow = null, WarningLow = null, WarningHigh = 2.0m, CriticalHigh = 4.0m, + CreatedAt = DateTimeOffset.UtcNow + }, + new AlertThreshold + { + Id = Guid.NewGuid(), ObservationCode = "AVPU", + DisplayName = "AVPU Consciousness Level", Unit = "score", + CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = 2m, + CreatedAt = DateTimeOffset.UtcNow + }, + new AlertThreshold + { + Id = Guid.NewGuid(), ObservationCode = "SUPPLEMENTAL_O2", + DisplayName = "Supplemental Oxygen", Unit = "flag", + CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null, + CreatedAt = DateTimeOffset.UtcNow + }, + new AlertThreshold + { + Id = Guid.NewGuid(), ObservationCode = "GLUCOSE_MG_DL", + DisplayName = "Blood Glucose", Unit = "mg/dL", + CriticalLow = 40m, WarningLow = 70m, WarningHigh = 180m, CriticalHigh = 400m, + CreatedAt = DateTimeOffset.UtcNow + }, }; db.AlertThresholds.AddRange(thresholds); @@ -98,7 +148,25 @@ public static class DataSeeder Value = 97, Unit = "%", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-5), CreatedAt = now.AddMinutes(-5) }, // Normal temp for encounter2 new() { Id = Guid.NewGuid(), EncounterId = encounter2.Id, ObservationCode = "TEMP_C", - Value = 37.1m, Unit = "°C", Source = ObservationSource.Manual, RecordedAt = now.AddMinutes(-20), CreatedAt = now.AddMinutes(-20) } + Value = 37.1m, Unit = "°C", Source = ObservationSource.Manual, RecordedAt = now.AddMinutes(-20), CreatedAt = now.AddMinutes(-20) }, + // Blood pressure + new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "SYSTOLIC_BP", + Value = 128, Unit = "mmHg", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-25), CreatedAt = now.AddMinutes(-25) }, + new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "DIASTOLIC_BP", + Value = 82, Unit = "mmHg", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-25), CreatedAt = now.AddMinutes(-25) }, + // Normal lactate + new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "LACTATE_MMOL_L", + Value = 1.2m, Unit = "mmol/L", Source = ObservationSource.Lab, RecordedAt = now.AddMinutes(-8), CreatedAt = now.AddMinutes(-8) }, + // AVPU = Alert (normal consciousness) + new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "AVPU", + Value = 0, Unit = "score", Source = ObservationSource.Manual, RecordedAt = now.AddMinutes(-28), CreatedAt = now.AddMinutes(-28) }, + new() { Id = Guid.NewGuid(), EncounterId = encounter2.Id, ObservationCode = "AVPU", + Value = 0, Unit = "score", Source = ObservationSource.Manual, RecordedAt = now.AddMinutes(-18), CreatedAt = now.AddMinutes(-18) }, + // Room air (no supplemental oxygen) + new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "SUPPLEMENTAL_O2", + Value = 0, Unit = "flag", Source = ObservationSource.Manual, RecordedAt = now.AddMinutes(-27), CreatedAt = now.AddMinutes(-27) }, + new() { Id = Guid.NewGuid(), EncounterId = encounter2.Id, ObservationCode = "SUPPLEMENTAL_O2", + Value = 1, Unit = "flag", Source = ObservationSource.Manual, RecordedAt = now.AddMinutes(-17), CreatedAt = now.AddMinutes(-17) }, }; db.Observations.AddRange(observations); diff --git a/VigilCareClinicalAPI/Domains/Entities/Encounter.cs b/VigilCareClinicalAPI/Domains/Entities/Encounter.cs index d0aa71c..9d044b2 100644 --- a/VigilCareClinicalAPI/Domains/Entities/Encounter.cs +++ b/VigilCareClinicalAPI/Domains/Entities/Encounter.cs @@ -6,6 +6,9 @@ public class Encounter public EncounterStatus Status { get; set; } public Department Department { get; set; } public string AttendingPhysician { get; set; } = null!; + public string? RoomBed { get; set; } + public string? AdmissionReason { get; set; } + public string? DischargeDiagnosis { get; set; } public DateTimeOffset AdmittedAt { get; set; } public DateTimeOffset? DischargedAt { get; set; } public DateTimeOffset CreatedAt { get; set; } diff --git a/VigilCareClinicalAPI/Domains/Entities/Patient.cs b/VigilCareClinicalAPI/Domains/Entities/Patient.cs index 1a5b199..412dea4 100644 --- a/VigilCareClinicalAPI/Domains/Entities/Patient.cs +++ b/VigilCareClinicalAPI/Domains/Entities/Patient.cs @@ -6,6 +6,10 @@ public class Patient public string LastName { get; set; } = null!; public DateOnly DateOfBirth { get; set; } public string Gender { get; set; } = null!; + public BloodType? BloodType { get; set; } + public string? Allergies { get; set; } + public string? EmergencyContactName { get; set; } + public string? EmergencyContactPhone { get; set; } public string Status { get; set; } = "active"; public DateTimeOffset CreatedAt { get; set; } diff --git a/VigilCareClinicalAPI/Domains/Enums/AlertType.cs b/VigilCareClinicalAPI/Domains/Enums/AlertType.cs index d27bc20..c8ea714 100644 --- a/VigilCareClinicalAPI/Domains/Enums/AlertType.cs +++ b/VigilCareClinicalAPI/Domains/Enums/AlertType.cs @@ -6,7 +6,12 @@ public enum AlertType CriticalPotassiumMeqL, CriticalSpo2, CriticalRespRate, - CriticalWbcKUl + CriticalWbcKUl, + CriticalSystolicBp, + CriticalDiastolicBp, + CriticalLactateMmolL, + CriticalAvpu, + CriticalGlucoseMgDl } public static class AlertTypeExtensions @@ -20,6 +25,11 @@ public static class AlertTypeExtensions AlertType.CriticalSpo2 => "CRITICAL_SPO2", AlertType.CriticalRespRate => "CRITICAL_RESP_RATE", AlertType.CriticalWbcKUl => "CRITICAL_WBC_K_UL", + AlertType.CriticalSystolicBp => "CRITICAL_SYSTOLIC_BP", + AlertType.CriticalDiastolicBp => "CRITICAL_DIASTOLIC_BP", + AlertType.CriticalLactateMmolL => "CRITICAL_LACTATE_MMOL_L", + AlertType.CriticalAvpu => "CRITICAL_AVPU", + AlertType.CriticalGlucoseMgDl => "CRITICAL_GLUCOSE_MG_DL", _ => throw new ArgumentOutOfRangeException(nameof(t)) }; @@ -32,6 +42,11 @@ public static class AlertTypeExtensions "CRITICAL_SPO2" => AlertType.CriticalSpo2, "CRITICAL_RESP_RATE" => AlertType.CriticalRespRate, "CRITICAL_WBC_K_UL" => AlertType.CriticalWbcKUl, + "CRITICAL_SYSTOLIC_BP" => AlertType.CriticalSystolicBp, + "CRITICAL_DIASTOLIC_BP" => AlertType.CriticalDiastolicBp, + "CRITICAL_LACTATE_MMOL_L" => AlertType.CriticalLactateMmolL, + "CRITICAL_AVPU" => AlertType.CriticalAvpu, + "CRITICAL_GLUCOSE_MG_DL" => AlertType.CriticalGlucoseMgDl, _ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert type: '{v}'") }; @@ -44,6 +59,11 @@ public static class AlertTypeExtensions "SPO2" => AlertType.CriticalSpo2, "RESP_RATE" => AlertType.CriticalRespRate, "WBC_K_UL" => AlertType.CriticalWbcKUl, + "SYSTOLIC_BP" => AlertType.CriticalSystolicBp, + "DIASTOLIC_BP" => AlertType.CriticalDiastolicBp, + "LACTATE_MMOL_L" => AlertType.CriticalLactateMmolL, + "AVPU" => AlertType.CriticalAvpu, + "GLUCOSE_MG_DL" => AlertType.CriticalGlucoseMgDl, _ => throw new ArgumentOutOfRangeException( nameof(observationCode), $"No critical alert type for observation code '{observationCode}'") }; diff --git a/VigilCareClinicalAPI/Domains/Enums/BloodType.cs b/VigilCareClinicalAPI/Domains/Enums/BloodType.cs new file mode 100644 index 0000000..bac80e5 --- /dev/null +++ b/VigilCareClinicalAPI/Domains/Enums/BloodType.cs @@ -0,0 +1,40 @@ +public enum BloodType +{ + APositive, + ANegative, + BPositive, + BNegative, + AbPositive, + AbNegative, + OPositive, + ONegative +} + +public static class BloodTypeExtensions +{ + public static string ToDbString(this BloodType b) => b switch + { + BloodType.APositive => "A+", + BloodType.ANegative => "A-", + BloodType.BPositive => "B+", + BloodType.BNegative => "B-", + BloodType.AbPositive => "AB+", + BloodType.AbNegative => "AB-", + BloodType.OPositive => "O+", + BloodType.ONegative => "O-", + _ => throw new ArgumentOutOfRangeException(nameof(b)) + }; + + public static BloodType FromDbString(string v) => v switch + { + "A+" => BloodType.APositive, + "A-" => BloodType.ANegative, + "B+" => BloodType.BPositive, + "B-" => BloodType.BNegative, + "AB+" => BloodType.AbPositive, + "AB-" => BloodType.AbNegative, + "O+" => BloodType.OPositive, + "O-" => BloodType.ONegative, + _ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown blood type: '{v}'") + }; +} diff --git a/VigilCareClinicalAPI/Domains/Json/BloodTypeJsonConverter.cs b/VigilCareClinicalAPI/Domains/Json/BloodTypeJsonConverter.cs new file mode 100644 index 0000000..e7c8cc2 --- /dev/null +++ b/VigilCareClinicalAPI/Domains/Json/BloodTypeJsonConverter.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +public sealed class BloodTypeJsonConverter : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) => + typeToConvert == typeof(BloodType) || typeToConvert == typeof(BloodType?); + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + if (typeToConvert == typeof(BloodType)) + return new BloodTypeConverter(); + + return new NullableBloodTypeConverter(); + } + + private sealed class BloodTypeConverter : JsonConverter + { + public override BloodType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => BloodTypeExtensions.FromDbString(reader.GetString()!); + + public override void Write(Utf8JsonWriter writer, BloodType value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToDbString()); + } + + private sealed class NullableBloodTypeConverter : JsonConverter + { + public override BloodType? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return null; + + return BloodTypeExtensions.FromDbString(reader.GetString()!); + } + + public override void Write(Utf8JsonWriter writer, BloodType? value, JsonSerializerOptions options) + { + if (value is null) + writer.WriteNullValue(); + else + writer.WriteStringValue(value.Value.ToDbString()); + } + } +} diff --git a/VigilCareClinicalAPI/Elasticsearch/Documents/PatientEncounterDocument.cs b/VigilCareClinicalAPI/Elasticsearch/Documents/PatientEncounterDocument.cs index 33d4dd7..9b66768 100644 --- a/VigilCareClinicalAPI/Elasticsearch/Documents/PatientEncounterDocument.cs +++ b/VigilCareClinicalAPI/Elasticsearch/Documents/PatientEncounterDocument.cs @@ -8,6 +8,8 @@ public class PatientEncounterDocument public string Status { get; set; } = null!; public string AttendingPhysician { get; set; } = null!; public DateTimeOffset AdmittedAt { get; set; } + public string? RoomBed { get; set; } + public string? AdmissionReason { get; set; } public int OpenAlertCount { get; set; } public DateTimeOffset? LastObservationAt { get; set; } } \ No newline at end of file diff --git a/VigilCareClinicalAPI/Migrations/20260618050245_AddPatientClinicalDemographics.Designer.cs b/VigilCareClinicalAPI/Migrations/20260618050245_AddPatientClinicalDemographics.Designer.cs new file mode 100644 index 0000000..aa640dd --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260618050245_AddPatientClinicalDemographics.Designer.cs @@ -0,0 +1,612 @@ +// +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("20260618050245_AddPatientClinicalDemographics")] + partial class AddPatientClinicalDemographics + { + /// + 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("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("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("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/20260618050245_AddPatientClinicalDemographics.cs b/VigilCareClinicalAPI/Migrations/20260618050245_AddPatientClinicalDemographics.cs new file mode 100644 index 0000000..07ecaff --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260618050245_AddPatientClinicalDemographics.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + /// + public partial class AddPatientClinicalDemographics : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "allergies", + table: "patients", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "blood_type", + table: "patients", + type: "character varying(5)", + maxLength: 5, + nullable: true); + + migrationBuilder.AddColumn( + name: "emergency_contact_name", + table: "patients", + type: "character varying(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "emergency_contact_phone", + table: "patients", + type: "character varying(20)", + maxLength: 20, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "allergies", + table: "patients"); + + migrationBuilder.DropColumn( + name: "blood_type", + table: "patients"); + + migrationBuilder.DropColumn( + name: "emergency_contact_name", + table: "patients"); + + migrationBuilder.DropColumn( + name: "emergency_contact_phone", + table: "patients"); + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/20260618060707_AddEncounterClinicalFields.Designer.cs b/VigilCareClinicalAPI/Migrations/20260618060707_AddEncounterClinicalFields.Designer.cs new file mode 100644 index 0000000..5923106 --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260618060707_AddEncounterClinicalFields.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("20260618060707_AddEncounterClinicalFields")] + partial class AddEncounterClinicalFields + { + /// + 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/20260618060707_AddEncounterClinicalFields.cs b/VigilCareClinicalAPI/Migrations/20260618060707_AddEncounterClinicalFields.cs new file mode 100644 index 0000000..e6cb56c --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260618060707_AddEncounterClinicalFields.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + /// + public partial class AddEncounterClinicalFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "admission_reason", + table: "encounters", + type: "character varying(500)", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "discharge_diagnosis", + table: "encounters", + type: "character varying(500)", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "room_bed", + table: "encounters", + type: "character varying(20)", + maxLength: 20, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "admission_reason", + table: "encounters"); + + migrationBuilder.DropColumn( + name: "discharge_diagnosis", + table: "encounters"); + + migrationBuilder.DropColumn( + name: "room_bed", + table: "encounters"); + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/20260618061534_ExpandAlertTypeCheckConstraint.Designer.cs b/VigilCareClinicalAPI/Migrations/20260618061534_ExpandAlertTypeCheckConstraint.Designer.cs new file mode 100644 index 0000000..c88160f --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260618061534_ExpandAlertTypeCheckConstraint.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("20260618061534_ExpandAlertTypeCheckConstraint")] + partial class ExpandAlertTypeCheckConstraint + { + /// + 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/20260618061534_ExpandAlertTypeCheckConstraint.cs b/VigilCareClinicalAPI/Migrations/20260618061534_ExpandAlertTypeCheckConstraint.cs new file mode 100644 index 0000000..3cdb1c5 --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260618061534_ExpandAlertTypeCheckConstraint.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + /// + public partial class ExpandAlertTypeCheckConstraint : 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' + )); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs index 3814372..4a2f091 100644 --- a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs +++ b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs @@ -168,6 +168,11 @@ namespace VigilCareClinicalAPI.Migrations .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") @@ -192,6 +197,11 @@ namespace VigilCareClinicalAPI.Migrations .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"); @@ -206,6 +216,11 @@ namespace VigilCareClinicalAPI.Migrations .HasColumnType("uuid") .HasColumnName("patient_id"); + b.Property("RoomBed") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("room_bed"); + b.Property("Status") .IsRequired() .ValueGeneratedOnAdd() @@ -408,6 +423,15 @@ namespace VigilCareClinicalAPI.Migrations .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") @@ -418,6 +442,16 @@ namespace VigilCareClinicalAPI.Migrations .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) diff --git a/VigilCareClinicalAPI/Models/Records/Encounter/EncounterStatusChangedEvent.cs b/VigilCareClinicalAPI/Models/Records/Encounter/EncounterStatusChangedEvent.cs index 0352de1..eaf81d4 100644 --- a/VigilCareClinicalAPI/Models/Records/Encounter/EncounterStatusChangedEvent.cs +++ b/VigilCareClinicalAPI/Models/Records/Encounter/EncounterStatusChangedEvent.cs @@ -1,4 +1,4 @@ public record EncounterStatusChangedEvent( Guid EncounterId, Guid PatientId, string Mrn, string PatientName, string? PreviousStatus, string NewStatus, string Department, - string AttendingPhysician, DateTimeOffset AdmittedAt, DateTimeOffset ChangedAt); \ No newline at end of file + string AttendingPhysician, string? RoomBed, string? AdmissionReason, DateTimeOffset AdmittedAt, DateTimeOffset ChangedAt); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Models/Records/Encounter/OpenEncounterRequest.cs b/VigilCareClinicalAPI/Models/Records/Encounter/OpenEncounterRequest.cs index d59d60c..1cadeaa 100644 --- a/VigilCareClinicalAPI/Models/Records/Encounter/OpenEncounterRequest.cs +++ b/VigilCareClinicalAPI/Models/Records/Encounter/OpenEncounterRequest.cs @@ -1,4 +1,6 @@ public record OpenEncounterRequest( EncounterType EncounterType, Department Department, - string AttendingPhysician); \ No newline at end of file + string AttendingPhysician, + string? RoomBed = null, + string? AdmissionReason = null); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Models/Records/Encounter/TransitionStatusRequest.cs b/VigilCareClinicalAPI/Models/Records/Encounter/TransitionStatusRequest.cs index 038daca..bde5e32 100644 --- a/VigilCareClinicalAPI/Models/Records/Encounter/TransitionStatusRequest.cs +++ b/VigilCareClinicalAPI/Models/Records/Encounter/TransitionStatusRequest.cs @@ -1 +1,3 @@ -public record TransitionStatusRequest(EncounterStatus Status); \ No newline at end of file +public record TransitionStatusRequest( + EncounterStatus Status, + string? DischargeDiagnosis = null); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Models/Records/Patient/RegisterPatientRequest.cs b/VigilCareClinicalAPI/Models/Records/Patient/RegisterPatientRequest.cs index b7eba0e..4d9444a 100644 --- a/VigilCareClinicalAPI/Models/Records/Patient/RegisterPatientRequest.cs +++ b/VigilCareClinicalAPI/Models/Records/Patient/RegisterPatientRequest.cs @@ -2,4 +2,8 @@ public record RegisterPatientRequest( string FirstName, string LastName, DateOnly DateOfBirth, - string Gender); \ No newline at end of file + string Gender, + BloodType? BloodType = null, + string? Allergies = null, + string? EmergencyContactName = null, + string? EmergencyContactPhone = null); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Program.cs b/VigilCareClinicalAPI/Program.cs index 617aea4..a57656b 100644 --- a/VigilCareClinicalAPI/Program.cs +++ b/VigilCareClinicalAPI/Program.cs @@ -93,6 +93,7 @@ try .AddJsonOptions(opts => { opts.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; + opts.JsonSerializerOptions.Converters.Add(new BloodTypeJsonConverter()); opts.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); opts.JsonSerializerOptions.Converters.Add(new ObservationSourceJsonConverter()); opts.JsonSerializerOptions.Converters.Add(new DepartmentJsonConverter()); diff --git a/VigilCareClinicalAPI/Services/EncounterService.cs b/VigilCareClinicalAPI/Services/EncounterService.cs index 743a1d9..a67d635 100644 --- a/VigilCareClinicalAPI/Services/EncounterService.cs +++ b/VigilCareClinicalAPI/Services/EncounterService.cs @@ -32,7 +32,7 @@ public class EncounterService : IEncounterService } public async Task TransitionStatusAsync( - Guid encounterId, EncounterStatus targetStatus) + Guid encounterId, EncounterStatus targetStatus, string? dischargeDiagnosis = null) { var encounter = await _db.Encounters .Include(e => e.Patient) @@ -50,7 +50,10 @@ public class EncounterService : IEncounterService encounter.Status = targetStatus; if (targetStatus == EncounterStatus.Discharged) + { encounter.DischargedAt = DateTimeOffset.UtcNow; + encounter.DischargeDiagnosis = dischargeDiagnosis; + } _db.OutboxEvents.Add(new OutboxEvent { @@ -66,6 +69,8 @@ public class EncounterService : IEncounterService newStatus = targetStatus.ToDbString(), department = encounter.Department.ToDbString(), attendingPhysician = encounter.AttendingPhysician, + roomBed = encounter.RoomBed, + admissionReason = encounter.AdmissionReason, admittedAt = encounter.AdmittedAt, changedAt = DateTimeOffset.UtcNow }), diff --git a/VigilCareClinicalAPI/Services/Interfaces/IEncounterService.cs b/VigilCareClinicalAPI/Services/Interfaces/IEncounterService.cs index 027c290..f73fa9b 100644 --- a/VigilCareClinicalAPI/Services/Interfaces/IEncounterService.cs +++ b/VigilCareClinicalAPI/Services/Interfaces/IEncounterService.cs @@ -1,6 +1,7 @@ public interface IEncounterService { Task GetByIdAsync(Guid id); - Task TransitionStatusAsync(Guid encounterId, EncounterStatus targetStatus); + Task TransitionStatusAsync( + Guid encounterId, EncounterStatus targetStatus, string? dischargeDiagnosis = null); Task GetTimelineAsync(Guid encounterId); } diff --git a/VigilCareClinicalAPI/Services/PatientService.cs b/VigilCareClinicalAPI/Services/PatientService.cs index 3c56c6f..95a18ef 100644 --- a/VigilCareClinicalAPI/Services/PatientService.cs +++ b/VigilCareClinicalAPI/Services/PatientService.cs @@ -18,6 +18,10 @@ public class PatientService : IPatientService LastName = req.LastName, DateOfBirth = req.DateOfBirth, Gender = req.Gender, + BloodType = req.BloodType, + Allergies = req.Allergies, + EmergencyContactName = req.EmergencyContactName, + EmergencyContactPhone = req.EmergencyContactPhone, CreatedAt = DateTimeOffset.UtcNow }; _db.Patients.Add(patient); @@ -84,6 +88,8 @@ public class PatientService : IPatientService Status = EncounterStatus.Active, Department = req.Department, AttendingPhysician = req.AttendingPhysician, + RoomBed = req.RoomBed, + AdmissionReason = req.AdmissionReason, AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow }; @@ -103,6 +109,8 @@ public class PatientService : IPatientService newStatus = encounter.Status.ToDbString(), department = encounter.Department.ToDbString(), attendingPhysician = encounter.AttendingPhysician, + roomBed = encounter.RoomBed, + admissionReason = encounter.AdmissionReason, admittedAt = encounter.AdmittedAt, changedAt = DateTimeOffset.UtcNow }), diff --git a/VigilCareClinicalAPI/Services/PlausibilityValidator.cs b/VigilCareClinicalAPI/Services/PlausibilityValidator.cs index e11dd64..75967c6 100644 --- a/VigilCareClinicalAPI/Services/PlausibilityValidator.cs +++ b/VigilCareClinicalAPI/Services/PlausibilityValidator.cs @@ -12,6 +12,11 @@ public static class PlausibilityValidator ["RESP_RATE"] = (1, 80), ["WBC_K_UL"] = (0.1m, 500), ["GLUCOSE_MG_DL"] = (10, 1500), + ["SYSTOLIC_BP"] = (40, 300), + ["DIASTOLIC_BP"] = (20, 200), + ["LACTATE_MMOL_L"] = (0.1m, 30), + ["AVPU"] = (0, 3), + ["SUPPLEMENTAL_O2"] = (0, 1), }; public static bool IsPlausible(string observationCode, decimal value, out string? reason) diff --git a/infra/grafana/dashbpards/vigilcare.json b/infra/grafana/dashboards/vigilcare.json similarity index 100% rename from infra/grafana/dashbpards/vigilcare.json rename to infra/grafana/dashboards/vigilcare.json