feature: Clinical Data Model Expansion & Observation Vocabulary

This commit is contained in:
voltsrage
2026-06-18 15:00:42 +08:00
parent 3b4c5c524b
commit 7d630fbbd9
33 changed files with 2636 additions and 36 deletions
+120 -19
View File
@@ -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. 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 ## Domain Model — How It Maps to a Real Clinical System
In a hospital, a patient presents for care and an encounter is opened. Bedside monitors and lab systems post observations continuously against that encounter. A rules engine evaluates each observation against configured thresholds and flags abnormal values as clinical alerts. Clinicians acknowledge and resolve alerts. If a critical alert goes unacknowledged for five minutes, the system escalates to the on-call backup. All events flow through Kafka so the Elasticsearch dashboard, sepsis engine, and data lake writer consume the same stream independently. In a hospital, a patient presents for care and an encounter is opened. Bedside monitors and lab systems post observations continuously against that encounter. A rules engine evaluates each observation against configured thresholds and flags abnormal values as clinical alerts. 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 - **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 - **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 - **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) - **Swagger UI** — OpenAPI spec via Swashbuckle (Development only)
--- ---
@@ -89,6 +91,10 @@ IHostedServices (background):
DischargeSummaryWorkerService → RabbitMQ discharge.queue → MinIO PDF DischargeSummaryWorkerService → RabbitMQ discharge.queue → MinIO PDF
DataLakeWriterService → Kafka (data-lake-writer) → Parquet files in MinIO DataLakeWriterService → Kafka (data-lake-writer) → Parquet files in MinIO
ReconciliationScheduler → three scheduled safety checks → reconciliation_alerts + RabbitMQ 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. **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) | | Search / analytics | Elasticsearch 8.13 (CQRS read projection) |
| Data lake | MinIO (Parquet, S3-compatible) | | Data lake | MinIO (Parquet, S3-compatible) |
| Logging | Serilog + Seq sink | | 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) | | Docs | Swagger / OpenAPI (Swashbuckle) |
| Testing | xUnit + Testcontainers | | Testing | xUnit + Testcontainers + WebApplicationFactory |
--- ---
@@ -144,19 +152,30 @@ VigilCareClinicalAPI/
│ ├── AlertType.cs # ThresholdBreach, SepsisWarning, … │ ├── AlertType.cs # ThresholdBreach, SepsisWarning, …
│ ├── ObservationSource.cs # Device, Manual, Lab │ ├── ObservationSource.cs # Device, Manual, Lab
│ └── OrderType.cs / ReconciliationCheckType.cs / Department.cs / OrderStatus.cs │ └── OrderType.cs / ReconciliationCheckType.cs / Department.cs / OrderStatus.cs
│ └── Json/
│ ├── ObservationSourceJsonConverter.cs
│ └── DepartmentJsonConverter.cs
├── Services/ ├── Services/
│ ├── Interfaces/ # IPatientService, IEncounterService, …
│ ├── PatientService.cs │ ├── PatientService.cs
│ ├── EncounterService.cs # Status state machine + ConflictException on invalid transitions │ ├── EncounterService.cs # Status state machine + ConflictException on invalid transitions
│ ├── AlertThresholdService.cs # CRUD + Redis write-through invalidation │ ├── 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 │ ├── ObservationQueryService.cs # Cursor-paginated history
│ ├── AlertService.cs # Acknowledge, resolve, list │ ├── AlertService.cs # Acknowledge, resolve, list
│ ├── AnalyticsService.cs # Elasticsearch query wrappers │ ├── AnalyticsService.cs # Elasticsearch query wrappers
│ └── PlausibilityValidator.cs # Per-code numeric range guard │ └── PlausibilityValidator.cs # Per-code numeric range guard
├── Observability/
│ └── Metrics/
│ └── ClinicalMetrics.cs # Eight Prometheus metric families (counters, histogram, gauges)
├── BackgroundServices/ ├── BackgroundServices/
│ ├── ThresholdCacheLoader.cs # Pre-loads all thresholds into Redis on startup │ ├── ThresholdCacheLoader.cs # Pre-loads all thresholds into Redis on startup
│ ├── KafkaTopicProvisioner.cs # Creates topics with NumPartitions from config │ ├── KafkaTopicProvisioner.cs # Creates topics with NumPartitions from config
│ ├── OutboxRelayService.cs # Polls outbox every 500ms; publishes to Kafka; marks processed │ ├── 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/ │ ├── ElasticsSearch/
│ │ ├── ElasticIndexProvisioner.cs # Creates patient_encounters, observations, clinical_alerts indices │ │ ├── ElasticIndexProvisioner.cs # Creates patient_encounters, observations, clinical_alerts indices
│ │ └── EsIndexerService.cs # consumer group: es-indexer; upserts Elasticsearch documents │ │ └── EsIndexerService.cs # consumer group: es-indexer; upserts Elasticsearch documents
@@ -172,6 +191,10 @@ VigilCareClinicalAPI/
│ ├── PendingOrdersCheck.cs # Pending orders without results > 4 hours │ ├── PendingOrdersCheck.cs # Pending orders without results > 4 hours
│ ├── DisconnectedMonitorsCheck.cs # Active inpatients with no observation > 2 hours │ ├── DisconnectedMonitorsCheck.cs # Active inpatients with no observation > 2 hours
│ └── ReconciliationPublisher.cs # Publishes findings to notifications.reconciliation.queue │ └── ReconciliationPublisher.cs # Publishes findings to notifications.reconciliation.queue
├── Configuration/
│ ├── KafkaOptions.cs / KafkaTopicOptions.cs
│ ├── RabbitMqOptions.cs / MinioOptions.cs
│ └── ReconciliationJobOptions.cs
├── Sepsis/ ├── Sepsis/
│ ├── SirsDetector.cs # Redis SIRS state management (SET/DEL/MGET) │ ├── SirsDetector.cs # Redis SIRS state management (SET/DEL/MGET)
│ └── SirsEvaluator.cs # Per-code criterion evaluation │ └── SirsEvaluator.cs # Per-code criterion evaluation
@@ -193,7 +216,7 @@ VigilCareClinicalAPI/
│ └── Encounter/EncounterStatusRow.cs # Parquet row contract for encounter status events │ └── Encounter/EncounterStatusRow.cs # Parquet row contract for encounter status events
├── Data/ ├── Data/
│ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints │ ├── 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 │ └── Seed/DataSeeder.cs # Seeds patients, encounters, thresholds, observations
├── Common/ ├── Common/
│ ├── ApiResponse.cs # { success, statusCode, data, error } envelope │ ├── ApiResponse.cs # { success, statusCode, data, error } envelope
@@ -227,12 +250,22 @@ tests/
└── DataLakePhase9Tests.cs # Kafka → MinIO Parquet flow and schema checks └── DataLakePhase9Tests.cs # Kafka → MinIO Parquet flow and schema checks
scripts/ scripts/
├── run-phase8-verification.sh # Prometheus + alerts_unacknowledged_gauge checks ├── run-api-redis-tests.sh # Phase 1 — patient/encounter/threshold + Redis cache
── run-phase9-verification.sh # Data lake integration tests + Kafka + MinIO + DuckDB ── 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/ docs/
├── plans/phase-9-plan.md # Phase 9 implementation and verification guide ├── plans/ # Phase 19 implementation and verification guides
── decisions/data-lake-design.md # Parquet vs JSON, partitioning, replay rationale ── 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 3. Pre-loads all thresholds into Redis
4. Provisions Kafka topics and Elasticsearch indices 4. Provisions Kafka topics and Elasticsearch indices
5. Declares the RabbitMQ exchange and queue topology 5. Declares the RabbitMQ exchange and queue topology
6. Starts the data lake writer (`data-lake-writer` → Parquet in MinIO) 6. Starts all background consumers (outbox relay, ES indexer, sepsis engine, notification workers, data lake writer, reconciliation scheduler)
7. Starts the reconciliation scheduler (three safety checks on a configurable interval) 7. Starts Prometheus metric collectors (unacknowledged alerts, outbox pending, Kafka consumer lag)
Swagger UI is available at `http://localhost:<port>/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 ### Run Tests
@@ -356,15 +389,36 @@ Swagger UI is available at `http://localhost:<port>/swagger` in Development.
dotnet test 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 ### 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 ```bash
./scripts/run-phase8-verification.sh # Prometheus metrics, alerts_unacknowledged_gauge ./scripts/run-phase8-verification.sh # Prometheus target UP, eight metrics, alerts_unacknowledged_gauge live update
./scripts/run-phase9-verification.sh # Data lake tests, Kafka offsets, MinIO Parquet, DuckDB schema ./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): 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" 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 ## Pagination
List endpoints use offset pagination: List endpoints use offset pagination:
@@ -851,6 +948,8 @@ Observation history uses cursor pagination on `(recorded_at DESC, id DESC)`. Off
## Implemented Phases ## Implemented Phases
All nine phases from the project roadmap are implemented and covered by integration tests and/or verification scripts.
| Phase | Feature | Status | | Phase | Feature | Status |
|---|---|---| |---|---|---|
| 1 | Schema, EF Core migrations, patient/encounter CRUD, alert threshold CRUD, encounter status machine, Redis threshold pre-load, seed data | Done | | 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 | | 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 | | 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 | | 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 | | 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; Parquet flush to MinIO; `DataLakePhase9Tests`; `run-phase9-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.
@@ -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<AppDbContext>();
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<IConnectionMultiplexer>();
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<IngestObservationRequest>
{
new("SYSTOLIC_BP", 65, "mmHg", ObservationSource.Device, DateTimeOffset.UtcNow, null)
}));
resp.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
body!.RootElement.GetProperty("data").GetProperty("alertGenerated").GetBoolean()
.Should().BeTrue();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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<IngestObservationRequest>
{
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<AppDbContext>();
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<IngestObservationRequest>
{
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<AppDbContext>();
(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<IngestObservationRequest>
{
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<AppDbContext>();
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<JsonDocument>();
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<JsonDocument>();
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<JsonDocument>();
var data = body!.RootElement.GetProperty("data");
data.GetProperty("roomBed").GetString().Should().Be("ICU-3C");
data.GetProperty("admissionReason").GetString().Should().Be("Acute respiratory distress");
}
}
@@ -7,7 +7,8 @@ using Minio.DataModel.Args;
using Parquet; using Parquet;
using StackExchange.Redis; using StackExchange.Redis;
public class DataLakePhase9Tests : IClassFixture<ApiFixture> [Collection("Integration")]
public class DataLakePhase9Tests
{ {
private readonly ApiFixture _fixture; private readonly ApiFixture _fixture;
private readonly HttpClient _http; private readonly HttpClient _http;
@@ -208,7 +209,6 @@ public class DataLakePhase9Tests : IClassFixture<ApiFixture>
await EnsureThresholdAsync(db, "HEART_RATE", "Heart Rate", "bpm", 30, 50, 100, 150); 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, "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 EnsureThresholdAsync(db, "TEMP_C", "Temperature", "°C", 34m, 36m, 37.8m, 40m);
await db.SaveChangesAsync();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>(); var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
var cache = redis.GetDatabase(1); var cache = redis.GetDatabase(1);
@@ -245,5 +245,15 @@ public class DataLakePhase9Tests : IClassFixture<ApiFixture>
CriticalHigh = criticalHigh, CriticalHigh = criticalHigh,
CreatedAt = DateTimeOffset.UtcNow 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.
}
} }
} }
@@ -4,7 +4,8 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis; using StackExchange.Redis;
public class ObservabilityPhase8Tests : IClassFixture<ApiFixture> [Collection("Integration")]
public class ObservabilityPhase8Tests
{ {
private static readonly string[] ExpectedMetrics = private static readonly string[] ExpectedMetrics =
{ {
@@ -58,6 +58,8 @@ public class ElasticIndexProvisioner : IHostedService
.Keyword(k => k.Department) .Keyword(k => k.Department)
.Keyword(k => k.Status) .Keyword(k => k.Status)
.Keyword(k => k.AttendingPhysician) .Keyword(k => k.AttendingPhysician)
.Keyword(k => k.RoomBed)
.Text(t => t.AdmissionReason)
.Date(d => d.AdmittedAt) .Date(d => d.AdmittedAt)
.IntegerNumber(i => i.OpenAlertCount) .IntegerNumber(i => i.OpenAlertCount)
.Date(d => d.LastObservationAt!) .Date(d => d.LastObservationAt!)
@@ -111,6 +111,8 @@ public class EsIndexerService : BackgroundService
Department = evt.Department, Department = evt.Department,
Status = evt.NewStatus, Status = evt.NewStatus,
AttendingPhysician = evt.AttendingPhysician, AttendingPhysician = evt.AttendingPhysician,
RoomBed = evt.RoomBed,
AdmissionReason = evt.AdmissionReason,
AdmittedAt = evt.AdmittedAt, AdmittedAt = evt.AdmittedAt,
OpenAlertCount = 0, OpenAlertCount = 0,
LastObservationAt = null LastObservationAt = null
@@ -39,8 +39,8 @@ public class EncountersController : ControllerBase
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
public async Task<IActionResult> TransitionStatus(Guid id, [FromBody] TransitionStatusRequest req) public async Task<IActionResult> TransitionStatus(Guid id, [FromBody] TransitionStatusRequest req)
{ {
var result = await _encounters.TransitionStatusAsync(id, req.Status); var result = await _encounters.TransitionStatusAsync(id, req.Status, req.DischargeDiagnosis);
return Ok(ApiResponse<object>.Ok(new { encounterId = result.EncounterId, newStatus = result.NewStatus })); return Ok(ApiResponse<object>.Ok(new { encounterId = result.EncounterId, newStatus = result.NewStatus, dischargeDiagnosis = req.DischargeDiagnosis }));
} }
/// <summary> /// <summary>
@@ -39,6 +39,9 @@ public class EncounterConfiguration : IEntityTypeConfiguration<Encounter>
v => v.ToDbString(), v => v.ToDbString(),
v => DepartmentExtensions.FromDbString(v)) v => DepartmentExtensions.FromDbString(v))
.IsRequired(); .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.AttendingPhysician).HasColumnName("attending_physician").HasMaxLength(200).IsRequired();
builder.Property(e => e.AdmittedAt).HasColumnName("admitted_at").HasDefaultValueSql("NOW()"); builder.Property(e => e.AdmittedAt).HasColumnName("admitted_at").HasDefaultValueSql("NOW()");
builder.Property(e => e.DischargedAt).HasColumnName("discharged_at"); builder.Property(e => e.DischargedAt).HasColumnName("discharged_at");
@@ -14,6 +14,13 @@ public class PatientConfiguration : IEntityTypeConfiguration<Patient>
builder.Property(p => p.DateOfBirth).HasColumnName("date_of_birth"); builder.Property(p => p.DateOfBirth).HasColumnName("date_of_birth");
builder.Property(p => p.Gender).HasColumnName("gender").HasMaxLength(10).IsRequired(); 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.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()"); builder.Property(p => p.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
// MRN uses exact-match unique index — MRN lookups are always equality checks, // MRN uses exact-match unique index — MRN lookups are always equality checks,
+72 -4
View File
@@ -13,12 +13,16 @@ public static class DataSeeder
{ {
Id = Guid.NewGuid(), Mrn = "MRN-000001", FirstName = "Jane", LastName = "Smith", Id = Guid.NewGuid(), Mrn = "MRN-000001", FirstName = "Jane", LastName = "Smith",
DateOfBirth = new DateOnly(1975, 4, 12), Gender = "F", DateOfBirth = new DateOnly(1975, 4, 12), Gender = "F",
BloodType = BloodType.APositive, Allergies = "Penicillin",
EmergencyContactName = "John Smith", EmergencyContactPhone = "555-0101",
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}; };
var patient2 = new Patient var patient2 = new Patient
{ {
Id = Guid.NewGuid(), Mrn = "MRN-000002", FirstName = "Robert", LastName = "Chen", Id = Guid.NewGuid(), Mrn = "MRN-000002", FirstName = "Robert", LastName = "Chen",
DateOfBirth = new DateOnly(1962, 9, 3), Gender = "M", DateOfBirth = new DateOnly(1962, 9, 3), Gender = "M",
BloodType = BloodType.ONegative, Allergies = null,
EmergencyContactName = "Linda Chen", EmergencyContactPhone = "555-0202",
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}; };
db.Patients.AddRange(patient1, patient2); db.Patients.AddRange(patient1, patient2);
@@ -28,14 +32,18 @@ public static class DataSeeder
{ {
Id = Guid.NewGuid(), PatientId = patient1.Id, EncounterType = EncounterType.Inpatient, Id = Guid.NewGuid(), PatientId = patient1.Id, EncounterType = EncounterType.Inpatient,
Status = EncounterStatus.Active, Department = Department.Icu, 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) CreatedAt = DateTimeOffset.UtcNow.AddHours(-6)
}; };
var encounter2 = new Encounter var encounter2 = new Encounter
{ {
Id = Guid.NewGuid(), PatientId = patient2.Id, EncounterType = EncounterType.Inpatient, Id = Guid.NewGuid(), PatientId = patient2.Id, EncounterType = EncounterType.Inpatient,
Status = EncounterStatus.Active, Department = Department.GeneralMedicine, 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) CreatedAt = DateTimeOffset.UtcNow.AddHours(-12)
}; };
db.Encounters.AddRange(encounter1, encounter2); db.Encounters.AddRange(encounter1, encounter2);
@@ -76,7 +84,49 @@ public static class DataSeeder
DisplayName = "White Blood Cell Count", Unit = "k/µL", DisplayName = "White Blood Cell Count", Unit = "k/µL",
CriticalLow = 2.0m, WarningLow = 4.0m, WarningHigh = 12.0m, CriticalHigh = 20.0m, CriticalLow = 2.0m, WarningLow = 4.0m, WarningHigh = 12.0m, CriticalHigh = 20.0m,
CreatedAt = DateTimeOffset.UtcNow 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); 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) }, Value = 97, Unit = "%", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-5), CreatedAt = now.AddMinutes(-5) },
// Normal temp for encounter2 // Normal temp for encounter2
new() { Id = Guid.NewGuid(), EncounterId = encounter2.Id, ObservationCode = "TEMP_C", 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); db.Observations.AddRange(observations);
@@ -6,6 +6,9 @@ public class Encounter
public EncounterStatus Status { get; set; } public EncounterStatus Status { get; set; }
public Department Department { get; set; } public Department Department { get; set; }
public string AttendingPhysician { get; set; } = null!; 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 AdmittedAt { get; set; }
public DateTimeOffset? DischargedAt { get; set; } public DateTimeOffset? DischargedAt { get; set; }
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
@@ -6,6 +6,10 @@ public class Patient
public string LastName { get; set; } = null!; public string LastName { get; set; } = null!;
public DateOnly DateOfBirth { get; set; } public DateOnly DateOfBirth { get; set; }
public string Gender { get; set; } = null!; 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 string Status { get; set; } = "active";
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
@@ -6,7 +6,12 @@ public enum AlertType
CriticalPotassiumMeqL, CriticalPotassiumMeqL,
CriticalSpo2, CriticalSpo2,
CriticalRespRate, CriticalRespRate,
CriticalWbcKUl CriticalWbcKUl,
CriticalSystolicBp,
CriticalDiastolicBp,
CriticalLactateMmolL,
CriticalAvpu,
CriticalGlucoseMgDl
} }
public static class AlertTypeExtensions public static class AlertTypeExtensions
@@ -20,6 +25,11 @@ public static class AlertTypeExtensions
AlertType.CriticalSpo2 => "CRITICAL_SPO2", AlertType.CriticalSpo2 => "CRITICAL_SPO2",
AlertType.CriticalRespRate => "CRITICAL_RESP_RATE", AlertType.CriticalRespRate => "CRITICAL_RESP_RATE",
AlertType.CriticalWbcKUl => "CRITICAL_WBC_K_UL", 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)) _ => throw new ArgumentOutOfRangeException(nameof(t))
}; };
@@ -32,6 +42,11 @@ public static class AlertTypeExtensions
"CRITICAL_SPO2" => AlertType.CriticalSpo2, "CRITICAL_SPO2" => AlertType.CriticalSpo2,
"CRITICAL_RESP_RATE" => AlertType.CriticalRespRate, "CRITICAL_RESP_RATE" => AlertType.CriticalRespRate,
"CRITICAL_WBC_K_UL" => AlertType.CriticalWbcKUl, "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}'") _ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert type: '{v}'")
}; };
@@ -44,6 +59,11 @@ public static class AlertTypeExtensions
"SPO2" => AlertType.CriticalSpo2, "SPO2" => AlertType.CriticalSpo2,
"RESP_RATE" => AlertType.CriticalRespRate, "RESP_RATE" => AlertType.CriticalRespRate,
"WBC_K_UL" => AlertType.CriticalWbcKUl, "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( _ => throw new ArgumentOutOfRangeException(
nameof(observationCode), $"No critical alert type for observation code '{observationCode}'") nameof(observationCode), $"No critical alert type for observation code '{observationCode}'")
}; };
@@ -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}'")
};
}
@@ -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<BloodType>
{
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<BloodType?>
{
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());
}
}
}
@@ -8,6 +8,8 @@ public class PatientEncounterDocument
public string Status { get; set; } = null!; public string Status { get; set; } = null!;
public string AttendingPhysician { get; set; } = null!; public string AttendingPhysician { get; set; } = null!;
public DateTimeOffset AdmittedAt { get; set; } public DateTimeOffset AdmittedAt { get; set; }
public string? RoomBed { get; set; }
public string? AdmissionReason { get; set; }
public int OpenAlertCount { get; set; } public int OpenAlertCount { get; set; }
public DateTimeOffset? LastObservationAt { get; set; } public DateTimeOffset? LastObservationAt { get; set; }
} }
@@ -0,0 +1,612 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260618050245_AddPatientClinicalDemographics")]
partial class AddPatientClinicalDemographics
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlertThreshold", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<decimal?>("CriticalHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_high");
b.Property<decimal?>("CriticalLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_low");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal?>("WarningHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_high");
b.Property<decimal?>("WarningLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_low");
b.HasKey("Id");
b.HasIndex("ObservationCode")
.IsUnique();
b.ToTable("alert_thresholds", (string)null);
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("AcknowledgedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acknowledged_at");
b.Property<string>("AcknowledgedBy")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("acknowledged_by");
b.Property<string>("AlertType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("alert_type");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("text")
.HasColumnName("details");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<Guid?>("ObservationId")
.HasColumnType("uuid")
.HasColumnName("observation_id");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("severity");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'OPEN'");
b.Property<DateTimeOffset>("TriggeredAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("triggered_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("EncounterId", "TriggeredAt");
b.HasIndex("PatientId", "TriggeredAt");
b.HasIndex("Severity", "TriggeredAt")
.HasFilter("status = 'OPEN'");
b.ToTable("clinical_alerts", null, t =>
{
t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL')");
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
});
});
modelBuilder.Entity("Encounter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("AdmittedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("admitted_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("AttendingPhysician")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("attending_physician");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Department")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("department");
b.Property<DateTimeOffset?>("DischargedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("discharged_at");
b.Property<string>("EncounterType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("encounter_type");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'SCHEDULED'");
b.HasKey("Id");
b.HasIndex("PatientId", "AdmittedAt");
b.HasIndex("Status", "AdmittedAt")
.HasFilter("status = 'ACTIVE'");
b.ToTable("encounters", null, t =>
{
t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')");
t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
});
});
modelBuilder.Entity("Observation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("IdempotencyKey")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("idempotency_key");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<string>("Source")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("source")
.HasDefaultValueSql("'MANUAL'");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal>("Value")
.HasColumnType("decimal(10,3)")
.HasColumnName("value");
b.HasKey("Id");
b.HasIndex("IdempotencyKey")
.IsUnique()
.HasFilter("idempotency_key IS NOT NULL");
b.HasIndex("EncounterId", "ObservationCode", "RecordedAt");
b.ToTable("observations", null, t =>
{
t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')");
});
});
modelBuilder.Entity("Order", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("OrderType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("order_type");
b.Property<DateTimeOffset>("OrderedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("ordered_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("OrderedBy")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("ordered_by");
b.Property<DateTimeOffset?>("ResultedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resulted_at");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'PENDING'");
b.HasKey("Id");
b.HasIndex("EncounterId", "OrderedAt");
b.HasIndex("Status", "OrderedAt")
.HasFilter("status IN ('PENDING', 'IN_PROGRESS')");
b.ToTable("orders", null, t =>
{
t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')");
t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')");
});
});
modelBuilder.Entity("OutboxEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("PartitionKey")
.HasMaxLength(36)
.HasColumnType("character varying(36)")
.HasColumnName("partition_key");
b.Property<string>("Payload")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("payload");
b.Property<DateTimeOffset?>("ProcessedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_at");
b.Property<string>("Topic")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("topic");
b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasFilter("processed_at IS NULL");
b.ToTable("outbox_events", (string)null);
});
modelBuilder.Entity("Patient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Allergies")
.HasColumnType("text")
.HasColumnName("allergies");
b.Property<string>("BloodType")
.HasMaxLength(5)
.HasColumnType("character varying(5)")
.HasColumnName("blood_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateOnly>("DateOfBirth")
.HasColumnType("date")
.HasColumnName("date_of_birth");
b.Property<string>("EmergencyContactName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("emergency_contact_name");
b.Property<string>("EmergencyContactPhone")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("emergency_contact_phone");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("first_name");
b.Property<string>("Gender")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("gender");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("last_name");
b.Property<string>("Mrn")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("mrn");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("active")
.HasColumnName("status");
b.HasKey("Id");
b.HasIndex("Mrn")
.IsUnique();
b.ToTable("patients", (string)null);
});
modelBuilder.Entity("ReconciliationAlert", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("CheckType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("check_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("text")
.HasColumnName("details");
b.Property<Guid?>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<Guid?>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.HasKey("Id");
b.HasIndex("EncounterId");
b.HasIndex("PatientId");
b.HasIndex("CheckType", "EncounterId")
.HasFilter("resolved_at IS NULL");
b.ToTable("reconciliation_alerts", null, t =>
{
t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')");
});
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Alerts")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Encounter", b =>
{
b.HasOne("Patient", "Patient")
.WithMany("Encounters")
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Patient");
});
modelBuilder.Entity("Observation", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Observations")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Order", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Orders")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("ReconciliationAlert", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Patient", "Patient")
.WithMany()
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Encounter");
b.Navigation("Patient");
});
modelBuilder.Entity("Encounter", b =>
{
b.Navigation("Alerts");
b.Navigation("Observations");
b.Navigation("Orders");
});
modelBuilder.Entity("Patient", b =>
{
b.Navigation("Encounters");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddPatientClinicalDemographics : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "allergies",
table: "patients",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "blood_type",
table: "patients",
type: "character varying(5)",
maxLength: 5,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "emergency_contact_name",
table: "patients",
type: "character varying(200)",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "emergency_contact_phone",
table: "patients",
type: "character varying(20)",
maxLength: 20,
nullable: true);
}
/// <inheritdoc />
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");
}
}
}
@@ -0,0 +1,627 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260618060707_AddEncounterClinicalFields")]
partial class AddEncounterClinicalFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlertThreshold", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<decimal?>("CriticalHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_high");
b.Property<decimal?>("CriticalLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_low");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal?>("WarningHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_high");
b.Property<decimal?>("WarningLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_low");
b.HasKey("Id");
b.HasIndex("ObservationCode")
.IsUnique();
b.ToTable("alert_thresholds", (string)null);
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("AcknowledgedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acknowledged_at");
b.Property<string>("AcknowledgedBy")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("acknowledged_by");
b.Property<string>("AlertType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("alert_type");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("text")
.HasColumnName("details");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<Guid?>("ObservationId")
.HasColumnType("uuid")
.HasColumnName("observation_id");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("severity");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'OPEN'");
b.Property<DateTimeOffset>("TriggeredAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("triggered_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("EncounterId", "TriggeredAt");
b.HasIndex("PatientId", "TriggeredAt");
b.HasIndex("Severity", "TriggeredAt")
.HasFilter("status = 'OPEN'");
b.ToTable("clinical_alerts", null, t =>
{
t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL')");
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
});
});
modelBuilder.Entity("Encounter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("AdmissionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("admission_reason");
b.Property<DateTimeOffset>("AdmittedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("admitted_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("AttendingPhysician")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("attending_physician");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Department")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("department");
b.Property<string>("DischargeDiagnosis")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("discharge_diagnosis");
b.Property<DateTimeOffset?>("DischargedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("discharged_at");
b.Property<string>("EncounterType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("encounter_type");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<string>("RoomBed")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("room_bed");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'SCHEDULED'");
b.HasKey("Id");
b.HasIndex("PatientId", "AdmittedAt");
b.HasIndex("Status", "AdmittedAt")
.HasFilter("status = 'ACTIVE'");
b.ToTable("encounters", null, t =>
{
t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')");
t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
});
});
modelBuilder.Entity("Observation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("IdempotencyKey")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("idempotency_key");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<string>("Source")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("source")
.HasDefaultValueSql("'MANUAL'");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal>("Value")
.HasColumnType("decimal(10,3)")
.HasColumnName("value");
b.HasKey("Id");
b.HasIndex("IdempotencyKey")
.IsUnique()
.HasFilter("idempotency_key IS NOT NULL");
b.HasIndex("EncounterId", "ObservationCode", "RecordedAt");
b.ToTable("observations", null, t =>
{
t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')");
});
});
modelBuilder.Entity("Order", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("OrderType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("order_type");
b.Property<DateTimeOffset>("OrderedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("ordered_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("OrderedBy")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("ordered_by");
b.Property<DateTimeOffset?>("ResultedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resulted_at");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'PENDING'");
b.HasKey("Id");
b.HasIndex("EncounterId", "OrderedAt");
b.HasIndex("Status", "OrderedAt")
.HasFilter("status IN ('PENDING', 'IN_PROGRESS')");
b.ToTable("orders", null, t =>
{
t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')");
t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')");
});
});
modelBuilder.Entity("OutboxEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("PartitionKey")
.HasMaxLength(36)
.HasColumnType("character varying(36)")
.HasColumnName("partition_key");
b.Property<string>("Payload")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("payload");
b.Property<DateTimeOffset?>("ProcessedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_at");
b.Property<string>("Topic")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("topic");
b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasFilter("processed_at IS NULL");
b.ToTable("outbox_events", (string)null);
});
modelBuilder.Entity("Patient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Allergies")
.HasColumnType("text")
.HasColumnName("allergies");
b.Property<string>("BloodType")
.HasMaxLength(5)
.HasColumnType("character varying(5)")
.HasColumnName("blood_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateOnly>("DateOfBirth")
.HasColumnType("date")
.HasColumnName("date_of_birth");
b.Property<string>("EmergencyContactName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("emergency_contact_name");
b.Property<string>("EmergencyContactPhone")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("emergency_contact_phone");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("first_name");
b.Property<string>("Gender")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("gender");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("last_name");
b.Property<string>("Mrn")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("mrn");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("active")
.HasColumnName("status");
b.HasKey("Id");
b.HasIndex("Mrn")
.IsUnique();
b.ToTable("patients", (string)null);
});
modelBuilder.Entity("ReconciliationAlert", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("CheckType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("check_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("text")
.HasColumnName("details");
b.Property<Guid?>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<Guid?>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.HasKey("Id");
b.HasIndex("EncounterId");
b.HasIndex("PatientId");
b.HasIndex("CheckType", "EncounterId")
.HasFilter("resolved_at IS NULL");
b.ToTable("reconciliation_alerts", null, t =>
{
t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')");
});
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Alerts")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Encounter", b =>
{
b.HasOne("Patient", "Patient")
.WithMany("Encounters")
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Patient");
});
modelBuilder.Entity("Observation", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Observations")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Order", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Orders")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("ReconciliationAlert", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Patient", "Patient")
.WithMany()
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Encounter");
b.Navigation("Patient");
});
modelBuilder.Entity("Encounter", b =>
{
b.Navigation("Alerts");
b.Navigation("Observations");
b.Navigation("Orders");
});
modelBuilder.Entity("Patient", b =>
{
b.Navigation("Encounters");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddEncounterClinicalFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "admission_reason",
table: "encounters",
type: "character varying(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "discharge_diagnosis",
table: "encounters",
type: "character varying(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "room_bed",
table: "encounters",
type: "character varying(20)",
maxLength: 20,
nullable: true);
}
/// <inheritdoc />
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");
}
}
}
@@ -0,0 +1,627 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260618061534_ExpandAlertTypeCheckConstraint")]
partial class ExpandAlertTypeCheckConstraint
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlertThreshold", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<decimal?>("CriticalHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_high");
b.Property<decimal?>("CriticalLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_low");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal?>("WarningHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_high");
b.Property<decimal?>("WarningLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_low");
b.HasKey("Id");
b.HasIndex("ObservationCode")
.IsUnique();
b.ToTable("alert_thresholds", (string)null);
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("AcknowledgedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acknowledged_at");
b.Property<string>("AcknowledgedBy")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("acknowledged_by");
b.Property<string>("AlertType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("alert_type");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("text")
.HasColumnName("details");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<Guid?>("ObservationId")
.HasColumnType("uuid")
.HasColumnName("observation_id");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("severity");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'OPEN'");
b.Property<DateTimeOffset>("TriggeredAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("triggered_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("EncounterId", "TriggeredAt");
b.HasIndex("PatientId", "TriggeredAt");
b.HasIndex("Severity", "TriggeredAt")
.HasFilter("status = 'OPEN'");
b.ToTable("clinical_alerts", null, t =>
{
t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL')");
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
});
});
modelBuilder.Entity("Encounter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("AdmissionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("admission_reason");
b.Property<DateTimeOffset>("AdmittedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("admitted_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("AttendingPhysician")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("attending_physician");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Department")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("department");
b.Property<string>("DischargeDiagnosis")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("discharge_diagnosis");
b.Property<DateTimeOffset?>("DischargedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("discharged_at");
b.Property<string>("EncounterType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("encounter_type");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<string>("RoomBed")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("room_bed");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'SCHEDULED'");
b.HasKey("Id");
b.HasIndex("PatientId", "AdmittedAt");
b.HasIndex("Status", "AdmittedAt")
.HasFilter("status = 'ACTIVE'");
b.ToTable("encounters", null, t =>
{
t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')");
t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
});
});
modelBuilder.Entity("Observation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("IdempotencyKey")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("idempotency_key");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<string>("Source")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("source")
.HasDefaultValueSql("'MANUAL'");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal>("Value")
.HasColumnType("decimal(10,3)")
.HasColumnName("value");
b.HasKey("Id");
b.HasIndex("IdempotencyKey")
.IsUnique()
.HasFilter("idempotency_key IS NOT NULL");
b.HasIndex("EncounterId", "ObservationCode", "RecordedAt");
b.ToTable("observations", null, t =>
{
t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')");
});
});
modelBuilder.Entity("Order", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("OrderType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("order_type");
b.Property<DateTimeOffset>("OrderedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("ordered_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("OrderedBy")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("ordered_by");
b.Property<DateTimeOffset?>("ResultedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resulted_at");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'PENDING'");
b.HasKey("Id");
b.HasIndex("EncounterId", "OrderedAt");
b.HasIndex("Status", "OrderedAt")
.HasFilter("status IN ('PENDING', 'IN_PROGRESS')");
b.ToTable("orders", null, t =>
{
t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')");
t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')");
});
});
modelBuilder.Entity("OutboxEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("PartitionKey")
.HasMaxLength(36)
.HasColumnType("character varying(36)")
.HasColumnName("partition_key");
b.Property<string>("Payload")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("payload");
b.Property<DateTimeOffset?>("ProcessedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_at");
b.Property<string>("Topic")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("topic");
b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasFilter("processed_at IS NULL");
b.ToTable("outbox_events", (string)null);
});
modelBuilder.Entity("Patient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Allergies")
.HasColumnType("text")
.HasColumnName("allergies");
b.Property<string>("BloodType")
.HasMaxLength(5)
.HasColumnType("character varying(5)")
.HasColumnName("blood_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateOnly>("DateOfBirth")
.HasColumnType("date")
.HasColumnName("date_of_birth");
b.Property<string>("EmergencyContactName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("emergency_contact_name");
b.Property<string>("EmergencyContactPhone")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("emergency_contact_phone");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("first_name");
b.Property<string>("Gender")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("gender");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("last_name");
b.Property<string>("Mrn")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("mrn");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("active")
.HasColumnName("status");
b.HasKey("Id");
b.HasIndex("Mrn")
.IsUnique();
b.ToTable("patients", (string)null);
});
modelBuilder.Entity("ReconciliationAlert", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("CheckType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("check_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("text")
.HasColumnName("details");
b.Property<Guid?>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<Guid?>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.HasKey("Id");
b.HasIndex("EncounterId");
b.HasIndex("PatientId");
b.HasIndex("CheckType", "EncounterId")
.HasFilter("resolved_at IS NULL");
b.ToTable("reconciliation_alerts", null, t =>
{
t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')");
});
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Alerts")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Encounter", b =>
{
b.HasOne("Patient", "Patient")
.WithMany("Encounters")
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Patient");
});
modelBuilder.Entity("Observation", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Observations")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Order", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Orders")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("ReconciliationAlert", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Patient", "Patient")
.WithMany()
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Encounter");
b.Navigation("Patient");
});
modelBuilder.Entity("Encounter", b =>
{
b.Navigation("Alerts");
b.Navigation("Observations");
b.Navigation("Orders");
});
modelBuilder.Entity("Patient", b =>
{
b.Navigation("Encounters");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class ExpandAlertTypeCheckConstraint : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
ALTER TABLE clinical_alerts DROP CONSTRAINT chk_clinical_alerts_alert_type;
ALTER TABLE clinical_alerts ADD CONSTRAINT chk_clinical_alerts_alert_type
CHECK (alert_type IN (
'SEPSIS_WARNING',
'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L',
'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL',
'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L',
'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL'
));
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -168,6 +168,11 @@ namespace VigilCareClinicalAPI.Migrations
.HasColumnName("id") .HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()"); .HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("AdmissionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("admission_reason");
b.Property<DateTimeOffset>("AdmittedAt") b.Property<DateTimeOffset>("AdmittedAt")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
@@ -192,6 +197,11 @@ namespace VigilCareClinicalAPI.Migrations
.HasColumnType("character varying(100)") .HasColumnType("character varying(100)")
.HasColumnName("department"); .HasColumnName("department");
b.Property<string>("DischargeDiagnosis")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("discharge_diagnosis");
b.Property<DateTimeOffset?>("DischargedAt") b.Property<DateTimeOffset?>("DischargedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("discharged_at"); .HasColumnName("discharged_at");
@@ -206,6 +216,11 @@ namespace VigilCareClinicalAPI.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("patient_id"); .HasColumnName("patient_id");
b.Property<string>("RoomBed")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("room_bed");
b.Property<string>("Status") b.Property<string>("Status")
.IsRequired() .IsRequired()
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -408,6 +423,15 @@ namespace VigilCareClinicalAPI.Migrations
.HasColumnName("id") .HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()"); .HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Allergies")
.HasColumnType("text")
.HasColumnName("allergies");
b.Property<string>("BloodType")
.HasMaxLength(5)
.HasColumnType("character varying(5)")
.HasColumnName("blood_type");
b.Property<DateTimeOffset>("CreatedAt") b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
@@ -418,6 +442,16 @@ namespace VigilCareClinicalAPI.Migrations
.HasColumnType("date") .HasColumnType("date")
.HasColumnName("date_of_birth"); .HasColumnName("date_of_birth");
b.Property<string>("EmergencyContactName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("emergency_contact_name");
b.Property<string>("EmergencyContactPhone")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("emergency_contact_phone");
b.Property<string>("FirstName") b.Property<string>("FirstName")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@@ -1,4 +1,4 @@
public record EncounterStatusChangedEvent( public record EncounterStatusChangedEvent(
Guid EncounterId, Guid PatientId, string Mrn, string PatientName, Guid EncounterId, Guid PatientId, string Mrn, string PatientName,
string? PreviousStatus, string NewStatus, string Department, string? PreviousStatus, string NewStatus, string Department,
string AttendingPhysician, DateTimeOffset AdmittedAt, DateTimeOffset ChangedAt); string AttendingPhysician, string? RoomBed, string? AdmissionReason, DateTimeOffset AdmittedAt, DateTimeOffset ChangedAt);
@@ -1,4 +1,6 @@
public record OpenEncounterRequest( public record OpenEncounterRequest(
EncounterType EncounterType, EncounterType EncounterType,
Department Department, Department Department,
string AttendingPhysician); string AttendingPhysician,
string? RoomBed = null,
string? AdmissionReason = null);
@@ -1 +1,3 @@
public record TransitionStatusRequest(EncounterStatus Status); public record TransitionStatusRequest(
EncounterStatus Status,
string? DischargeDiagnosis = null);
@@ -2,4 +2,8 @@ public record RegisterPatientRequest(
string FirstName, string FirstName,
string LastName, string LastName,
DateOnly DateOfBirth, DateOnly DateOfBirth,
string Gender); string Gender,
BloodType? BloodType = null,
string? Allergies = null,
string? EmergencyContactName = null,
string? EmergencyContactPhone = null);
+1
View File
@@ -93,6 +93,7 @@ try
.AddJsonOptions(opts => .AddJsonOptions(opts =>
{ {
opts.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; opts.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
opts.JsonSerializerOptions.Converters.Add(new BloodTypeJsonConverter());
opts.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); opts.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
opts.JsonSerializerOptions.Converters.Add(new ObservationSourceJsonConverter()); opts.JsonSerializerOptions.Converters.Add(new ObservationSourceJsonConverter());
opts.JsonSerializerOptions.Converters.Add(new DepartmentJsonConverter()); opts.JsonSerializerOptions.Converters.Add(new DepartmentJsonConverter());
@@ -32,7 +32,7 @@ public class EncounterService : IEncounterService
} }
public async Task<EncounterStatusTransitionResult> TransitionStatusAsync( public async Task<EncounterStatusTransitionResult> TransitionStatusAsync(
Guid encounterId, EncounterStatus targetStatus) Guid encounterId, EncounterStatus targetStatus, string? dischargeDiagnosis = null)
{ {
var encounter = await _db.Encounters var encounter = await _db.Encounters
.Include(e => e.Patient) .Include(e => e.Patient)
@@ -50,7 +50,10 @@ public class EncounterService : IEncounterService
encounter.Status = targetStatus; encounter.Status = targetStatus;
if (targetStatus == EncounterStatus.Discharged) if (targetStatus == EncounterStatus.Discharged)
{
encounter.DischargedAt = DateTimeOffset.UtcNow; encounter.DischargedAt = DateTimeOffset.UtcNow;
encounter.DischargeDiagnosis = dischargeDiagnosis;
}
_db.OutboxEvents.Add(new OutboxEvent _db.OutboxEvents.Add(new OutboxEvent
{ {
@@ -66,6 +69,8 @@ public class EncounterService : IEncounterService
newStatus = targetStatus.ToDbString(), newStatus = targetStatus.ToDbString(),
department = encounter.Department.ToDbString(), department = encounter.Department.ToDbString(),
attendingPhysician = encounter.AttendingPhysician, attendingPhysician = encounter.AttendingPhysician,
roomBed = encounter.RoomBed,
admissionReason = encounter.AdmissionReason,
admittedAt = encounter.AdmittedAt, admittedAt = encounter.AdmittedAt,
changedAt = DateTimeOffset.UtcNow changedAt = DateTimeOffset.UtcNow
}), }),
@@ -1,6 +1,7 @@
public interface IEncounterService public interface IEncounterService
{ {
Task<Encounter> GetByIdAsync(Guid id); Task<Encounter> GetByIdAsync(Guid id);
Task<EncounterStatusTransitionResult> TransitionStatusAsync(Guid encounterId, EncounterStatus targetStatus); Task<EncounterStatusTransitionResult> TransitionStatusAsync(
Guid encounterId, EncounterStatus targetStatus, string? dischargeDiagnosis = null);
Task<object> GetTimelineAsync(Guid encounterId); Task<object> GetTimelineAsync(Guid encounterId);
} }
@@ -18,6 +18,10 @@ public class PatientService : IPatientService
LastName = req.LastName, LastName = req.LastName,
DateOfBirth = req.DateOfBirth, DateOfBirth = req.DateOfBirth,
Gender = req.Gender, Gender = req.Gender,
BloodType = req.BloodType,
Allergies = req.Allergies,
EmergencyContactName = req.EmergencyContactName,
EmergencyContactPhone = req.EmergencyContactPhone,
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}; };
_db.Patients.Add(patient); _db.Patients.Add(patient);
@@ -84,6 +88,8 @@ public class PatientService : IPatientService
Status = EncounterStatus.Active, Status = EncounterStatus.Active,
Department = req.Department, Department = req.Department,
AttendingPhysician = req.AttendingPhysician, AttendingPhysician = req.AttendingPhysician,
RoomBed = req.RoomBed,
AdmissionReason = req.AdmissionReason,
AdmittedAt = DateTimeOffset.UtcNow, AdmittedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}; };
@@ -103,6 +109,8 @@ public class PatientService : IPatientService
newStatus = encounter.Status.ToDbString(), newStatus = encounter.Status.ToDbString(),
department = encounter.Department.ToDbString(), department = encounter.Department.ToDbString(),
attendingPhysician = encounter.AttendingPhysician, attendingPhysician = encounter.AttendingPhysician,
roomBed = encounter.RoomBed,
admissionReason = encounter.AdmissionReason,
admittedAt = encounter.AdmittedAt, admittedAt = encounter.AdmittedAt,
changedAt = DateTimeOffset.UtcNow changedAt = DateTimeOffset.UtcNow
}), }),
@@ -12,6 +12,11 @@ public static class PlausibilityValidator
["RESP_RATE"] = (1, 80), ["RESP_RATE"] = (1, 80),
["WBC_K_UL"] = (0.1m, 500), ["WBC_K_UL"] = (0.1m, 500),
["GLUCOSE_MG_DL"] = (10, 1500), ["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) public static bool IsPlausible(string observationCode, decimal value, out string? reason)