feature: Trend Detection & Alert Suppression Windows
This commit is contained in:
@@ -59,12 +59,14 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
|
||||
- **Elasticsearch CQRS Projection** — `EsIndexerService` consumer group upserts `patient_encounters` documents, appends to the `observations` index, increments `openAlertCount` on alert events, and stamps `news2Score` / `news2RiskLevel` when a NEWS2 alert is generated; patient/encounter search; per-encounter observation trend (hourly avg/min/max); alert volume summary by department and severity; population query (numeric range aggregation across all patients)
|
||||
- **Sepsis Early Warning Engine** — `SepsisEngineService` Kafka consumer evaluates SIRS criteria (temperature, heart rate, respiratory rate, WBC) per encounter using Redis keys with a 30-minute TTL sliding window; on ≥2 active criteria, inserts a `SEPSIS_WARNING / CRITICAL` alert idempotently (`INSERT WHERE NOT EXISTS`)
|
||||
- **NEWS2 Composite Scoring Engine** — `News2ScoringService` Kafka consumer (`news2-scoring`) evaluates seven vital parameters per encounter (`RESP_RATE`, `SPO2`, `SYSTOLIC_BP`, `HEART_RATE`, `AVPU`, `TEMP_C`, `SUPPLEMENTAL_O2`) using Redis keys with a 4-hour TTL; when all seven are present, computes the official NEWS2 aggregate score, persists to `news2_scores`, and creates `NEWS2_WARNING` (score 5–6 or single param = 3) or `NEWS2_EMERGENCY` (score ≥ 7) alerts idempotently; `GET /encounters/:id/news2/current` and `/history` expose score history; Prometheus `news2_scores_total` and `news2_scoring_duration_seconds`
|
||||
- **Trend Detection Engine** — `TrendAnalyzerService` Kafka consumer (`trend-analyzer`) tracks rate-of-change for five vital parameters (`HEART_RATE`, `RESP_RATE`, `SYSTOLIC_BP`, `TEMP_C`, `SPO2`) using Redis sliding-window history; when velocity exceeds configured thresholds (e.g. 72→95 bpm in 30 min), creates a `RAPID_DETERIORATION` alert even if the current value is below warning thresholds; Prometheus `trend_alerts_total` and `trend_analysis_duration_seconds`
|
||||
- **Alert Suppression Windows** — acknowledging a suppressible alert (`WARNING_*`, `NEWS2_WARNING`) sets a Redis key `suppress:{encounterId}:{alertType}` with a configurable TTL (default 30 min from `AlertSuppression` config; optional per-code override via `alert_thresholds.suppression_window_minutes`); `WarningEvaluator` and `News2Detector` check suppression before creating new warning alerts; critical alerts (`CRITICAL_*`, `NEWS2_EMERGENCY`, `SEPSIS_WARNING`, `RAPID_DETERIORATION`) are never suppressed; observations and NEWS2 scores continue to persist during suppression; Prometheus `alert_suppressions_total`
|
||||
- **RabbitMQ Notification Workers** — `NotificationPublisherService` reads `alert.generated` from Kafka and publishes paging jobs to `alerts.paging.queue`; `PagingWorkerService` sends the page and waits for acknowledgment; if no ack arrives before timeout it NACKs to `alerts.paging.dlq` with `x-message-ttl = 300000ms`; if the host is stopping, in-flight paging messages are NACKed with `requeue=true` so they are retried after restart and do not false-escalate; `EscalationWorkerService` pages the on-call backup and sets alert status to `escalated`; `DischargeSummaryWorkerService` reads `encounter.status.changed`, generates a discharge summary, and stores it in MinIO under `/discharge-summaries/{encounterId}/summary.pdf`
|
||||
- **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 at least one successful upload; shutdown flush uses an uncanceled token so MinIO writes complete on Ctrl+C; `kafka_partition` and `kafka_offset` columns provide audit lineage
|
||||
- **Reconciliation Jobs** — three scheduled checks: (1) unacknowledged CRITICAL alerts older than 30 minutes, (2) pending orders without results after 4 hours, (3) active inpatients with no observation in 2 hours; each finding creates a `reconciliation_alerts` row and publishes to RabbitMQ
|
||||
- **Standard Envelope** — all responses use a consistent `{ success, statusCode, data, error }` wrapper; validation errors use the same shape; `ApiBehaviorOptions` overridden so model validation also produces the standard envelope with field-level `details`
|
||||
- **Input Validation** — FluentValidation validators on all request DTOs (patient registration, encounter open, observation ingest, alert acknowledge, alert thresholds, orders); invalid requests return 400 before reaching the service layer
|
||||
- **Observability** — Serilog structured logging enriched with `correlationId`, `encounterId`, `patientId` on alert paths; Seq sink (`http://localhost:5345`); Prometheus (`http://localhost:9101`) scrapes `GET /metrics`; ten 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
|
||||
- **Observability** — Serilog structured logging enriched with `correlationId`, `encounterId`, `patientId` on alert paths; Seq sink (`http://localhost:5345`); Prometheus (`http://localhost:9101`) scrapes `GET /metrics`; thirteen 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)
|
||||
|
||||
---
|
||||
@@ -78,7 +80,7 @@ HTTP request
|
||||
→ Controllers
|
||||
→ Services
|
||||
├── PostgreSQL (EF Core — writes, keyed reads)
|
||||
├── Redis (threshold cache, SIRS state, NEWS2 parameter state)
|
||||
├── Redis (threshold cache, SIRS state, NEWS2 parameter state, trend history, alert suppression keys)
|
||||
└── OutboxEvent (same transaction as domain write)
|
||||
|
||||
IHostedServices (background):
|
||||
@@ -91,6 +93,8 @@ IHostedServices (background):
|
||||
SepsisEngineService → Kafka → Redis SIRS state → PostgreSQL alert (consumer group: sepsis-engine)
|
||||
WarningAlertService → Kafka → WarningEvaluator → PostgreSQL WARNING alert (consumer group: warning-evaluator)
|
||||
News2ScoringService → Kafka → News2Detector → Redis NEWS2 state → PostgreSQL score + alert (consumer group: news2-scoring)
|
||||
TrendAnalyzerService → Kafka → TrendDetector → Redis trend history → PostgreSQL RAPID_DETERIORATION alert (consumer group: trend-analyzer)
|
||||
AlertSuppressionService → Redis suppress:{enc}:{type} keys set on acknowledge; read by WarningEvaluator + News2Detector
|
||||
NotificationPublisherService → Kafka → RabbitMQ paging.queue (consumer group: notification-publisher)
|
||||
PagingWorkerService → RabbitMQ paging.queue → log page → NACK on timeout (or requeue on shutdown)
|
||||
EscalationWorkerService → RabbitMQ escalation.queue → update alert status
|
||||
@@ -100,7 +104,7 @@ IHostedServices (background):
|
||||
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, NEWS2, escalation paths
|
||||
ClinicalMetrics (singleton) → inline counters/histogram from ingest, SIRS, NEWS2, trend, suppression, 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.
|
||||
@@ -174,16 +178,20 @@ VigilCareClinicalAPI/
|
||||
│ ├── AlertThresholdService.cs # CRUD + Redis write-through invalidation
|
||||
│ ├── ObservationService.cs # Ingest transaction: idempotency → plausibility → threshold → alert → outbox; emits Prometheus counters
|
||||
│ ├── ObservationQueryService.cs # Cursor-paginated history
|
||||
│ ├── AlertService.cs # Acknowledge, resolve, list
|
||||
│ ├── AlertService.cs # Acknowledge (sets suppression), resolve, list
|
||||
│ ├── AlertSuppressionService.cs # Redis suppress:{enc}:{type} TTL keys
|
||||
│ ├── OrderService.cs # Order lifecycle; status machine; ConflictException on illegal transitions
|
||||
│ ├── News2Service.cs # Current score + cursor-paginated history from PostgreSQL
|
||||
│ ├── WarningEvaluator.cs # Warning-range threshold evaluation; idempotent alert INSERT
|
||||
│ ├── WarningEvaluator.cs # Warning-range evaluation; suppression check; idempotent alert INSERT
|
||||
│ ├── AnalyticsService.cs # Elasticsearch query wrappers
|
||||
│ └── PlausibilityValidator.cs # Per-code numeric range guard
|
||||
├── Trend/
|
||||
│ ├── TrendCalculator.cs # Pure static rate-of-change logic
|
||||
│ └── TrendDetector.cs # Redis history + RAPID_DETERIORATION alert creation
|
||||
├── Validators/ # FluentValidation — RegisterPatient, OpenEncounter, IngestObservation, …
|
||||
├── Observability/
|
||||
│ └── Metrics/
|
||||
│ └── ClinicalMetrics.cs # Ten Prometheus metric families (counters, histograms, gauges)
|
||||
│ └── ClinicalMetrics.cs # Thirteen Prometheus metric families (counters, histograms, gauges)
|
||||
├── BackgroundServices/
|
||||
│ ├── ThresholdCacheLoader.cs # Pre-loads all thresholds into Redis on startup
|
||||
│ ├── KafkaTopicProvisioner.cs # Creates topics with NumPartitions from config
|
||||
@@ -198,6 +206,7 @@ VigilCareClinicalAPI/
|
||||
│ ├── SepsisEngineService.cs # consumer group: sepsis-engine; SIRS eval via Redis TTL keys
|
||||
│ ├── WarningAlertService.cs # consumer group: warning-evaluator; observation.recorded → WARNING alerts
|
||||
│ ├── News2ScoringService.cs # consumer group: news2-scoring; observation.recorded → NEWS2 score + alert
|
||||
│ ├── TrendAnalyzerService.cs # consumer group: trend-analyzer; observation.recorded → RAPID_DETERIORATION alert
|
||||
│ ├── Notifications/
|
||||
│ │ ├── NotificationPublisherService.cs # consumer group: notification-publisher; alert.generated → RabbitMQ paging.queue
|
||||
│ │ ├── PagingWorkerService.cs # RabbitMQ consumer; logs page; NACK on ack timeout → DLQ, requeue on graceful shutdown
|
||||
@@ -274,7 +283,10 @@ tests/
|
||||
├── OrderLifecycleTests.cs # Orders API — create, list, record result, illegal transition 409
|
||||
├── ValidationTests.cs # FluentValidation — empty fields, threshold ordering, order description
|
||||
├── News2CalculatorTests.cs # Boundary tests for all seven NEWS2 scoring tables
|
||||
└── News2DetectorTests.cs # NEWS2 detector — score tiers, alerts, idempotency, incomplete set
|
||||
├── News2DetectorTests.cs # NEWS2 detector — score tiers, alerts, idempotency, incomplete set
|
||||
├── TrendCalculatorTests.cs # Pure unit tests — rate-of-change, threshold direction, describe
|
||||
├── TrendDetectorTests.cs # Trend detector — rapid climb, stable, idempotent, non-trend code
|
||||
└── AlertSuppressionTests.cs # Suppression on acknowledge, read-side skip, TTL expiry
|
||||
|
||||
scripts/
|
||||
├── run-api-redis-tests.sh # Phase 1 — patient/encounter/threshold + Redis cache
|
||||
@@ -287,10 +299,11 @@ scripts/
|
||||
├── run-phase9-verification.sh # Phase 9 — data lake tests, Kafka offsets, MinIO Parquet, DuckDB schema
|
||||
├── run-phase10-verification.sh # Phase 10 — 12 Redis thresholds, clinical enrichment, ES pipeline, integration tests
|
||||
├── run-phase11-verification.sh # Phase 11 — warning alerts, orders API, validation, integration tests
|
||||
└── run-phase12-verification.sh # Phase 12 — NEWS2 end-to-end pipeline, API, ES, Prometheus, integration tests
|
||||
├── run-phase12-verification.sh # Phase 12 — NEWS2 end-to-end pipeline, API, ES, Prometheus, integration tests
|
||||
└── run-phase13-verification.sh # Phase 13 — trend detection, alert suppression, consumer lag, integration tests
|
||||
|
||||
docs/
|
||||
├── plans/ # Phase 1–12 implementation and verification guides
|
||||
├── plans/ # Phase 1–13 implementation and verification guides
|
||||
├── decisions/
|
||||
│ ├── data-lake-design.md # Parquet vs JSON, partitioning, replay rationale
|
||||
│ └── sepsis-engine-design.md # SIRS sliding window and idempotent alert design
|
||||
@@ -438,6 +451,9 @@ Integration tests use `WebApplicationFactory` with a `Testing` environment and T
|
||||
| `ValidationTests` | 11 | FluentValidation 400 on empty first name, invalid threshold order, empty order description |
|
||||
| `News2CalculatorTests` | 12 | Boundary tests for all seven NEWS2 scoring tables and risk-level determination |
|
||||
| `News2DetectorTests` | 12 | NEWS2 detector — score tiers, alert creation, incomplete parameters, idempotency |
|
||||
| `TrendCalculatorTests` | 13 | Pure unit tests — delta/time rate, SPO2/BP decline direction, describe formatting |
|
||||
| `TrendDetectorTests` | 13 | Trend detector — rapid HR climb alert, stable high HR, idempotency, non-trend code |
|
||||
| `AlertSuppressionTests` | 13 | Acknowledge sets Redis key, suppressed warning skipped, critical/NEWS2 emergency never suppressed, TTL expiry |
|
||||
|
||||
### Verification Scripts
|
||||
|
||||
@@ -449,6 +465,13 @@ With the API running (`dotnet run`) and Docker Compose up:
|
||||
./scripts/run-phase10-verification.sh # 12 Redis thresholds, clinical enrichment, ES pipeline, Phase 10 integration tests
|
||||
./scripts/run-phase11-verification.sh # Warning alert pipeline, orders API, FluentValidation, Phase 11 integration tests
|
||||
./scripts/run-phase12-verification.sh # NEWS2 end-to-end pipeline, API, Elasticsearch, Prometheus, Phase 12 integration tests
|
||||
./scripts/run-phase13-verification.sh # Trend detection, alert suppression, consumer lag, Phase 13 integration tests
|
||||
```
|
||||
|
||||
Phase 13 unit/integration tests only:
|
||||
|
||||
```bash
|
||||
dotnet test --filter "FullyQualifiedName~Trend|FullyQualifiedName~Suppression"
|
||||
```
|
||||
|
||||
Per-phase test runners (subset of `dotnet test`):
|
||||
@@ -481,16 +504,19 @@ See `docs/plans/phase-8-plan.md` through `docs/plans/phase-12-plan.md` for manua
|
||||
|
||||
## Prometheus Metrics
|
||||
|
||||
`GET /metrics` exposes ten application metric families registered in `ClinicalMetrics`. Three background collectors poll PostgreSQL and Kafka every 30 seconds; counters and histograms are updated inline during request handling and background processing.
|
||||
`GET /metrics` exposes thirteen application metric families registered in `ClinicalMetrics`. Three background collectors poll PostgreSQL and Kafka every 30 seconds; counters and histograms are updated inline during request handling and background processing.
|
||||
|
||||
| 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`, `News2Detector` |
|
||||
| `clinical_alerts_total` | Counter | `alert_type`, `severity` | `ObservationService`, `SirsDetector`, `News2Detector`, `TrendDetector`, `WarningEvaluator` |
|
||||
| `sirs_detections_total` | Counter | — | `SirsDetector` — only on successful idempotent insert |
|
||||
| `news2_scores_total` | Counter | `risk_level` | `News2Detector` — on each persisted score (`LOW`, `MEDIUM`, `HIGH`, …) |
|
||||
| `news2_scoring_duration_seconds` | Histogram | — | `News2Detector` — Redis update through score persistence |
|
||||
| `trend_alerts_total` | Counter | `observation_code` | `TrendDetector` — on each `RAPID_DETERIORATION` alert created |
|
||||
| `trend_analysis_duration_seconds` | Histogram | — | `TrendDetector` — per-observation trend evaluation |
|
||||
| `alert_suppressions_total` | Counter | `alert_type` | `AlertSuppressionService` — on each suppression window set after acknowledge |
|
||||
| `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 |
|
||||
@@ -988,7 +1014,7 @@ Exchange: `clinical.notifications.exchange` (direct)
|
||||
|
||||
| Topic | Partition key | Consumer groups |
|
||||
|---|---|---|
|
||||
| `observation.recorded` | `encounterId` | `es-indexer`, `sepsis-engine`, `warning-evaluator`, `news2-scoring`, `data-lake-writer` |
|
||||
| `observation.recorded` | `encounterId` | `es-indexer`, `sepsis-engine`, `warning-evaluator`, `news2-scoring`, `trend-analyzer`, `data-lake-writer` |
|
||||
| `alert.generated` | `encounterId` | `es-indexer`, `notification-publisher`, `data-lake-writer` |
|
||||
| `encounter.status.changed` | `encounterId` | `es-indexer`, `data-lake-writer` |
|
||||
|
||||
@@ -1142,5 +1168,6 @@ Twelve phases from the project roadmap are implemented and verified. Integration
|
||||
| 10 | Clinical data model expansion — `BloodType`, patient allergies/emergency contact, encounter room/bed/admission/discharge fields; five new observation codes (`SYSTOLIC_BP`, `DIASTOLIC_BP`, `LACTATE_MMOL_L`, `AVPU`, `SUPPLEMENTAL_O2`); `GLUCOSE_MG_DL` threshold fix; 12 seeded thresholds; `ClinicalDemographicsAndObservationTests`; `run-phase10-verification.sh` | Done |
|
||||
| 11 | Warning alert consumer (`WarningAlertService` / `warning-evaluator`); 10 `Warning*` alert types; Orders API (`OrdersController`, `OrderService`); FluentValidation on all request DTOs; `WarningAlertTests`, `OrderLifecycleTests`, `ValidationTests`; `run-phase11-verification.sh` | Done |
|
||||
| 12 | NEWS2 composite scoring (`News2Calculator`, `News2Detector`, `News2ScoringService`); `news2_scores` table; `NEWS2_WARNING` / `NEWS2_EMERGENCY` alert types; `News2Controller` (current + history); ES `news2Score` / `news2RiskLevel` projection; Prometheus NEWS2 metrics; `News2CalculatorTests`, `News2DetectorTests`; `run-phase12-verification.sh` | Done |
|
||||
| 13 | Trend detection (`TrendCalculator`, `TrendDetector`, `TrendAnalyzerService`); `RAPID_DETERIORATION` alert type; alert suppression windows (`AlertSuppressionService`, Redis `suppress:{enc}:{type}`); `TrendCalculatorTests`, `TrendDetectorTests`, `AlertSuppressionTests`; `run-phase13-verification.sh` | Done |
|
||||
|
||||
**Optional follow-up:** execute and document the Kafka replay demonstration for the data lake (reset `data-lake-writer` offsets, clear MinIO prefixes, restart API, confirm Parquet rebuild). See `docs/plans/phase-9-plan.md` § Replay demonstration.
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StackExchange.Redis;
|
||||
|
||||
[Collection("Integration")]
|
||||
public class AlertSuppressionTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ApiFixture _fixture;
|
||||
private Guid _patientId;
|
||||
private Guid _encounterId;
|
||||
|
||||
public AlertSuppressionTests(ApiFixture fixture) => _fixture = fixture;
|
||||
|
||||
public async Task InitializeAsync() => await ResetAndSeedThresholdAsync();
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
private async Task ResetAndSeedThresholdAsync()
|
||||
{
|
||||
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-SUP-001", FirstName = "Suppress", LastName = "Test",
|
||||
DateOfBirth = new DateOnly(1982, 8, 10), Gender = "M", CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
var encounter = new Encounter
|
||||
{
|
||||
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
|
||||
Status = EncounterStatus.Active, Department = Department.Icu,
|
||||
AttendingPhysician = "Dr. Suppress", AdmittedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Patients.Add(patient);
|
||||
db.Encounters.Add(encounter);
|
||||
db.AlertThresholds.Add(new AlertThreshold
|
||||
{
|
||||
Id = Guid.NewGuid(), ObservationCode = "HEART_RATE",
|
||||
DisplayName = "Heart Rate", Unit = "bpm",
|
||||
CriticalLow = 30, WarningLow = 50, WarningHigh = 100, CriticalHigh = 150,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
await redis.GetDatabase().StringSetAsync("threshold:HEART_RATE",
|
||||
"""{"ObservationCode":"HEART_RATE","CriticalLow":30,"WarningLow":50,"WarningHigh":100,"CriticalHigh":150}""");
|
||||
|
||||
_patientId = patient.Id;
|
||||
_encounterId = encounter.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeWarning_SetsSuppressionKey()
|
||||
{
|
||||
await ResetAndSeedThresholdAsync();
|
||||
|
||||
var alertId = await SeedAlertAsync(AlertType.WarningHeartRate, AlertSeverity.Warning);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var alerts = scope.ServiceProvider.GetRequiredService<IAlertService>();
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
|
||||
await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("nurse-1", "monitoring"));
|
||||
|
||||
var key = AlertSuppressionService.SuppressionKey(_encounterId, AlertType.WarningHeartRate);
|
||||
(await redis.GetDatabase().KeyExistsAsync(key)).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuppressedWarning_SkipsAlertCreation()
|
||||
{
|
||||
await ResetAndSeedThresholdAsync();
|
||||
|
||||
var alertId = await SeedAlertAsync(AlertType.WarningHeartRate, AlertSeverity.Warning);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var alerts = scope.ServiceProvider.GetRequiredService<IAlertService>();
|
||||
var evaluator = scope.ServiceProvider.GetRequiredService<WarningEvaluator>();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("nurse-1", "monitoring"));
|
||||
await alerts.ResolveAsync(alertId);
|
||||
|
||||
var created = await evaluator.EvaluateAsync(
|
||||
Guid.NewGuid(), _encounterId, _patientId, "HEART_RATE", 105m);
|
||||
|
||||
created.Should().BeFalse();
|
||||
(await db.ClinicalAlerts.CountAsync()).Should().Be(1,
|
||||
"suppressed warning must not create a new alert after resolve");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CriticalAlert_NotSuppressible()
|
||||
{
|
||||
await ResetAndSeedThresholdAsync();
|
||||
|
||||
var alertId = await SeedAlertAsync(AlertType.CriticalHeartRate, AlertSeverity.Critical);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var alerts = scope.ServiceProvider.GetRequiredService<IAlertService>();
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
|
||||
await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("dr-1", "treating"));
|
||||
|
||||
var key = AlertSuppressionService.SuppressionKey(_encounterId, AlertType.CriticalHeartRate);
|
||||
(await redis.GetDatabase().KeyExistsAsync(key)).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task News2Emergency_NotSuppressible()
|
||||
{
|
||||
await ResetAndSeedThresholdAsync();
|
||||
|
||||
var alertId = await SeedAlertAsync(AlertType.News2Emergency, AlertSeverity.Critical);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var alerts = scope.ServiceProvider.GetRequiredService<IAlertService>();
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
|
||||
await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("dr-1", "reviewed"));
|
||||
|
||||
var key = AlertSuppressionService.SuppressionKey(_encounterId, AlertType.News2Emergency);
|
||||
(await redis.GetDatabase().KeyExistsAsync(key)).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task News2Warning_Suppressible()
|
||||
{
|
||||
await ResetAndSeedThresholdAsync();
|
||||
|
||||
var alertId = await SeedAlertAsync(AlertType.News2Warning, AlertSeverity.Warning);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var alerts = scope.ServiceProvider.GetRequiredService<IAlertService>();
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
|
||||
await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("nurse-1", "monitoring"));
|
||||
|
||||
var key = AlertSuppressionService.SuppressionKey(_encounterId, AlertType.News2Warning);
|
||||
(await redis.GetDatabase().KeyExistsAsync(key)).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuppressionExpires_AllowsNewAlert()
|
||||
{
|
||||
await ResetAndSeedThresholdAsync();
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var suppression = scope.ServiceProvider.GetRequiredService<IAlertSuppressionService>();
|
||||
var evaluator = scope.ServiceProvider.GetRequiredService<WarningEvaluator>();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
await suppression.SetSuppressionAsync(
|
||||
_encounterId, AlertType.WarningHeartRate, TimeSpan.FromSeconds(1));
|
||||
|
||||
var blocked = await evaluator.EvaluateAsync(
|
||||
Guid.NewGuid(), _encounterId, _patientId, "HEART_RATE", 105m);
|
||||
blocked.Should().BeFalse();
|
||||
|
||||
await Task.Delay(1500);
|
||||
|
||||
var created = await evaluator.EvaluateAsync(
|
||||
Guid.NewGuid(), _encounterId, _patientId, "HEART_RATE", 105m);
|
||||
created.Should().BeTrue();
|
||||
|
||||
var alert = await db.ClinicalAlerts.SingleAsync();
|
||||
alert.AlertType.Should().Be(AlertType.WarningHeartRate);
|
||||
}
|
||||
|
||||
private async Task<Guid> SeedAlertAsync(AlertType alertType, AlertSeverity severity)
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var alert = new ClinicalAlert
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EncounterId = _encounterId,
|
||||
PatientId = _patientId,
|
||||
AlertType = alertType,
|
||||
Severity = severity,
|
||||
Details = $"Test {alertType.ToDbString()} alert.",
|
||||
Status = AlertStatus.Open,
|
||||
TriggeredAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.ClinicalAlerts.Add(alert);
|
||||
await db.SaveChangesAsync();
|
||||
return alert.Id;
|
||||
}
|
||||
}
|
||||
@@ -32,12 +32,17 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// Apply migrations against the test database on first run
|
||||
// Apply migrations before the host starts — background services such as
|
||||
// ThresholdCacheLoader query the database during StartAsync.
|
||||
var connectionString = "Host=localhost;Port=5436;Database=vigilcare_test;Username=postgres;Password=password";
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
await using (var migrateDb = new AppDbContext(options))
|
||||
await migrateDb.Database.MigrateAsync();
|
||||
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
// Flush the test Redis database (db=1) to avoid cross-test cache pollution
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
var server = redis.GetServer(redis.GetEndPoints().First());
|
||||
await server.FlushDatabaseAsync(1);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using FluentAssertions;
|
||||
|
||||
public class TrendCalculatorTests
|
||||
{
|
||||
private static readonly DateTimeOffset BaseTime =
|
||||
new(2026, 6, 18, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void ComputeRatePerMinute_TwoEntries_ReturnsCorrectRate()
|
||||
{
|
||||
var entries = new List<TrendHistoryEntry>
|
||||
{
|
||||
new(72m, BaseTime),
|
||||
new(95m, BaseTime.AddMinutes(10))
|
||||
};
|
||||
|
||||
var rate = TrendCalculator.ComputeRatePerMinute(entries, windowMinutes: 30);
|
||||
|
||||
rate.Should().Be(2.3m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRatePerMinute_SingleEntry_ReturnsNull()
|
||||
{
|
||||
var entries = new List<TrendHistoryEntry> { new(72m, BaseTime) };
|
||||
|
||||
TrendCalculator.ComputeRatePerMinute(entries, windowMinutes: 30)
|
||||
.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExceedsThreshold_HeartRateRise_ReturnsTrue()
|
||||
{
|
||||
TrendCalculator.ExceedsThreshold("HEART_RATE", 0.6m, 0.5m).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExceedsThreshold_Spo2Decline_ReturnsTrue()
|
||||
{
|
||||
TrendCalculator.ExceedsThreshold("SPO2", -0.3m, 0.2m).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExceedsThreshold_StableRate_ReturnsFalse()
|
||||
{
|
||||
TrendCalculator.ExceedsThreshold("HEART_RATE", 0.3m, 0.5m).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescribeTrend_FormatsCorrectly()
|
||||
{
|
||||
TrendCalculator.DescribeTrend("HEART_RATE", 0.77m, 95m)
|
||||
.Should().Be("Rapid rise: HEART_RATE rising at 0.77/min (current 95)");
|
||||
|
||||
TrendCalculator.DescribeTrend("SPO2", -0.25m, 92m)
|
||||
.Should().Be("Rapid decline: SPO2 falling at 0.25/min (current 92)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StackExchange.Redis;
|
||||
|
||||
[Collection("Integration")]
|
||||
public class TrendDetectorTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ApiFixture _fixture;
|
||||
private Guid _encounterId;
|
||||
private Guid _patientId;
|
||||
private static readonly DateTimeOffset BaseTime =
|
||||
new(2026, 6, 18, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public TrendDetectorTests(ApiFixture fixture) => _fixture = fixture;
|
||||
|
||||
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-TREND-001", FirstName = "Trend", LastName = "Test",
|
||||
DateOfBirth = new DateOnly(1970, 3, 1), Gender = "F",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
var encounter = new Encounter
|
||||
{
|
||||
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
|
||||
Status = EncounterStatus.Active, Department = Department.Icu,
|
||||
AttendingPhysician = "Dr. Trend", AdmittedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Patients.Add(patient);
|
||||
db.Encounters.Add(encounter);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
_patientId = patient.Id;
|
||||
_encounterId = encounter.Id;
|
||||
|
||||
await ClearTrendHistoryAsync(scope.ServiceProvider);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task RapidHeartRateClimb_CreatesAlert()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var detector = scope.ServiceProvider.GetRequiredService<TrendDetector>();
|
||||
|
||||
await detector.ProcessObservationAsync(
|
||||
_encounterId, _patientId, "HEART_RATE", 72m, BaseTime);
|
||||
var result = await detector.ProcessObservationAsync(
|
||||
_encounterId, _patientId, "HEART_RATE", 95m, BaseTime.AddMinutes(10));
|
||||
|
||||
result.Outcome.Should().Be(TrendOutcome.RapidDeterioration);
|
||||
result.AlertCreated.Should().BeTrue();
|
||||
result.RatePerMinute.Should().Be(2.3m);
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var alert = await db.ClinicalAlerts.SingleAsync();
|
||||
alert.AlertType.Should().Be(AlertType.RapidDeterioration);
|
||||
alert.Severity.Should().Be(AlertSeverity.Warning);
|
||||
alert.Details.Should().Contain("HEART_RATE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StableHighHeartRate_NoTrendAlert()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var detector = scope.ServiceProvider.GetRequiredService<TrendDetector>();
|
||||
|
||||
await detector.ProcessObservationAsync(
|
||||
_encounterId, _patientId, "HEART_RATE", 95m, BaseTime);
|
||||
await detector.ProcessObservationAsync(
|
||||
_encounterId, _patientId, "HEART_RATE", 95m, BaseTime.AddMinutes(10));
|
||||
var result = await detector.ProcessObservationAsync(
|
||||
_encounterId, _patientId, "HEART_RATE", 95m, BaseTime.AddMinutes(20));
|
||||
|
||||
result.Outcome.Should().Be(TrendOutcome.Stable);
|
||||
result.AlertCreated.Should().BeFalse();
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
(await db.ClinicalAlerts.CountAsync()).Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InsufficientHistory_NoAlert()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var detector = scope.ServiceProvider.GetRequiredService<TrendDetector>();
|
||||
|
||||
var result = await detector.ProcessObservationAsync(
|
||||
_encounterId, _patientId, "HEART_RATE", 72m, BaseTime);
|
||||
|
||||
result.Outcome.Should().Be(TrendOutcome.InsufficientHistory);
|
||||
result.AlertCreated.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonTrendCode_Ignored()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var detector = scope.ServiceProvider.GetRequiredService<TrendDetector>();
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
|
||||
var result = await detector.ProcessObservationAsync(
|
||||
_encounterId, _patientId, "POTASSIUM_MEQ_L", 4.0m, BaseTime);
|
||||
|
||||
result.Outcome.Should().Be(TrendOutcome.NotTrendCode);
|
||||
|
||||
var key = TrendCalculator.HistoryKey(_encounterId, "POTASSIUM_MEQ_L");
|
||||
(await redis.GetDatabase().KeyExistsAsync(key)).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DuplicateTrendAlert_Idempotent()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var detector = scope.ServiceProvider.GetRequiredService<TrendDetector>();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
await detector.ProcessObservationAsync(
|
||||
_encounterId, _patientId, "HEART_RATE", 72m, BaseTime);
|
||||
await detector.ProcessObservationAsync(
|
||||
_encounterId, _patientId, "HEART_RATE", 95m, BaseTime.AddMinutes(10));
|
||||
|
||||
var second = await detector.ProcessObservationAsync(
|
||||
_encounterId, _patientId, "HEART_RATE", 98m, BaseTime.AddMinutes(20));
|
||||
|
||||
second.Outcome.Should().Be(TrendOutcome.AlertAlreadyOpen);
|
||||
second.AlertCreated.Should().BeFalse();
|
||||
(await db.ClinicalAlerts.CountAsync()).Should().Be(1,
|
||||
"second rapid climb must not duplicate while first RAPID_DETERIORATION is open");
|
||||
}
|
||||
|
||||
private async Task ClearTrendHistoryAsync(IServiceProvider services)
|
||||
{
|
||||
var redis = services.GetRequiredService<IConnectionMultiplexer>();
|
||||
var cache = redis.GetDatabase();
|
||||
foreach (var key in TrendCalculator.AllHistoryKeys(_encounterId))
|
||||
await cache.KeyDeleteAsync(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Text.Json;
|
||||
using Confluent.Kafka;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public class TrendAnalyzerService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly KafkaOptions _kafkaOptions;
|
||||
private readonly ILogger<TrendAnalyzerService> _logger;
|
||||
|
||||
public TrendAnalyzerService(
|
||||
IServiceProvider services,
|
||||
IOptions<KafkaOptions> kafkaOptions,
|
||||
ILogger<TrendAnalyzerService> logger)
|
||||
{
|
||||
_services = services;
|
||||
_kafkaOptions = kafkaOptions.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var config = new ConsumerConfig
|
||||
{
|
||||
BootstrapServers = _kafkaOptions.BootstrapServers,
|
||||
GroupId = "trend-analyzer",
|
||||
AutoOffsetReset = AutoOffsetReset.Earliest,
|
||||
EnableAutoCommit = false
|
||||
};
|
||||
|
||||
using var consumer = new ConsumerBuilder<string, string>(config).Build();
|
||||
consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded);
|
||||
|
||||
_logger.LogInformation("TrendAnalyzerService started — consumer group: trend-analyzer");
|
||||
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
ConsumeResult<string, string>? result = null;
|
||||
try
|
||||
{
|
||||
result = consumer.Consume(stoppingToken);
|
||||
|
||||
var evt = JsonSerializer.Deserialize<TrendObservationEvent>(
|
||||
result.Message.Value,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var detector = scope.ServiceProvider.GetRequiredService<TrendDetector>();
|
||||
|
||||
var outcome = await detector.ProcessObservationAsync(
|
||||
evt.EncounterId,
|
||||
evt.PatientId,
|
||||
evt.ObservationCode,
|
||||
evt.Value,
|
||||
evt.RecordedAt,
|
||||
stoppingToken);
|
||||
|
||||
if (outcome.Outcome == TrendOutcome.RapidDeterioration)
|
||||
_logger.LogInformation(
|
||||
"RAPID_DETERIORATION alert via consumer — encounter={Id} code={Code} rate={Rate}/min",
|
||||
evt.EncounterId, outcome.ObservationCode, outcome.RatePerMinute);
|
||||
|
||||
consumer.Commit(result);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"TrendAnalyzerService failed on topic={Topic} offset={Offset} — not committing",
|
||||
result?.Topic, result?.Offset.Value);
|
||||
await Task.Delay(2000, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
consumer.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
public class SuppressionOptions
|
||||
{
|
||||
public const string SectionName = "AlertSuppression";
|
||||
|
||||
/// <summary>Default suppression window after acknowledgment (minutes).</summary>
|
||||
public int DefaultWindowMinutes { get; set; } = 30;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
public class TrendDetectionOptions
|
||||
{
|
||||
public const string SectionName = "TrendDetection";
|
||||
|
||||
/// <summary>Sliding window for rate-of-change calculation (minutes).</summary>
|
||||
public int WindowMinutes { get; set; } = 30;
|
||||
|
||||
/// <summary>Maximum history entries stored per parameter in Redis.</summary>
|
||||
public int MaxHistoryEntries { get; set; } = 10;
|
||||
|
||||
/// <summary>Redis TTL for trend history keys (seconds). Default 2 hours.</summary>
|
||||
public int HistoryTtlSeconds { get; set; } = 7200;
|
||||
|
||||
/// <summary>Per-code rate thresholds: units per minute.</summary>
|
||||
public Dictionary<string, decimal> RateThresholdsPerMinute { get; set; } = new()
|
||||
{
|
||||
["HEART_RATE"] = 0.5m, // 15 bpm rise in 30 min
|
||||
["RESP_RATE"] = 0.3m, // 9 breaths/min rise in 30 min
|
||||
["SYSTOLIC_BP"] = 1.0m, // 30 mmHg drop in 30 min (negative rate checked separately)
|
||||
["TEMP_C"] = 0.05m, // 1.5 °C rise in 30 min
|
||||
["SPO2"] = 0.2m // 6% drop in 30 min (negative rate)
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ public class AlertThresholdConfiguration : IEntityTypeConfiguration<AlertThresho
|
||||
builder.Property(t => t.WarningLow).HasColumnName("warning_low").HasColumnType("decimal(10,3)");
|
||||
builder.Property(t => t.WarningHigh).HasColumnName("warning_high").HasColumnType("decimal(10,3)");
|
||||
builder.Property(t => t.CriticalHigh).HasColumnName("critical_high").HasColumnType("decimal(10,3)");
|
||||
builder.Property(t => t.SuppressionWindowMinutes).HasColumnName("suppression_window_minutes");
|
||||
builder.Property(t => t.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasIndex(t => t.ObservationCode).IsUnique();
|
||||
|
||||
@@ -8,5 +8,6 @@ public class AlertThreshold
|
||||
public decimal? WarningLow { get; set; }
|
||||
public decimal? WarningHigh { get; set; }
|
||||
public decimal? CriticalHigh { get; set; }
|
||||
public int? SuppressionWindowMinutes { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -26,7 +26,9 @@ public enum AlertType
|
||||
WarningGlucoseMgDl,
|
||||
|
||||
News2Warning,
|
||||
News2Emergency
|
||||
News2Emergency,
|
||||
|
||||
RapidDeterioration,
|
||||
}
|
||||
|
||||
public static class AlertTypeExtensions
|
||||
@@ -57,6 +59,7 @@ public static class AlertTypeExtensions
|
||||
AlertType.WarningGlucoseMgDl => "WARNING_GLUCOSE_MG_DL",
|
||||
AlertType.News2Warning => "NEWS2_WARNING",
|
||||
AlertType.News2Emergency => "NEWS2_EMERGENCY",
|
||||
AlertType.RapidDeterioration => "RAPID_DETERIORATION",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||
};
|
||||
|
||||
@@ -86,6 +89,7 @@ public static class AlertTypeExtensions
|
||||
"WARNING_GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl,
|
||||
"NEWS2_WARNING" => AlertType.News2Warning,
|
||||
"NEWS2_EMERGENCY" => AlertType.News2Emergency,
|
||||
"RAPID_DETERIORATION" => AlertType.RapidDeterioration,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert type: '{v}'")
|
||||
};
|
||||
|
||||
@@ -122,4 +126,31 @@ public static class AlertTypeExtensions
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(observationCode), $"No warning alert type for observation code '{observationCode}'")
|
||||
};
|
||||
|
||||
public static bool IsSuppressible(this AlertType t) => t switch
|
||||
{
|
||||
AlertType.SepsisWarning or AlertType.News2Emergency => false,
|
||||
AlertType.CriticalHeartRate or AlertType.CriticalTempC or AlertType.CriticalPotassiumMeqL
|
||||
or AlertType.CriticalSpo2 or AlertType.CriticalRespRate or AlertType.CriticalWbcKUl
|
||||
or AlertType.CriticalSystolicBp or AlertType.CriticalDiastolicBp
|
||||
or AlertType.CriticalLactateMmolL or AlertType.CriticalAvpu
|
||||
or AlertType.CriticalGlucoseMgDl => false,
|
||||
AlertType.RapidDeterioration => false, // trajectory alerts are never suppressed
|
||||
_ => true // all Warning* types and News2Warning
|
||||
};
|
||||
|
||||
public static string? ObservationCodeForWarning(this AlertType t) => t switch
|
||||
{
|
||||
AlertType.WarningHeartRate => "HEART_RATE",
|
||||
AlertType.WarningTempC => "TEMP_C",
|
||||
AlertType.WarningPotassiumMeqL => "POTASSIUM_MEQ_L",
|
||||
AlertType.WarningSpo2 => "SPO2",
|
||||
AlertType.WarningRespRate => "RESP_RATE",
|
||||
AlertType.WarningWbcKUl => "WBC_K_UL",
|
||||
AlertType.WarningSystolicBp => "SYSTOLIC_BP",
|
||||
AlertType.WarningDiastolicBp => "DIASTOLIC_BP",
|
||||
AlertType.WarningLactateMmolL => "LACTATE_MMOL_L",
|
||||
AlertType.WarningGlucoseMgDl => "GLUCOSE_MG_DL",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
public enum TrendOutcome
|
||||
{
|
||||
NotTrendCode,
|
||||
InsufficientHistory,
|
||||
Stable,
|
||||
RapidDeterioration,
|
||||
AlertAlreadyOpen
|
||||
}
|
||||
+716
@@ -0,0 +1,716 @@
|
||||
// <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("20260618121634_AddRapidDeteriorationAlertType")]
|
||||
partial class AddRapidDeteriorationAlertType
|
||||
{
|
||||
/// <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("News2Score", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CalculatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("calculated_at");
|
||||
|
||||
b.Property<int>("ConsciousnessScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("consciousness_score");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<bool>("HasSingleParamThree")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_single_param_three");
|
||||
|
||||
b.Property<int>("HeartRateScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("heart_rate_score");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<int>("RespRateScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("resp_rate_score");
|
||||
|
||||
b.Property<string>("RiskLevel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("risk_level");
|
||||
|
||||
b.Property<int>("Spo2Score")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("spo2_score");
|
||||
|
||||
b.Property<int>("SupplementalO2Score")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("supplemental_o2_score");
|
||||
|
||||
b.Property<int>("SystolicBpScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("systolic_bp_score");
|
||||
|
||||
b.Property<int>("TemperatureScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("temperature_score");
|
||||
|
||||
b.Property<int>("TotalScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("total_score");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EncounterId", "CalculatedAt");
|
||||
|
||||
b.HasIndex("PatientId", "CalculatedAt");
|
||||
|
||||
b.ToTable("news2_scores", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_news2_scores_risk_level", "risk_level IN ('LOW', 'LOW_MEDIUM', 'MEDIUM', 'HIGH')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Observation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("idempotency_key");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
b.Property<DateTimeOffset>("RecordedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("recorded_at");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("source")
|
||||
.HasDefaultValueSql("'MANUAL'");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<decimal>("Value")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IdempotencyKey")
|
||||
.IsUnique()
|
||||
.HasFilter("idempotency_key IS NOT NULL");
|
||||
|
||||
b.HasIndex("EncounterId", "ObservationCode", "RecordedAt");
|
||||
|
||||
b.ToTable("observations", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Order", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("OrderType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("order_type");
|
||||
|
||||
b.Property<DateTimeOffset>("OrderedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("ordered_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("OrderedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("ordered_by");
|
||||
|
||||
b.Property<string>("ResultSummary")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("result_summary");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResultedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("resulted_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'PENDING'");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EncounterId", "OrderedAt");
|
||||
|
||||
b.HasIndex("Status", "OrderedAt")
|
||||
.HasFilter("status IN ('PENDING', 'IN_PROGRESS')");
|
||||
|
||||
b.ToTable("orders", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')");
|
||||
|
||||
t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OutboxEvent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("PartitionKey")
|
||||
.HasMaxLength(36)
|
||||
.HasColumnType("character varying(36)")
|
||||
.HasColumnName("partition_key");
|
||||
|
||||
b.Property<string>("Payload")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<DateTimeOffset?>("ProcessedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("processed_at");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("topic");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasFilter("processed_at IS NULL");
|
||||
|
||||
b.ToTable("outbox_events", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Patient", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("Allergies")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("allergies");
|
||||
|
||||
b.Property<string>("BloodType")
|
||||
.HasMaxLength(5)
|
||||
.HasColumnType("character varying(5)")
|
||||
.HasColumnName("blood_type");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<DateOnly>("DateOfBirth")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("date_of_birth");
|
||||
|
||||
b.Property<string>("EmergencyContactName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("emergency_contact_name");
|
||||
|
||||
b.Property<string>("EmergencyContactPhone")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("emergency_contact_phone");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("first_name");
|
||||
|
||||
b.Property<string>("Gender")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasColumnName("gender");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("last_name");
|
||||
|
||||
b.Property<string>("Mrn")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("mrn");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Mrn")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("patients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReconciliationAlert", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("CheckType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("check_type");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("details");
|
||||
|
||||
b.Property<Guid?>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<Guid?>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("resolved_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EncounterId");
|
||||
|
||||
b.HasIndex("PatientId");
|
||||
|
||||
b.HasIndex("CheckType", "EncounterId")
|
||||
.HasFilter("resolved_at IS NULL");
|
||||
|
||||
b.ToTable("reconciliation_alerts", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClinicalAlert", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany("Alerts")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Encounter", b =>
|
||||
{
|
||||
b.HasOne("Patient", "Patient")
|
||||
.WithMany("Encounters")
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("News2Score", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany()
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Observation", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany("Observations")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Order", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany("Orders")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReconciliationAlert", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany()
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Patient", "Patient")
|
||||
.WithMany()
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Encounter");
|
||||
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Encounter", b =>
|
||||
{
|
||||
b.Navigation("Alerts");
|
||||
|
||||
b.Navigation("Observations");
|
||||
|
||||
b.Navigation("Orders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Patient", b =>
|
||||
{
|
||||
b.Navigation("Encounters");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRapidDeteriorationAlertType : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
ALTER TABLE clinical_alerts DROP CONSTRAINT chk_clinical_alerts_alert_type;
|
||||
ALTER TABLE clinical_alerts ADD CONSTRAINT chk_clinical_alerts_alert_type
|
||||
CHECK (alert_type IN (
|
||||
'SEPSIS_WARNING',
|
||||
'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L',
|
||||
'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL',
|
||||
'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L',
|
||||
'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL',
|
||||
'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L',
|
||||
'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL',
|
||||
'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L',
|
||||
'WARNING_GLUCOSE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION'
|
||||
));
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
+720
@@ -0,0 +1,720 @@
|
||||
// <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("20260618124632_AddSuppressionWindowMinutes")]
|
||||
partial class AddSuppressionWindowMinutes
|
||||
{
|
||||
/// <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<int?>("SuppressionWindowMinutes")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("suppression_window_minutes");
|
||||
|
||||
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("News2Score", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CalculatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("calculated_at");
|
||||
|
||||
b.Property<int>("ConsciousnessScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("consciousness_score");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<bool>("HasSingleParamThree")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_single_param_three");
|
||||
|
||||
b.Property<int>("HeartRateScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("heart_rate_score");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<int>("RespRateScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("resp_rate_score");
|
||||
|
||||
b.Property<string>("RiskLevel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("risk_level");
|
||||
|
||||
b.Property<int>("Spo2Score")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("spo2_score");
|
||||
|
||||
b.Property<int>("SupplementalO2Score")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("supplemental_o2_score");
|
||||
|
||||
b.Property<int>("SystolicBpScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("systolic_bp_score");
|
||||
|
||||
b.Property<int>("TemperatureScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("temperature_score");
|
||||
|
||||
b.Property<int>("TotalScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("total_score");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EncounterId", "CalculatedAt");
|
||||
|
||||
b.HasIndex("PatientId", "CalculatedAt");
|
||||
|
||||
b.ToTable("news2_scores", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_news2_scores_risk_level", "risk_level IN ('LOW', 'LOW_MEDIUM', 'MEDIUM', 'HIGH')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Observation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("idempotency_key");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
b.Property<DateTimeOffset>("RecordedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("recorded_at");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("source")
|
||||
.HasDefaultValueSql("'MANUAL'");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<decimal>("Value")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IdempotencyKey")
|
||||
.IsUnique()
|
||||
.HasFilter("idempotency_key IS NOT NULL");
|
||||
|
||||
b.HasIndex("EncounterId", "ObservationCode", "RecordedAt");
|
||||
|
||||
b.ToTable("observations", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Order", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("OrderType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("order_type");
|
||||
|
||||
b.Property<DateTimeOffset>("OrderedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("ordered_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("OrderedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("ordered_by");
|
||||
|
||||
b.Property<string>("ResultSummary")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("result_summary");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResultedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("resulted_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'PENDING'");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EncounterId", "OrderedAt");
|
||||
|
||||
b.HasIndex("Status", "OrderedAt")
|
||||
.HasFilter("status IN ('PENDING', 'IN_PROGRESS')");
|
||||
|
||||
b.ToTable("orders", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')");
|
||||
|
||||
t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OutboxEvent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("PartitionKey")
|
||||
.HasMaxLength(36)
|
||||
.HasColumnType("character varying(36)")
|
||||
.HasColumnName("partition_key");
|
||||
|
||||
b.Property<string>("Payload")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<DateTimeOffset?>("ProcessedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("processed_at");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("topic");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasFilter("processed_at IS NULL");
|
||||
|
||||
b.ToTable("outbox_events", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Patient", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("Allergies")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("allergies");
|
||||
|
||||
b.Property<string>("BloodType")
|
||||
.HasMaxLength(5)
|
||||
.HasColumnType("character varying(5)")
|
||||
.HasColumnName("blood_type");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<DateOnly>("DateOfBirth")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("date_of_birth");
|
||||
|
||||
b.Property<string>("EmergencyContactName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("emergency_contact_name");
|
||||
|
||||
b.Property<string>("EmergencyContactPhone")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("emergency_contact_phone");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("first_name");
|
||||
|
||||
b.Property<string>("Gender")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasColumnName("gender");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("last_name");
|
||||
|
||||
b.Property<string>("Mrn")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("mrn");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Mrn")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("patients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReconciliationAlert", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("CheckType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("check_type");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("details");
|
||||
|
||||
b.Property<Guid?>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<Guid?>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("resolved_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EncounterId");
|
||||
|
||||
b.HasIndex("PatientId");
|
||||
|
||||
b.HasIndex("CheckType", "EncounterId")
|
||||
.HasFilter("resolved_at IS NULL");
|
||||
|
||||
b.ToTable("reconciliation_alerts", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClinicalAlert", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany("Alerts")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Encounter", b =>
|
||||
{
|
||||
b.HasOne("Patient", "Patient")
|
||||
.WithMany("Encounters")
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("News2Score", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany()
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Observation", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany("Observations")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Order", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany("Orders")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReconciliationAlert", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany()
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Patient", "Patient")
|
||||
.WithMany()
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Encounter");
|
||||
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Encounter", b =>
|
||||
{
|
||||
b.Navigation("Alerts");
|
||||
|
||||
b.Navigation("Observations");
|
||||
|
||||
b.Navigation("Orders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Patient", b =>
|
||||
{
|
||||
b.Navigation("Encounters");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSuppressionWindowMinutes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "suppression_window_minutes",
|
||||
table: "alert_thresholds",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "suppression_window_minutes",
|
||||
table: "alert_thresholds");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,10 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
b.Property<int?>("SuppressionWindowMinutes")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("suppression_window_minutes");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
public record TrendHistoryEntry(decimal Value, DateTimeOffset RecordedAt);
|
||||
@@ -0,0 +1,6 @@
|
||||
public record TrendObservationEvent(
|
||||
Guid EncounterId,
|
||||
Guid PatientId,
|
||||
string ObservationCode,
|
||||
decimal Value,
|
||||
DateTimeOffset RecordedAt);
|
||||
@@ -0,0 +1,5 @@
|
||||
public record TrendResult(
|
||||
TrendOutcome Outcome,
|
||||
string? ObservationCode = null,
|
||||
decimal? RatePerMinute = null,
|
||||
bool AlertCreated = false);
|
||||
@@ -153,6 +153,16 @@ public class News2Detector
|
||||
int totalScore, string riskLevel, int[] paramScores,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (alertType == AlertType.News2Warning)
|
||||
{
|
||||
var suppression = _services.GetRequiredService<IAlertSuppressionService>();
|
||||
if (await suppression.IsSuppressedAsync(encounterId, alertType, ct))
|
||||
{
|
||||
_logger.LogDebug("NEWS2_WARNING suppressed for encounter {Id}", encounterId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
|
||||
@@ -36,6 +36,16 @@ public sealed class ClinicalMetrics
|
||||
"escalations_total",
|
||||
"Total alert escalations processed through the DLQ escalation path.");
|
||||
|
||||
public readonly Counter TrendAlertsTotal = Metrics.CreateCounter(
|
||||
"trend_alerts_total",
|
||||
"Total RAPID_DETERIORATION alerts generated, labeled by observation code.",
|
||||
labelNames: new[] { "observation_code" });
|
||||
|
||||
public readonly Counter AlertSuppressionsTotal = Metrics.CreateCounter(
|
||||
"alert_suppressions_total",
|
||||
"Total alert suppression windows set after acknowledgment.",
|
||||
labelNames: new[] { "alert_type" });
|
||||
|
||||
// --- Histograms ---
|
||||
|
||||
// Measures the full ingest transaction: Redis cache lookup + alert evaluation +
|
||||
@@ -57,6 +67,14 @@ public sealed class ClinicalMetrics
|
||||
Buckets = new[] { 0.001, 0.005, 0.01, 0.025, 0.05, 0.1 }
|
||||
});
|
||||
|
||||
public readonly Histogram TrendAnalysisDuration = Metrics.CreateHistogram(
|
||||
"trend_analysis_duration_seconds",
|
||||
"Time to evaluate trend for one observation.",
|
||||
new HistogramConfiguration
|
||||
{
|
||||
Buckets = new[] { 0.001, 0.005, 0.01, 0.025, 0.05, 0.1 }
|
||||
});
|
||||
|
||||
// --- Gauges (set by background collectors, not incremented inline) ---
|
||||
|
||||
// The most clinically significant panel. A non-zero value means a patient's
|
||||
|
||||
@@ -65,6 +65,12 @@ try
|
||||
builder.Services.Configure<DataLakeOptions>(
|
||||
builder.Configuration.GetSection(DataLakeOptions.Section));
|
||||
|
||||
builder.Services.Configure<TrendDetectionOptions>(
|
||||
builder.Configuration.GetSection(TrendDetectionOptions.SectionName));
|
||||
|
||||
builder.Services.Configure<SuppressionOptions>(
|
||||
builder.Configuration.GetSection(SuppressionOptions.SectionName));
|
||||
|
||||
builder.Services.AddScoped<IPatientService, PatientService>();
|
||||
builder.Services.AddScoped<IEncounterService, EncounterService>();
|
||||
builder.Services.AddScoped<IAlertThresholdService, AlertThresholdService>();
|
||||
@@ -81,6 +87,8 @@ try
|
||||
builder.Services.AddScoped<ReconciliationPublisher>();
|
||||
builder.Services.AddScoped<WarningEvaluator>();
|
||||
builder.Services.AddScoped<News2Detector>();
|
||||
builder.Services.AddScoped<TrendDetector>();
|
||||
builder.Services.AddSingleton<IAlertSuppressionService, AlertSuppressionService>();
|
||||
|
||||
builder.Services.AddHostedService<ThresholdCacheLoader>();
|
||||
builder.Services.AddHostedService<KafkaTopicProvisioner>();
|
||||
@@ -100,6 +108,7 @@ try
|
||||
builder.Services.AddHostedService<DataLakeWriterService>();
|
||||
builder.Services.AddHostedService<WarningAlertService>();
|
||||
builder.Services.AddHostedService<News2ScoringService>();
|
||||
builder.Services.AddHostedService<TrendAnalyzerService>();
|
||||
|
||||
|
||||
builder.Services.AddControllers()
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public class AlertService : IAlertService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
public AlertService(AppDbContext db) => _db = db;
|
||||
public AlertService(AppDbContext db, IServiceProvider services)
|
||||
{
|
||||
_db = db;
|
||||
_services = services;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<ClinicalAlert>> ListByEncounterAsync(
|
||||
Guid encounterId, AlertStatus? status, int page, int pageSize)
|
||||
@@ -100,10 +106,42 @@ public class AlertService : IAlertService
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
if (alert.AlertType.IsSuppressible())
|
||||
{
|
||||
var suppression = _services.GetRequiredService<IAlertSuppressionService>();
|
||||
var options = _services.GetRequiredService<IOptions<SuppressionOptions>>().Value;
|
||||
var windowMinutes = await ResolveSuppressionWindowMinutesAsync(
|
||||
alert.AlertType, options.DefaultWindowMinutes);
|
||||
|
||||
await suppression.SetSuppressionAsync(
|
||||
alert.EncounterId,
|
||||
alert.AlertType,
|
||||
TimeSpan.FromMinutes(windowMinutes));
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return alert;
|
||||
}
|
||||
|
||||
private async Task<int> ResolveSuppressionWindowMinutesAsync(
|
||||
AlertType alertType, int defaultWindowMinutes)
|
||||
{
|
||||
if (alertType == AlertType.News2Warning)
|
||||
return defaultWindowMinutes;
|
||||
|
||||
var observationCode = alertType.ObservationCodeForWarning();
|
||||
if (observationCode is null)
|
||||
return defaultWindowMinutes;
|
||||
|
||||
var overrideMinutes = await _db.AlertThresholds
|
||||
.AsNoTracking()
|
||||
.Where(t => t.ObservationCode == observationCode)
|
||||
.Select(t => t.SuppressionWindowMinutes)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return overrideMinutes ?? defaultWindowMinutes;
|
||||
}
|
||||
|
||||
public async Task<ClinicalAlert> ResolveAsync(Guid id)
|
||||
{
|
||||
var alert = await _db.ClinicalAlerts.FindAsync(id);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class AlertSuppressionService : IAlertSuppressionService
|
||||
{
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly ClinicalMetrics _metrics;
|
||||
|
||||
public AlertSuppressionService(IConnectionMultiplexer redis, ClinicalMetrics metrics)
|
||||
{
|
||||
_redis = redis;
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public static string SuppressionKey(Guid encounterId, AlertType alertType) =>
|
||||
$"suppress:{encounterId}:{alertType.ToDbString()}";
|
||||
|
||||
public async Task SetSuppressionAsync(
|
||||
Guid encounterId, AlertType alertType, TimeSpan ttl, CancellationToken ct = default)
|
||||
{
|
||||
var cache = _redis.GetDatabase();
|
||||
await cache.StringSetAsync(SuppressionKey(encounterId, alertType), "1", ttl);
|
||||
_metrics.AlertSuppressionsTotal.WithLabels(alertType.ToDbString()).Inc();
|
||||
}
|
||||
|
||||
public async Task<bool> IsSuppressedAsync(
|
||||
Guid encounterId, AlertType alertType, CancellationToken ct = default)
|
||||
{
|
||||
var cache = _redis.GetDatabase();
|
||||
return await cache.KeyExistsAsync(SuppressionKey(encounterId, alertType));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
public interface IAlertSuppressionService
|
||||
{
|
||||
Task SetSuppressionAsync(Guid encounterId, AlertType alertType, TimeSpan ttl, CancellationToken ct = default);
|
||||
Task<bool> IsSuppressedAsync(Guid encounterId, AlertType alertType, CancellationToken ct = default);
|
||||
}
|
||||
@@ -35,6 +35,15 @@ public class WarningEvaluator
|
||||
// critical alerts are created synchronously by the ingest path.
|
||||
if (IsCriticalBreach(value, threshold)) return false;
|
||||
|
||||
var alertType = AlertTypeExtensions.WarningFor(observationCode);
|
||||
var suppression = _services.GetRequiredService<IAlertSuppressionService>();
|
||||
if (await suppression.IsSuppressedAsync(encounterId, alertType, ct))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Warning alert suppressed for encounter {Id} type {Type}", encounterId, alertType);
|
||||
return false;
|
||||
}
|
||||
|
||||
return await TryCreateWarningAlertAsync(
|
||||
observationId, encounterId, patientId, observationCode, value, threshold, ct);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using StackExchange.Redis;
|
||||
|
||||
public static class TrendCalculator
|
||||
{
|
||||
// NEWS2-relevant codes that support trend detection.
|
||||
public static readonly IReadOnlyList<string> TrendCodes = new[]
|
||||
{
|
||||
"HEART_RATE", "RESP_RATE", "SYSTOLIC_BP", "TEMP_C", "SPO2"
|
||||
};
|
||||
|
||||
public static bool IsTrendCode(string observationCode) =>
|
||||
TrendCodes.Contains(observationCode);
|
||||
|
||||
public static string HistoryKey(Guid encounterId, string code) =>
|
||||
$"trend:{encounterId}:{code}";
|
||||
|
||||
public static RedisKey[] AllHistoryKeys(Guid encounterId) =>
|
||||
TrendCodes.Select(c => (RedisKey)HistoryKey(encounterId, c)).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Computes rate of change (units per minute) between the oldest and newest
|
||||
/// entries within the window. Returns null if fewer than 2 entries or window exceeded.
|
||||
/// </summary>
|
||||
public static decimal? ComputeRatePerMinute(
|
||||
IReadOnlyList<TrendHistoryEntry> entries,
|
||||
int windowMinutes)
|
||||
{
|
||||
if (entries.Count < 2) return null;
|
||||
|
||||
var newest = entries[^1];
|
||||
var oldest = entries[0];
|
||||
|
||||
var deltaMinutes = (newest.RecordedAt - oldest.RecordedAt).TotalMinutes;
|
||||
if (deltaMinutes <= 0 || deltaMinutes > windowMinutes) return null;
|
||||
|
||||
return (newest.Value - oldest.Value) / (decimal)deltaMinutes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the rate exceeds the configured threshold.
|
||||
/// For SPO2 and SYSTOLIC_BP, checks negative rate (decline) as clinically significant.
|
||||
/// </summary>
|
||||
public static bool ExceedsThreshold(
|
||||
string observationCode,
|
||||
decimal ratePerMinute,
|
||||
decimal thresholdPerMinute) =>
|
||||
observationCode switch
|
||||
{
|
||||
"SPO2" or "SYSTOLIC_BP" => ratePerMinute <= -thresholdPerMinute,
|
||||
_ => ratePerMinute >= thresholdPerMinute
|
||||
};
|
||||
|
||||
public static string DescribeTrend(
|
||||
string observationCode, decimal ratePerMinute, decimal currentValue) =>
|
||||
observationCode switch
|
||||
{
|
||||
"SPO2" or "SYSTOLIC_BP" =>
|
||||
$"Rapid decline: {observationCode} falling at {Math.Abs(ratePerMinute):F2}/min (current {currentValue})",
|
||||
_ =>
|
||||
$"Rapid rise: {observationCode} rising at {ratePerMinute:F2}/min (current {currentValue})"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Prometheus;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class TrendDetector
|
||||
{
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly TrendDetectionOptions _options;
|
||||
private readonly ClinicalMetrics _metrics;
|
||||
private readonly ILogger<TrendDetector> _logger;
|
||||
|
||||
public TrendDetector(
|
||||
ILogger<TrendDetector> logger,
|
||||
ClinicalMetrics metrics,
|
||||
IServiceProvider services,
|
||||
IConnectionMultiplexer redis,
|
||||
IOptions<TrendDetectionOptions> options)
|
||||
{
|
||||
_logger = logger;
|
||||
_metrics = metrics;
|
||||
_services = services;
|
||||
_redis = redis;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<TrendResult> ProcessObservationAsync(
|
||||
Guid encounterId,
|
||||
Guid patientId,
|
||||
string observationCode,
|
||||
decimal value,
|
||||
DateTimeOffset recordedAt,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!TrendCalculator.IsTrendCode(observationCode))
|
||||
return new TrendResult(TrendOutcome.NotTrendCode);
|
||||
|
||||
using var timer = _metrics.TrendAnalysisDuration.NewTimer();
|
||||
|
||||
var cache = _redis.GetDatabase();
|
||||
var key = TrendCalculator.HistoryKey(encounterId, observationCode);
|
||||
|
||||
// Append new entry to history list
|
||||
var entry = new TrendHistoryEntry(value, recordedAt);
|
||||
var historyJson = await cache.StringGetAsync(key);
|
||||
var history = historyJson.HasValue
|
||||
? JsonSerializer.Deserialize<List<TrendHistoryEntry>>(historyJson!) ?? new()
|
||||
: new List<TrendHistoryEntry>();
|
||||
|
||||
history.Add(entry);
|
||||
|
||||
// Trim to max entries and evict entries outside window
|
||||
var cutoff = recordedAt.AddMinutes(-_options.WindowMinutes);
|
||||
history = history
|
||||
.Where(e => e.RecordedAt >= cutoff)
|
||||
.TakeLast(_options.MaxHistoryEntries)
|
||||
.ToList();
|
||||
|
||||
await cache.StringSetAsync(
|
||||
key,
|
||||
JsonSerializer.Serialize(history),
|
||||
TimeSpan.FromSeconds(_options.HistoryTtlSeconds));
|
||||
|
||||
if (history.Count < 2)
|
||||
return new TrendResult(TrendOutcome.InsufficientHistory, observationCode);
|
||||
|
||||
var rate = TrendCalculator.ComputeRatePerMinute(history, _options.WindowMinutes);
|
||||
if (rate is null)
|
||||
return new TrendResult(TrendOutcome.InsufficientHistory, observationCode);
|
||||
|
||||
if (!_options.RateThresholdsPerMinute.TryGetValue(observationCode, out var threshold))
|
||||
return new TrendResult(TrendOutcome.Stable, observationCode, rate);
|
||||
|
||||
if (!TrendCalculator.ExceedsThreshold(observationCode, rate.Value, threshold))
|
||||
return new TrendResult(TrendOutcome.Stable, observationCode, rate);
|
||||
|
||||
var details = TrendCalculator.DescribeTrend(observationCode, rate.Value, value);
|
||||
var created = await TryCreateAlertAsync(
|
||||
encounterId, patientId, observationCode, details, rate.Value, ct);
|
||||
|
||||
return new TrendResult(
|
||||
created ? TrendOutcome.RapidDeterioration : TrendOutcome.AlertAlreadyOpen,
|
||||
observationCode, rate, created);
|
||||
}
|
||||
|
||||
private async Task<bool> TryCreateAlertAsync(
|
||||
Guid encounterId, Guid patientId,
|
||||
string observationCode, string details, decimal ratePerMinute,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync(ct);
|
||||
|
||||
var alertId = Guid.NewGuid();
|
||||
var triggeredAt = DateTimeOffset.UtcNow;
|
||||
var fullDetails = $"{details} — velocity {ratePerMinute:F2}/min over {_options.WindowMinutes}min window.";
|
||||
|
||||
var affected = await db.Database.ExecuteSqlInterpolatedAsync($"""
|
||||
INSERT INTO clinical_alerts
|
||||
(id, encounter_id, patient_id, alert_type, severity, details, status, triggered_at)
|
||||
SELECT {alertId}, {encounterId}, {patientId},
|
||||
'RAPID_DETERIORATION', 'WARNING', {fullDetails}, 'OPEN', {triggeredAt}
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM clinical_alerts
|
||||
WHERE encounter_id = {encounterId}
|
||||
AND alert_type = 'RAPID_DETERIORATION'
|
||||
AND details LIKE {$"%{observationCode}%"}
|
||||
AND status IN ('OPEN', 'ESCALATED')
|
||||
)
|
||||
""", ct);
|
||||
|
||||
if (affected == 0)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
db.OutboxEvents.Add(new OutboxEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Topic = "alert.generated",
|
||||
Payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
alertId,
|
||||
encounterId,
|
||||
patientId,
|
||||
alertType = "RAPID_DETERIORATION",
|
||||
severity = "Warning",
|
||||
details = fullDetails,
|
||||
triggeredAt,
|
||||
observationCode,
|
||||
ratePerMinute,
|
||||
partitionKey = encounterId.ToString()
|
||||
}),
|
||||
PartitionKey = encounterId.ToString(),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
|
||||
_metrics.TrendAlertsTotal.WithLabels(observationCode).Inc();
|
||||
_metrics.ClinicalAlertsTotal
|
||||
.WithLabels("RAPID_DETERIORATION", "Warning").Inc();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -78,5 +78,20 @@
|
||||
"DataLake": {
|
||||
"FlushCount": 1000,
|
||||
"FlushIntervalSeconds": 300
|
||||
},
|
||||
"TrendDetection": {
|
||||
"WindowMinutes": 30,
|
||||
"MaxHistoryEntries": 10,
|
||||
"HistoryTtlSeconds": 7200,
|
||||
"RateThresholdsPerMinute": {
|
||||
"HEART_RATE": 0.5,
|
||||
"RESP_RATE": 0.3,
|
||||
"SYSTOLIC_BP": 1.0,
|
||||
"TEMP_C": 0.05,
|
||||
"SPO2": 0.2
|
||||
}
|
||||
},
|
||||
"AlertSuppression": {
|
||||
"DefaultWindowMinutes": 30
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+281
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-${ROOT_DIR}/docker-compose.yml}"
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost:5270}"
|
||||
REDIS_PORT="${REDIS_PORT:-6382}"
|
||||
|
||||
PGHOST="${PGHOST:-localhost}"
|
||||
PGPORT="${PGPORT:-5436}"
|
||||
PGDATABASE="${PGDATABASE:-vigilcare}"
|
||||
PGUSER="${PGUSER:-postgres}"
|
||||
PGPASSWORD="${PGPASSWORD:-password}"
|
||||
|
||||
TEST_PROJECT="${TEST_PROJECT:-${ROOT_DIR}/VigilCareClinicalAPI.Tests/VigilCareClinicalAPI.Tests.csproj}"
|
||||
TEST_FILTER="${TEST_FILTER:-FullyQualifiedName~Trend|FullyQualifiedName~Suppression}"
|
||||
FULL_TEST="${FULL_TEST:-0}"
|
||||
|
||||
TREND_CONSUMER_GROUP="${TREND_CONSUMER_GROUP:-trend-analyzer}"
|
||||
WARNING_CONSUMER_GROUP="${WARNING_CONSUMER_GROUP:-warning-evaluator}"
|
||||
TREND_WAIT_SECS="${TREND_WAIT_SECS:-45}"
|
||||
WARNING_WAIT_SECS="${WARNING_WAIT_SECS:-45}"
|
||||
|
||||
SCRIPT_RUN_ID="$(date -u +"%Y%m%d%H%M%S")"
|
||||
RECORDED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
|
||||
TMP_FILES=()
|
||||
cleanup() {
|
||||
local f
|
||||
for f in "${TMP_FILES[@]}"; do
|
||||
rm -f "${f}" "${f}.status" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
need() {
|
||||
command -v "$1" >/dev/null 2>&1 || {
|
||||
echo "Missing dependency: $1"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
need curl
|
||||
need jq
|
||||
need dotnet
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1 || [[ ! -f "${COMPOSE_FILE}" ]]; then
|
||||
echo "Missing dependency: docker compose (${COMPOSE_FILE})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
compose() {
|
||||
docker compose -f "${COMPOSE_FILE}" "$@"
|
||||
}
|
||||
|
||||
kafka_exec() {
|
||||
compose exec -T kafka "$@"
|
||||
}
|
||||
|
||||
redis_cmd() {
|
||||
if command -v redis-cli >/dev/null 2>&1; then
|
||||
redis-cli -p "${REDIS_PORT}" "$@"
|
||||
else
|
||||
compose exec -T redis redis-cli "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
psql_cmd() {
|
||||
local sql="$1"
|
||||
if command -v psql >/dev/null 2>&1; then
|
||||
PGPASSWORD="${PGPASSWORD}" psql -h "${PGHOST}" -p "${PGPORT}" -U "${PGUSER}" -d "${PGDATABASE}" -tAc "${sql}"
|
||||
else
|
||||
compose exec -T postgres psql -U "${PGUSER}" -d "${PGDATABASE}" -tAc "${sql}"
|
||||
fi
|
||||
}
|
||||
|
||||
request() {
|
||||
local method="$1"
|
||||
local url="$2"
|
||||
local body="${3:-}"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
TMP_FILES+=("${tmp}")
|
||||
local status
|
||||
|
||||
if [[ -n "${body}" ]]; then
|
||||
status="$(curl -sS -o "${tmp}" -w "%{http_code}" -X "${method}" "${url}" \
|
||||
-H "Content-Type: application/json" -d "${body}")"
|
||||
else
|
||||
status="$(curl -sS -o "${tmp}" -w "%{http_code}" -X "${method}" "${url}")"
|
||||
fi
|
||||
|
||||
echo "${status}" > "${tmp}.status"
|
||||
echo "${tmp}"
|
||||
}
|
||||
|
||||
assert_status() {
|
||||
local expected="$1"
|
||||
local body_file="$2"
|
||||
local status
|
||||
status="$(<"${body_file}.status")"
|
||||
if [[ "${status}" != "${expected}" ]]; then
|
||||
echo "Expected HTTP ${expected}, got ${status}"
|
||||
cat "${body_file}"
|
||||
echo
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
consumer_group_lag() {
|
||||
local group="$1"
|
||||
kafka_exec /opt/kafka/bin/kafka-consumer-groups.sh \
|
||||
--bootstrap-server localhost:9092 \
|
||||
--describe \
|
||||
--group "${group}" 2>/dev/null | \
|
||||
awk 'NR > 1 && $1 != "" { sum += $6 } END { print sum + 0 }'
|
||||
}
|
||||
|
||||
wait_for_consumer_lag_zero() {
|
||||
local group="$1"
|
||||
local max_secs="$2"
|
||||
local elapsed=0
|
||||
local lag="unknown"
|
||||
while (( elapsed < max_secs )); do
|
||||
lag="$(consumer_group_lag "${group}")"
|
||||
if [[ "${lag}" == "0" ]]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
echo "Consumer group ${group} lag did not reach zero within ${max_secs}s (lag=${lag})"
|
||||
exit 1
|
||||
}
|
||||
|
||||
ingest_hr() {
|
||||
local encounter_id="$1"
|
||||
local value="$2"
|
||||
local recorded_at="${3:-${RECORDED_AT}}"
|
||||
jq -nc \
|
||||
--arg recordedAt "${recorded_at}" \
|
||||
--argjson value "${value}" \
|
||||
'{observations:[{"observationCode":"HEART_RATE","value":$value,"unit":"bpm","source":"DEVICE","recordedAt":$recordedAt}]}'
|
||||
}
|
||||
|
||||
TOTAL_STEPS=10
|
||||
echo "Phase 13 verification starting..."
|
||||
echo "Repo root: ${ROOT_DIR}"
|
||||
echo "API: ${BASE_URL}"
|
||||
|
||||
echo "[1/${TOTAL_STEPS}] Infrastructure preflight: API, PostgreSQL, Redis, Kafka, threshold cache"
|
||||
api_status="$(curl -sS -o /dev/null -w "%{http_code}" "${BASE_URL}/api/v1/alert-thresholds" || true)"
|
||||
[[ "${api_status}" == "200" ]] || { echo "API not ready (${api_status}) — run docker compose up -d and dotnet run"; exit 1; }
|
||||
redis_cmd PING >/dev/null || { echo "Redis not reachable on port ${REDIS_PORT}"; exit 1; }
|
||||
psql_cmd "SELECT 1" >/dev/null || { echo "PostgreSQL not reachable"; exit 1; }
|
||||
kafka_exec /opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 >/dev/null || {
|
||||
echo "Kafka not ready"
|
||||
exit 1
|
||||
}
|
||||
redis_cmd EXISTS "threshold:HEART_RATE" | grep -q '^1$' || {
|
||||
echo "Missing Redis threshold:HEART_RATE — restart API to run ThresholdCacheLoader"
|
||||
exit 1
|
||||
}
|
||||
echo "OK: infrastructure preflight passed"
|
||||
|
||||
echo "[2/${TOTAL_STEPS}] Register patient + open active encounter"
|
||||
patient_payload="$(jq -nc \
|
||||
--arg fn "Phase13" \
|
||||
--arg ln "Verify${SCRIPT_RUN_ID}" \
|
||||
'{firstName:$fn,lastName:$ln,dateOfBirth:"1985-06-01",gender:"M"}')"
|
||||
patient_resp="$(request POST "${BASE_URL}/api/v1/patients" "${patient_payload}")"
|
||||
assert_status "201" "${patient_resp}"
|
||||
patient_id="$(jq -r '.data.id' "${patient_resp}")"
|
||||
|
||||
enc_payload='{"encounterType":"Inpatient","department":"ICU","attendingPhysician":"Dr. Phase13"}'
|
||||
enc_resp="$(request POST "${BASE_URL}/api/v1/patients/${patient_id}/encounters" "${enc_payload}")"
|
||||
assert_status "201" "${enc_resp}"
|
||||
encounter_id="$(jq -r '.data.id' "${enc_resp}")"
|
||||
echo "OK: patient=${patient_id} encounter=${encounter_id}"
|
||||
|
||||
echo "[3/${TOTAL_STEPS}] Ingest HR 72, wait 5s, ingest HR 95 → assert RAPID_DETERIORATION"
|
||||
baseline_at="$(date -u -d '10 minutes ago' +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-10M +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
climb_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
hr72_payload="$(ingest_hr "${encounter_id}" 72 "${baseline_at}")"
|
||||
obs_resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${hr72_payload}")"
|
||||
assert_status "201" "${obs_resp}"
|
||||
sleep 5
|
||||
hr95_payload="$(ingest_hr "${encounter_id}" 95 "${climb_at}")"
|
||||
obs_resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${hr95_payload}")"
|
||||
assert_status "201" "${obs_resp}"
|
||||
wait_for_consumer_lag_zero "${TREND_CONSUMER_GROUP}" "${TREND_WAIT_SECS}"
|
||||
|
||||
trend_count="$(psql_cmd "SELECT COUNT(*) FROM clinical_alerts WHERE encounter_id = '${encounter_id}' AND alert_type = 'RAPID_DETERIORATION'")"
|
||||
[[ "${trend_count}" == "1" ]] || {
|
||||
echo "Expected 1 RAPID_DETERIORATION alert, found ${trend_count}"
|
||||
psql_cmd "SELECT alert_type, severity, details FROM clinical_alerts WHERE encounter_id = '${encounter_id}'" || true
|
||||
exit 1
|
||||
}
|
||||
echo "OK: RAPID_DETERIORATION alert created"
|
||||
|
||||
echo "[4/${TOTAL_STEPS}] Re-ingest HR 95 → assert no duplicate trend alert"
|
||||
obs_resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${hr95_payload}")"
|
||||
assert_status "201" "${obs_resp}"
|
||||
wait_for_consumer_lag_zero "${TREND_CONSUMER_GROUP}" "${TREND_WAIT_SECS}"
|
||||
trend_count="$(psql_cmd "SELECT COUNT(*) FROM clinical_alerts WHERE encounter_id = '${encounter_id}' AND alert_type = 'RAPID_DETERIORATION'")"
|
||||
[[ "${trend_count}" == "1" ]] || {
|
||||
echo "Expected idempotent RAPID_DETERIORATION count=1, found ${trend_count}"
|
||||
exit 1
|
||||
}
|
||||
echo "OK: no duplicate RAPID_DETERIORATION alert"
|
||||
|
||||
echo "[5/${TOTAL_STEPS}] Ingest HR 105 (warning range) → assert WARNING_HEART_RATE created"
|
||||
hr105_payload="$(ingest_hr "${encounter_id}" 105)"
|
||||
obs_resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${hr105_payload}")"
|
||||
assert_status "201" "${obs_resp}"
|
||||
wait_for_consumer_lag_zero "${WARNING_CONSUMER_GROUP}" "${WARNING_WAIT_SECS}"
|
||||
|
||||
warning_count="$(psql_cmd "SELECT COUNT(*) FROM clinical_alerts WHERE encounter_id = '${encounter_id}' AND alert_type = 'WARNING_HEART_RATE'")"
|
||||
[[ "${warning_count}" == "1" ]] || {
|
||||
echo "Expected 1 WARNING_HEART_RATE alert, found ${warning_count}"
|
||||
psql_cmd "SELECT alert_type, severity, status FROM clinical_alerts WHERE encounter_id = '${encounter_id}'" || true
|
||||
exit 1
|
||||
}
|
||||
echo "OK: WARNING_HEART_RATE alert created"
|
||||
|
||||
echo "[6/${TOTAL_STEPS}] Acknowledge warning alert → assert Redis suppress key exists"
|
||||
alert_id="$(psql_cmd "SELECT id FROM clinical_alerts WHERE encounter_id = '${encounter_id}' AND alert_type = 'WARNING_HEART_RATE' LIMIT 1")"
|
||||
ack_resp="$(request POST "${BASE_URL}/api/v1/alerts/${alert_id}/acknowledge" \
|
||||
'{"clinicianId":"nurse-1","note":"monitoring"}')"
|
||||
assert_status "200" "${ack_resp}"
|
||||
|
||||
suppress_key="suppress:${encounter_id}:WARNING_HEART_RATE"
|
||||
redis_cmd EXISTS "${suppress_key}" | grep -q '^1$' || {
|
||||
echo "Expected Redis key ${suppress_key}"
|
||||
redis_cmd KEYS "suppress:${encounter_id}:*" || true
|
||||
exit 1
|
||||
}
|
||||
echo "OK: suppression key ${suppress_key} exists"
|
||||
|
||||
echo "[7/${TOTAL_STEPS}] Re-ingest HR 105 → assert no new WARNING_HEART_RATE (suppressed)"
|
||||
obs_resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${hr105_payload}")"
|
||||
assert_status "201" "${obs_resp}"
|
||||
wait_for_consumer_lag_zero "${WARNING_CONSUMER_GROUP}" "${WARNING_WAIT_SECS}"
|
||||
warning_count="$(psql_cmd "SELECT COUNT(*) FROM clinical_alerts WHERE encounter_id = '${encounter_id}' AND alert_type = 'WARNING_HEART_RATE'")"
|
||||
[[ "${warning_count}" == "1" ]] || {
|
||||
echo "Expected suppressed WARNING_HEART_RATE count=1, found ${warning_count}"
|
||||
exit 1
|
||||
}
|
||||
echo "OK: no new WARNING_HEART_RATE while suppressed"
|
||||
|
||||
echo "[8/${TOTAL_STEPS}] Ingest HR 160 (critical) → assert CRITICAL_HEART_RATE fires despite suppression"
|
||||
hr160_payload="$(ingest_hr "${encounter_id}" 160)"
|
||||
obs_resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${hr160_payload}")"
|
||||
assert_status "201" "${obs_resp}"
|
||||
critical_count="$(psql_cmd "SELECT COUNT(*) FROM clinical_alerts WHERE encounter_id = '${encounter_id}' AND alert_type = 'CRITICAL_HEART_RATE'")"
|
||||
[[ "${critical_count}" == "1" ]] || {
|
||||
echo "Expected 1 CRITICAL_HEART_RATE alert despite active WARNING suppression, found ${critical_count}"
|
||||
psql_cmd "SELECT alert_type, severity, status FROM clinical_alerts WHERE encounter_id = '${encounter_id}'" || true
|
||||
exit 1
|
||||
}
|
||||
echo "OK: CRITICAL_HEART_RATE created despite suppression window"
|
||||
|
||||
echo "[9/${TOTAL_STEPS}] Consumer lag = 0 for trend-analyzer and warning-evaluator"
|
||||
wait_for_consumer_lag_zero "${TREND_CONSUMER_GROUP}" "${TREND_WAIT_SECS}"
|
||||
wait_for_consumer_lag_zero "${WARNING_CONSUMER_GROUP}" "${WARNING_WAIT_SECS}"
|
||||
echo "OK: trend-analyzer and warning-evaluator lag = 0"
|
||||
|
||||
echo "[10/${TOTAL_STEPS}] Run Phase 13 unit/integration tests"
|
||||
dotnet test "${TEST_PROJECT}" --filter "${TEST_FILTER}"
|
||||
|
||||
if [[ "${FULL_TEST}" == "1" ]]; then
|
||||
echo "Running full test suite (FULL_TEST=1)"
|
||||
dotnet test "${ROOT_DIR}/VigilCareClinicalAPI.sln" 2>/dev/null || dotnet test "${ROOT_DIR}"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Phase 13 verification checks passed."
|
||||
echo "Encounter id: ${encounter_id}"
|
||||
Reference in New Issue
Block a user