From 101040f9d9eaf975b476a633a0d861803c80d3b3 Mon Sep 17 00:00:00 2001 From: voltsrage Date: Wed, 17 Jun 2026 14:36:10 +0800 Subject: [PATCH] feature: Reconciliation Jobs --- .../AlertLifecycleTests.cs | 2 +- .../NotificationPipelineTests.cs | 2 +- .../ObservationIngestTests.cs | 2 +- .../ReconciliationTests.cs | 391 ++++++++++++ .../SirsDetectorTests.cs | 2 +- .../ElasticIndexProvisioner.cs | 0 .../{ => ElasticsSearch}/EsIndexerService.cs | 0 .../DischargeSummaryWorkerService.cs | 4 +- .../DisconnectedMonitorsCheck.cs | 96 +++ .../Reconciliation/PendingOrdersCheck.cs | 93 +++ .../Reconciliation/ReconciliationPublisher.cs | 62 ++ .../Reconciliation/ReconciliationScheduler.cs | 62 ++ .../UnacknowledgedAlertsCheck.cs | 89 +++ .../Configuration/ReconciliationJobOptions.cs | 18 + .../Controllers/AlertsController.cs | 15 +- .../Configurations/EncounterConfiguration.cs | 10 +- .../Data/Configurations/OrderConfiguration.cs | 13 +- .../ReconciliationAlertConfiguration.cs | 9 + VigilCareClinicalAPI/Data/Seed/DataSeeder.cs | 4 +- .../Domains/Entities/Encounter.cs | 2 +- .../Domains/Entities/Order.cs | 2 +- .../Domains/Entities/ReconciliationAlert.cs | 3 + .../Domains/Enums/Department.cs | 34 + .../Domains/Enums/OrderStatus.cs | 22 + .../Domains/Json/DepartmentJsonConverter.cs | 11 + ...053159_AddReconciliationAlerts.Designer.cs | 589 +++++++++++++++++ .../20260617053159_AddReconciliationAlerts.cs | 60 ++ ...dOrderStatusAndDepartmentEnums.Designer.cs | 593 ++++++++++++++++++ ...054821_AddOrderStatusAndDepartmentEnums.cs | 102 +++ .../Migrations/AppDbContextModelSnapshot.cs | 31 +- .../Records/Encounter/OpenEncounterRequest.cs | 2 +- .../RabbitMqTopologyProvisioner.cs | 26 +- VigilCareClinicalAPI/Program.cs | 10 + VigilCareClinicalAPI/Services/AlertService.cs | 6 +- .../Services/EncounterService.cs | 2 +- .../Services/Interfaces/IAlertService.cs | 2 +- .../Services/ObservationService.cs | 2 +- .../Services/PatientService.cs | 2 +- VigilCareClinicalAPI/appsettings.json | 6 + scripts/run-reconciliation-tests.sh | 496 +++++++++++++++ 40 files changed, 2845 insertions(+), 32 deletions(-) create mode 100644 VigilCareClinicalAPI.Tests/ReconciliationTests.cs rename VigilCareClinicalAPI/BackgroundServices/{ => ElasticsSearch}/ElasticIndexProvisioner.cs (100%) rename VigilCareClinicalAPI/BackgroundServices/{ => ElasticsSearch}/EsIndexerService.cs (100%) create mode 100644 VigilCareClinicalAPI/BackgroundServices/Reconciliation/DisconnectedMonitorsCheck.cs create mode 100644 VigilCareClinicalAPI/BackgroundServices/Reconciliation/PendingOrdersCheck.cs create mode 100644 VigilCareClinicalAPI/BackgroundServices/Reconciliation/ReconciliationPublisher.cs create mode 100644 VigilCareClinicalAPI/BackgroundServices/Reconciliation/ReconciliationScheduler.cs create mode 100644 VigilCareClinicalAPI/BackgroundServices/Reconciliation/UnacknowledgedAlertsCheck.cs create mode 100644 VigilCareClinicalAPI/Configuration/ReconciliationJobOptions.cs create mode 100644 VigilCareClinicalAPI/Domains/Enums/Department.cs create mode 100644 VigilCareClinicalAPI/Domains/Enums/OrderStatus.cs create mode 100644 VigilCareClinicalAPI/Domains/Json/DepartmentJsonConverter.cs create mode 100644 VigilCareClinicalAPI/Migrations/20260617053159_AddReconciliationAlerts.Designer.cs create mode 100644 VigilCareClinicalAPI/Migrations/20260617053159_AddReconciliationAlerts.cs create mode 100644 VigilCareClinicalAPI/Migrations/20260617054821_AddOrderStatusAndDepartmentEnums.Designer.cs create mode 100644 VigilCareClinicalAPI/Migrations/20260617054821_AddOrderStatusAndDepartmentEnums.cs create mode 100755 scripts/run-reconciliation-tests.sh diff --git a/VigilCareClinicalAPI.Tests/AlertLifecycleTests.cs b/VigilCareClinicalAPI.Tests/AlertLifecycleTests.cs index 0209a7c..1751c00 100644 --- a/VigilCareClinicalAPI.Tests/AlertLifecycleTests.cs +++ b/VigilCareClinicalAPI.Tests/AlertLifecycleTests.cs @@ -31,7 +31,7 @@ public class AlertLifecycleTests : IAsyncLifetime var encounter = new Encounter { Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient, - Status = EncounterStatus.Active, Department = "General Medicine", + Status = EncounterStatus.Active, Department = Department.GeneralMedicine, AttendingPhysician = "Dr. Test", AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow }; diff --git a/VigilCareClinicalAPI.Tests/NotificationPipelineTests.cs b/VigilCareClinicalAPI.Tests/NotificationPipelineTests.cs index d63983c..0e8f164 100644 --- a/VigilCareClinicalAPI.Tests/NotificationPipelineTests.cs +++ b/VigilCareClinicalAPI.Tests/NotificationPipelineTests.cs @@ -130,7 +130,7 @@ public class NotificationPipelineTests : IAsyncLifetime var resp = await _http.PostAsJsonAsync( $"/api/v1/patients/{patientId}/encounters", - new OpenEncounterRequest(EncounterType.Inpatient, "ICU", "Dr. Osei")); + new OpenEncounterRequest(EncounterType.Inpatient, Department.Icu, "Dr. Osei")); resp.EnsureSuccessStatusCode(); var body = await resp.Content.ReadFromJsonAsync(); diff --git a/VigilCareClinicalAPI.Tests/ObservationIngestTests.cs b/VigilCareClinicalAPI.Tests/ObservationIngestTests.cs index eeae802..1f7972d 100644 --- a/VigilCareClinicalAPI.Tests/ObservationIngestTests.cs +++ b/VigilCareClinicalAPI.Tests/ObservationIngestTests.cs @@ -35,7 +35,7 @@ public class ObservationIngestTests : IAsyncLifetime var encounter = new Encounter { Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient, - Status = EncounterStatus.Active, Department = "ICU", + Status = EncounterStatus.Active, Department = Department.Icu, AttendingPhysician = "Dr. Test", AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow }; diff --git a/VigilCareClinicalAPI.Tests/ReconciliationTests.cs b/VigilCareClinicalAPI.Tests/ReconciliationTests.cs new file mode 100644 index 0000000..7fb60d0 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/ReconciliationTests.cs @@ -0,0 +1,391 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; + +[Collection("Integration")] +public class ReconciliationTests : IAsyncLifetime +{ + private readonly ApiFixture _fixture; + private readonly HttpClient _http; + + public ReconciliationTests(ApiFixture fixture) + { + _fixture = fixture; + _http = fixture.CreateClient(); + } + + public Task InitializeAsync() => Task.CompletedTask; + + public Task DisposeAsync() => Task.CompletedTask; + + // ------------------------------------------------------------------------- + // Check 1 — Unacknowledged critical alerts + // ------------------------------------------------------------------------- + + [Fact] + public async Task Check1_StaleCriticalAlert_CreatesReconciliationAlert() + { + await PrepareAsync(); + + var patientId = await CreatePatientAsync(); + var encounterId = await CreateActiveEncounterAsync(patientId); + var alertId = await IngestCriticalPotassiumAsync(encounterId); + + using (var scope = _fixture.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var alert = await db.ClinicalAlerts.FindAsync(alertId); + alert!.TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-31); + await db.SaveChangesAsync(); + } + + await using var checkScope = _fixture.Services.CreateAsyncScope(); + var check = checkScope.ServiceProvider.GetRequiredService(); + var count = await check.RunAsync(CancellationToken.None); + + Assert.Equal(1, count); + + using var verifyScope = _fixture.Services.CreateScope(); + var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); + var rec = await verifyDb.ReconciliationAlerts + .FirstOrDefaultAsync(r => r.CheckType == UnacknowledgedAlertsCheck.CheckType + && r.EncounterId == encounterId + && r.ResolvedAt == null); + + Assert.NotNull(rec); + Assert.Contains(alertId.ToString(), rec!.Details); + } + + [Fact] + public async Task Check1_AcknowledgedAlert_DoesNotCreateReconciliationAlert() + { + await PrepareAsync(); + + var patientId = await CreatePatientAsync(); + var encounterId = await CreateActiveEncounterAsync(patientId); + var alertId = await IngestCriticalPotassiumAsync(encounterId); + + var ackResp = await _http.PostAsJsonAsync( + $"/api/v1/alerts/{alertId}/acknowledge", + new AcknowledgeAlertRequest("Dr. Mensah", "Reviewed.")); + ackResp.EnsureSuccessStatusCode(); + + using (var scope = _fixture.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var alert = await db.ClinicalAlerts.FindAsync(alertId); + alert!.TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-31); + await db.SaveChangesAsync(); + } + + await using var checkScope = _fixture.Services.CreateAsyncScope(); + var check = checkScope.ServiceProvider.GetRequiredService(); + var count = await check.RunAsync(CancellationToken.None); + + Assert.Equal(0, count); + } + + [Fact] + public async Task Check1_SecondRun_DoesNotInsertDuplicate() + { + await PrepareAsync(); + + var patientId = await CreatePatientAsync(); + var encounterId = await CreateActiveEncounterAsync(patientId); + await IngestCriticalPotassiumAsync(encounterId); + + using (var scope = _fixture.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var alert = await db.ClinicalAlerts.FirstAsync(a => a.EncounterId == encounterId); + alert.TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-31); + await db.SaveChangesAsync(); + } + + await using (var s = _fixture.Services.CreateAsyncScope()) + { + var c = s.ServiceProvider.GetRequiredService(); + await c.RunAsync(CancellationToken.None); + } + + int secondCount; + await using (var s = _fixture.Services.CreateAsyncScope()) + { + var c = s.ServiceProvider.GetRequiredService(); + secondCount = await c.RunAsync(CancellationToken.None); + } + + Assert.Equal(0, secondCount); + } + + // ------------------------------------------------------------------------- + // Check 2 — Pending orders without results + // ------------------------------------------------------------------------- + + [Fact] + public async Task Check2_StalePendingOrder_CreatesReconciliationAlert() + { + await PrepareAsync(); + + var patientId = await CreatePatientAsync(); + var encounterId = await CreateActiveEncounterAsync(patientId); + + using (var scope = _fixture.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Orders.Add(new Order + { + Id = Guid.NewGuid(), + EncounterId = encounterId, + OrderType = OrderType.Lab, + Description = "Comprehensive metabolic panel", + OrderedBy = "Dr. Osei", + Status = OrderStatus.Pending, + OrderedAt = DateTimeOffset.UtcNow.AddHours(-5), + ResultedAt = null, + }); + await db.SaveChangesAsync(); + } + + await using var checkScope = _fixture.Services.CreateAsyncScope(); + var check = checkScope.ServiceProvider.GetRequiredService(); + var count = await check.RunAsync(CancellationToken.None); + + Assert.True(count >= 1); + + using var verifyScope = _fixture.Services.CreateScope(); + var db2 = verifyScope.ServiceProvider.GetRequiredService(); + var rec = await db2.ReconciliationAlerts + .FirstOrDefaultAsync(r => r.CheckType == PendingOrdersCheck.CheckType + && r.EncounterId == encounterId + && r.ResolvedAt == null); + Assert.NotNull(rec); + Assert.Contains("Comprehensive metabolic panel", rec!.Details); + } + + [Fact] + public async Task Check2_ResultedOrder_DoesNotCreateReconciliationAlert() + { + await PrepareAsync(); + + var patientId = await CreatePatientAsync(); + var encounterId = await CreateActiveEncounterAsync(patientId); + + using (var scope = _fixture.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Orders.Add(new Order + { + Id = Guid.NewGuid(), + EncounterId = encounterId, + OrderType = OrderType.Lab, + Description = "CBC", + OrderedBy = "Dr. Osei", + Status = OrderStatus.Resulted, + OrderedAt = DateTimeOffset.UtcNow.AddHours(-5), + ResultedAt = DateTimeOffset.UtcNow.AddHours(-4), + }); + await db.SaveChangesAsync(); + } + + await using var checkScope = _fixture.Services.CreateAsyncScope(); + var check = checkScope.ServiceProvider.GetRequiredService(); + var count = await check.RunAsync(CancellationToken.None); + + Assert.Equal(0, count); + } + + // ------------------------------------------------------------------------- + // Check 3 — Active inpatient without recent observations + // ------------------------------------------------------------------------- + + [Fact] + public async Task Check3_ActiveInpatientNoObservations_CreatesReconciliationAlert() + { + await PrepareAsync(); + + var patientId = await CreatePatientAsync(); + var encounterId = await CreateActiveEncounterAsync(patientId); + + await using var checkScope = _fixture.Services.CreateAsyncScope(); + var check = checkScope.ServiceProvider.GetRequiredService(); + var count = await check.RunAsync(CancellationToken.None); + + Assert.True(count >= 1); + + using var verifyScope = _fixture.Services.CreateScope(); + var db = verifyScope.ServiceProvider.GetRequiredService(); + var rec = await db.ReconciliationAlerts + .FirstOrDefaultAsync(r => r.CheckType == DisconnectedMonitorsCheck.CheckType + && r.EncounterId == encounterId + && r.ResolvedAt == null); + Assert.NotNull(rec); + Assert.Contains("no observations ever recorded", rec!.Details); + } + + [Fact] + public async Task Check3_ActiveInpatientStaleObservation_CreatesReconciliationAlert() + { + await PrepareAsync(); + + var patientId = await CreatePatientAsync(); + var encounterId = await CreateActiveEncounterAsync(patientId); + + var ingestResp = await _http.PostAsJsonAsync( + $"/api/v1/encounters/{encounterId}/observations", + new BatchIngestRequest(new List + { + new("HEART_RATE", 72, "bpm", ObservationSource.Device, + DateTimeOffset.UtcNow.AddHours(-3), null), + })); + ingestResp.EnsureSuccessStatusCode(); + + await using var checkScope = _fixture.Services.CreateAsyncScope(); + var check = checkScope.ServiceProvider.GetRequiredService(); + var count = await check.RunAsync(CancellationToken.None); + + Assert.True(count >= 1); + + using var verifyScope = _fixture.Services.CreateScope(); + var db = verifyScope.ServiceProvider.GetRequiredService(); + var rec = await db.ReconciliationAlerts + .FirstOrDefaultAsync(r => r.CheckType == DisconnectedMonitorsCheck.CheckType + && r.EncounterId == encounterId + && r.ResolvedAt == null); + Assert.NotNull(rec); + } + + [Fact] + public async Task Check3_RecentObservation_DoesNotCreateReconciliationAlert() + { + await PrepareAsync(); + + var patientId = await CreatePatientAsync(); + var encounterId = await CreateActiveEncounterAsync(patientId); + + var ingestResp = await _http.PostAsJsonAsync( + $"/api/v1/encounters/{encounterId}/observations", + new BatchIngestRequest(new List + { + new("HEART_RATE", 72, "bpm", ObservationSource.Device, + DateTimeOffset.UtcNow.AddMinutes(-30), null), + })); + ingestResp.EnsureSuccessStatusCode(); + + await using var checkScope = _fixture.Services.CreateAsyncScope(); + var check = checkScope.ServiceProvider.GetRequiredService(); + var count = await check.RunAsync(CancellationToken.None); + + Assert.Equal(0, count); + } + + [Fact] + public async Task Check3_OutpatientEncounter_IsIgnored() + { + await PrepareAsync(); + + var patientId = await CreatePatientAsync(); + + var resp = await _http.PostAsJsonAsync( + $"/api/v1/patients/{patientId}/encounters", + new OpenEncounterRequest(EncounterType.Outpatient, Department.GeneralMedicine, "Dr. Osei")); + resp.EnsureSuccessStatusCode(); + var body = await resp.Content.ReadFromJsonAsync(); + var encounterId = body!.RootElement.GetProperty("data").GetProperty("id").GetGuid(); + + await using var checkScope = _fixture.Services.CreateAsyncScope(); + var check = checkScope.ServiceProvider.GetRequiredService(); + await check.RunAsync(CancellationToken.None); + + using var verifyScope = _fixture.Services.CreateScope(); + var db = verifyScope.ServiceProvider.GetRequiredService(); + var rec = await db.ReconciliationAlerts + .FirstOrDefaultAsync(r => r.CheckType == DisconnectedMonitorsCheck.CheckType + && r.EncounterId == encounterId); + Assert.Null(rec); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private async Task PrepareAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + + db.AlertThresholds.AddRange( + new AlertThreshold + { + Id = Guid.NewGuid(), + ObservationCode = "POTASSIUM_MEQ_L", + DisplayName = "Serum Potassium", + Unit = "mEq/L", + CriticalLow = 2.5m, + WarningLow = 3.5m, + WarningHigh = 5.0m, + CriticalHigh = 6.5m, + CreatedAt = DateTimeOffset.UtcNow, + }, + new AlertThreshold + { + Id = Guid.NewGuid(), + ObservationCode = "HEART_RATE", + DisplayName = "Heart Rate", + Unit = "bpm", + CriticalLow = 30, + WarningLow = 50, + WarningHigh = 100, + CriticalHigh = 150, + CreatedAt = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + + var redis = scope.ServiceProvider.GetRequiredService(); + var cache = redis.GetDatabase(1); + await cache.StringSetAsync( + "threshold:POTASSIUM_MEQ_L", + """{"ObservationCode":"POTASSIUM_MEQ_L","CriticalLow":2.5,"WarningLow":3.5,"WarningHigh":5.0,"CriticalHigh":6.5}"""); + await cache.StringSetAsync( + "threshold:HEART_RATE", + """{"ObservationCode":"HEART_RATE","CriticalLow":30,"WarningLow":50,"WarningHigh":100,"CriticalHigh":150}"""); + } + + private async Task CreatePatientAsync() + { + var resp = await _http.PostAsJsonAsync( + "/api/v1/patients", + new RegisterPatientRequest("Reconciliation", "Test", new DateOnly(1970, 6, 1), "Other")); + resp.EnsureSuccessStatusCode(); + var body = await resp.Content.ReadFromJsonAsync(); + return body!.RootElement.GetProperty("data").GetProperty("id").GetGuid(); + } + + private async Task CreateActiveEncounterAsync(Guid patientId) + { + var resp = await _http.PostAsJsonAsync( + $"/api/v1/patients/{patientId}/encounters", + new OpenEncounterRequest(EncounterType.Inpatient, Department.Icu, "Dr. Osei")); + resp.EnsureSuccessStatusCode(); + var body = await resp.Content.ReadFromJsonAsync(); + return body!.RootElement.GetProperty("data").GetProperty("id").GetGuid(); + } + + private async Task IngestCriticalPotassiumAsync(Guid encounterId) + { + var ingestResp = await _http.PostAsJsonAsync( + $"/api/v1/encounters/{encounterId}/observations", + new BatchIngestRequest(new List + { + new("POTASSIUM_MEQ_L", 2.1m, "mEq/L", ObservationSource.Device, + DateTimeOffset.UtcNow, null), + })); + ingestResp.EnsureSuccessStatusCode(); + + var body = await ingestResp.Content.ReadFromJsonAsync(); + return body!.RootElement.GetProperty("data").GetProperty("alertId").GetGuid(); + } +} diff --git a/VigilCareClinicalAPI.Tests/SirsDetectorTests.cs b/VigilCareClinicalAPI.Tests/SirsDetectorTests.cs index f3c46dd..76462f0 100644 --- a/VigilCareClinicalAPI.Tests/SirsDetectorTests.cs +++ b/VigilCareClinicalAPI.Tests/SirsDetectorTests.cs @@ -28,7 +28,7 @@ public class SirsDetectorTests : IAsyncLifetime var encounter = new Encounter { Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient, - Status = EncounterStatus.Active, Department = "ICU", + Status = EncounterStatus.Active, Department = Department.Icu, AttendingPhysician = "Dr. SIRS", AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow }; diff --git a/VigilCareClinicalAPI/BackgroundServices/ElasticIndexProvisioner.cs b/VigilCareClinicalAPI/BackgroundServices/ElasticsSearch/ElasticIndexProvisioner.cs similarity index 100% rename from VigilCareClinicalAPI/BackgroundServices/ElasticIndexProvisioner.cs rename to VigilCareClinicalAPI/BackgroundServices/ElasticsSearch/ElasticIndexProvisioner.cs diff --git a/VigilCareClinicalAPI/BackgroundServices/EsIndexerService.cs b/VigilCareClinicalAPI/BackgroundServices/ElasticsSearch/EsIndexerService.cs similarity index 100% rename from VigilCareClinicalAPI/BackgroundServices/EsIndexerService.cs rename to VigilCareClinicalAPI/BackgroundServices/ElasticsSearch/EsIndexerService.cs diff --git a/VigilCareClinicalAPI/BackgroundServices/Notifications/DischargeSummaryWorkerService.cs b/VigilCareClinicalAPI/BackgroundServices/Notifications/DischargeSummaryWorkerService.cs index 1307f21..9b0614e 100644 --- a/VigilCareClinicalAPI/BackgroundServices/Notifications/DischargeSummaryWorkerService.cs +++ b/VigilCareClinicalAPI/BackgroundServices/Notifications/DischargeSummaryWorkerService.cs @@ -109,7 +109,7 @@ public sealed class DischargeSummaryWorkerService : BackgroundService sb.AppendLine(); sb.AppendLine($"Encounter ID: {encounter.Id}"); sb.AppendLine($"Type: {encounter.EncounterType}"); - sb.AppendLine($"Department: {encounter.Department}"); + sb.AppendLine($"Department: {encounter.Department.ToDbString()}"); sb.AppendLine($"Attending: {encounter.AttendingPhysician}"); sb.AppendLine($"Admitted: {encounter.AdmittedAt:u}"); sb.AppendLine($"Discharged: {encounter.DischargedAt:u}"); @@ -122,7 +122,7 @@ public sealed class DischargeSummaryWorkerService : BackgroundService { sb.AppendLine("--- Orders ---"); foreach (var o in orders) - sb.AppendLine($" [{o.Status}] {o.OrderType.ToDbString()}: {o.Description} (ordered: {o.OrderedAt:u})"); + sb.AppendLine($" [{o.Status.ToDbString()}] {o.OrderType.ToDbString()}: {o.Description} (ordered: {o.OrderedAt:u})"); } sb.AppendLine(); diff --git a/VigilCareClinicalAPI/BackgroundServices/Reconciliation/DisconnectedMonitorsCheck.cs b/VigilCareClinicalAPI/BackgroundServices/Reconciliation/DisconnectedMonitorsCheck.cs new file mode 100644 index 0000000..d8b1488 --- /dev/null +++ b/VigilCareClinicalAPI/BackgroundServices/Reconciliation/DisconnectedMonitorsCheck.cs @@ -0,0 +1,96 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +public sealed class DisconnectedMonitorsCheck +{ + public static readonly ReconciliationCheckType CheckType = ReconciliationCheckType.ActiveInpatientNoObservation; + + private readonly AppDbContext _db; + private readonly ReconciliationPublisher _publisher; + private readonly ReconciliationJobOptions _opts; + private readonly ILogger _logger; + + public DisconnectedMonitorsCheck( + AppDbContext db, + ReconciliationPublisher publisher, + IOptions opts, + ILogger logger) + { + _db = db; + _publisher = publisher; + _opts = opts.Value; + _logger = logger; + } + + public async Task RunAsync(CancellationToken ct) + { + var cutoff = DateTimeOffset.UtcNow + .AddHours(-_opts.NoObservationThresholdHours); + + // Limitation: this check detects absence of recorded observations, not absence + // of actual clinical monitoring. A nurse who took manual vitals but did not + // enter them into the system would still trigger a reconciliation alert. + // This is a known gap; the check errs on the side of false positives. + var candidates = await _db.Encounters + .Where(e => e.Status == EncounterStatus.Active + && e.EncounterType == EncounterType.Inpatient) + .Select(e => new + { + EncounterId = e.Id, + e.PatientId, + LastObservationAt = e.Observations + .OrderByDescending(o => o.RecordedAt) + .Select(o => (DateTimeOffset?)o.RecordedAt) + .FirstOrDefault(), + }) + .ToListAsync(ct); + + var stale = candidates + .Where(e => e.LastObservationAt == null || e.LastObservationAt < cutoff) + .ToList(); + + if (!stale.Any()) + { + _logger.LogDebug("[RECONCILIATION] Check3 — all active inpatients have recent observations"); + return 0; + } + + var inserted = 0; + foreach (var enc in stale) + { + var alreadyOpen = await _db.ReconciliationAlerts + .AnyAsync(r => r.CheckType == CheckType + && r.EncounterId == enc.EncounterId + && r.ResolvedAt == null, ct); + if (alreadyOpen) continue; + + var lastObs = enc.LastObservationAt.HasValue + ? $"last observation {enc.LastObservationAt:u}" + : "no observations ever recorded"; + var details = $"Active inpatient encounter {enc.EncounterId} has no recent observations " + + $"({lastObs}). Monitor may be disconnected."; + + var rec = new ReconciliationAlert + { + Id = Guid.NewGuid(), + CheckType = CheckType, // ReconciliationCheckType enum + EncounterId = enc.EncounterId, + PatientId = enc.PatientId, + Details = details, + CreatedAt = DateTimeOffset.UtcNow, + }; + + _db.ReconciliationAlerts.Add(rec); + await _db.SaveChangesAsync(ct); + await _publisher.PublishAsync(rec, ct); + + _logger.LogWarning( + "[RECONCILIATION] {CheckType} — encounter {EncounterId}: {LastObs}", + CheckType, enc.EncounterId, lastObs); + + inserted++; + } + + return inserted; + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/BackgroundServices/Reconciliation/PendingOrdersCheck.cs b/VigilCareClinicalAPI/BackgroundServices/Reconciliation/PendingOrdersCheck.cs new file mode 100644 index 0000000..3f958e6 --- /dev/null +++ b/VigilCareClinicalAPI/BackgroundServices/Reconciliation/PendingOrdersCheck.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +public sealed class PendingOrdersCheck +{ + public static readonly ReconciliationCheckType CheckType = ReconciliationCheckType.PendingOrderNoResult; + + private readonly AppDbContext _db; + private readonly ReconciliationPublisher _publisher; + private readonly ReconciliationJobOptions _opts; + private readonly ILogger _logger; + + public PendingOrdersCheck( + AppDbContext db, + ReconciliationPublisher publisher, + IOptions opts, + ILogger logger) + { + _db = db; + _publisher = publisher; + _opts = opts.Value; + _logger = logger; + } + + public async Task RunAsync(CancellationToken ct) + { + var cutoff = DateTimeOffset.UtcNow + .AddHours(-_opts.PendingOrderThresholdHours); + + var staleOrders = await _db.Orders + .Where(o => (o.Status == OrderStatus.Pending || o.Status == OrderStatus.InProgress) + && o.OrderedAt < cutoff + && o.ResultedAt == null) + .Select(o => new + { + o.Id, + o.EncounterId, + o.OrderType, + o.Description, + o.OrderedAt, + PatientId = o.Encounter!.PatientId, + }) + .ToListAsync(ct); + + if (!staleOrders.Any()) + { + _logger.LogDebug("[RECONCILIATION] Check2 — no stale pending orders"); + return 0; + } + + var byEncounter = staleOrders + .GroupBy(o => new { o.EncounterId, o.PatientId }) + .ToList(); + + var inserted = 0; + foreach (var group in byEncounter) + { + var alreadyOpen = await _db.ReconciliationAlerts + .AnyAsync(r => r.CheckType == CheckType + && r.EncounterId == group.Key.EncounterId + && r.ResolvedAt == null, ct); + if (alreadyOpen) continue; + + var lines = group.Select(o => + $" [{o.OrderType.ToDbString()}] {o.Description} (ordered {o.OrderedAt:u}, " + + $"pending {(DateTimeOffset.UtcNow - o.OrderedAt).TotalHours:F1}h) ID={o.Id}"); + var details = $"{group.Count()} order(s) pending without result for encounter " + + $"{group.Key.EncounterId}:\n{string.Join("\n", lines)}"; + + var rec = new ReconciliationAlert + { + Id = Guid.NewGuid(), + CheckType = CheckType, // ReconciliationCheckType enum + EncounterId = group.Key.EncounterId, + PatientId = group.Key.PatientId, + Details = details, + CreatedAt = DateTimeOffset.UtcNow, + }; + + _db.ReconciliationAlerts.Add(rec); + await _db.SaveChangesAsync(ct); + await _publisher.PublishAsync(rec, ct); + + _logger.LogWarning( + "[RECONCILIATION] {CheckType} — encounter {EncounterId}: {Count} stale order(s)", + CheckType, group.Key.EncounterId, group.Count()); + + inserted++; + } + + return inserted; + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/BackgroundServices/Reconciliation/ReconciliationPublisher.cs b/VigilCareClinicalAPI/BackgroundServices/Reconciliation/ReconciliationPublisher.cs new file mode 100644 index 0000000..8b5f308 --- /dev/null +++ b/VigilCareClinicalAPI/BackgroundServices/Reconciliation/ReconciliationPublisher.cs @@ -0,0 +1,62 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using RabbitMQ.Client; + +public interface IReconciliationPublisher +{ + Task PublishAsync(ReconciliationAlert alert, CancellationToken ct); +} + +public sealed class ReconciliationPublisher : IReconciliationPublisher +{ + private readonly RabbitMqOptions _opts; + private readonly ILogger _logger; + + public ReconciliationPublisher( + IOptions opts, + ILogger logger) + { + _opts = opts.Value; + _logger = logger; + } + + public Task PublishAsync(ReconciliationAlert alert, CancellationToken ct) + { + var factory = new ConnectionFactory + { + HostName = _opts.Host, + Port = _opts.Port, + UserName = _opts.Username, + Password = _opts.Password, + }; + + using var connection = factory.CreateConnection("reconciliation-publisher"); + using var channel = connection.CreateModel(); + + var props = channel.CreateBasicProperties(); + props.Persistent = true; + + var payload = JsonSerializer.Serialize(new + { + reconciliationAlertId = alert.Id, + checkType = alert.CheckType.ToDbString(), + encounterId = alert.EncounterId, + patientId = alert.PatientId, + details = alert.Details, + createdAt = alert.CreatedAt, + }); + + channel.BasicPublish( + exchange: RabbitMqTopologyProvisioner.Exchange, + routingKey: RabbitMqTopologyProvisioner.ReconciliationKey, + basicProperties: props, + body: Encoding.UTF8.GetBytes(payload)); + + _logger.LogInformation( + "[RECONCILIATION-PUBLISHED] CheckType={CheckType} EncounterId={EncounterId}", + alert.CheckType.ToDbString(), alert.EncounterId); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/BackgroundServices/Reconciliation/ReconciliationScheduler.cs b/VigilCareClinicalAPI/BackgroundServices/Reconciliation/ReconciliationScheduler.cs new file mode 100644 index 0000000..460e5d4 --- /dev/null +++ b/VigilCareClinicalAPI/BackgroundServices/Reconciliation/ReconciliationScheduler.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.Options; + +public sealed class ReconciliationScheduler : BackgroundService +{ + private readonly IServiceScopeFactory _scopes; + private readonly ReconciliationJobOptions _opts; + private readonly ILogger _logger; + + public ReconciliationScheduler( + IServiceScopeFactory scopes, + IOptions opts, + ILogger logger) + { + _scopes = scopes; + _opts = opts.Value; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Wait for RabbitMQ topology and migrations before first cycle. + await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); + + _logger.LogInformation( + "ReconciliationScheduler started — interval {IntervalMinutes} min", _opts.IntervalMinutes); + + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(_opts.IntervalMinutes)); + + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + _logger.LogInformation("[RECONCILIATION] Starting reconciliation cycle"); + + await RunCheckAsync( + check => check.RunAsync(stoppingToken), stoppingToken); + + await RunCheckAsync( + check => check.RunAsync(stoppingToken), stoppingToken); + + await RunCheckAsync( + check => check.RunAsync(stoppingToken), stoppingToken); + + _logger.LogInformation("[RECONCILIATION] Cycle complete"); + } + } + + private async Task RunCheckAsync(Func> run, CancellationToken ct) + where T : notnull + { + try + { + await using var scope = _scopes.CreateAsyncScope(); + var check = scope.ServiceProvider.GetRequiredService(); + var count = await run(check); + _logger.LogInformation( + "[RECONCILIATION] {Check} — new alerts: {Count}", typeof(T).Name, count); + } + catch (Exception ex) + { + _logger.LogError(ex, "[RECONCILIATION] {Check} failed", typeof(T).Name); + } + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/BackgroundServices/Reconciliation/UnacknowledgedAlertsCheck.cs b/VigilCareClinicalAPI/BackgroundServices/Reconciliation/UnacknowledgedAlertsCheck.cs new file mode 100644 index 0000000..af1a178 --- /dev/null +++ b/VigilCareClinicalAPI/BackgroundServices/Reconciliation/UnacknowledgedAlertsCheck.cs @@ -0,0 +1,89 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +public sealed class UnacknowledgedAlertsCheck +{ + public static readonly ReconciliationCheckType CheckType = ReconciliationCheckType.UnacknowledgedCriticalAlert; + + private readonly AppDbContext _db; + private readonly ReconciliationPublisher _publisher; + private readonly ReconciliationJobOptions _opts; + private readonly ILogger _logger; + + public UnacknowledgedAlertsCheck( + AppDbContext db, + ReconciliationPublisher publisher, + IOptions opts, + ILogger logger) + { + _db = db; + _publisher = publisher; + _opts = opts.Value; + _logger = logger; + } + + public async Task RunAsync(CancellationToken ct) + { + var cutoff = DateTimeOffset.UtcNow + .AddMinutes(-_opts.UnacknowledgedAlertThresholdMinutes); + + // Load all stale critical alerts, grouped by encounter. + var staleAlerts = await _db.ClinicalAlerts + .Where(a => a.Severity == AlertSeverity.Critical + && a.Status == AlertStatus.Open + && a.TriggeredAt < cutoff) + .Select(a => new { a.Id, a.EncounterId, a.PatientId, a.TriggeredAt, a.Details }) + .ToListAsync(ct); + + if (!staleAlerts.Any()) + { + _logger.LogDebug("[RECONCILIATION] Check1 — no stale critical alerts"); + return 0; + } + + // Group by encounter so one reconciliation_alert covers all alerts in that encounter. + var byEncounter = staleAlerts + .GroupBy(a => new { a.EncounterId, a.PatientId }) + .ToList(); + + var inserted = 0; + foreach (var group in byEncounter) + { + // Deduplication: skip if an open reconciliation_alert already exists + // for this check type and encounter (partial index makes this fast). + var alreadyOpen = await _db.ReconciliationAlerts + .AnyAsync(r => r.CheckType == CheckType + && r.EncounterId == group.Key.EncounterId + && r.ResolvedAt == null, ct); + if (alreadyOpen) continue; + + var alertIds = group.Select(a => a.Id).ToList(); + var oldest = group.Min(a => a.TriggeredAt); + var details = $"{alertIds.Count} unacknowledged CRITICAL alert(s) for encounter " + + $"{group.Key.EncounterId}. Oldest triggered at {oldest:u}. " + + $"Alert IDs: {string.Join(", ", alertIds)}."; + + var rec = new ReconciliationAlert + { + Id = Guid.NewGuid(), + CheckType = CheckType, // ReconciliationCheckType enum + EncounterId = group.Key.EncounterId, + PatientId = group.Key.PatientId, + Details = details, + CreatedAt = DateTimeOffset.UtcNow, + }; + + _db.ReconciliationAlerts.Add(rec); + await _db.SaveChangesAsync(ct); + await _publisher.PublishAsync(rec, ct); + + _logger.LogWarning( + "[RECONCILIATION] {CheckType} — encounter {EncounterId}: {Details}", + CheckType, group.Key.EncounterId, details); + + inserted++; + } + + return inserted; + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Configuration/ReconciliationJobOptions.cs b/VigilCareClinicalAPI/Configuration/ReconciliationJobOptions.cs new file mode 100644 index 0000000..be540f2 --- /dev/null +++ b/VigilCareClinicalAPI/Configuration/ReconciliationJobOptions.cs @@ -0,0 +1,18 @@ +public sealed class ReconciliationJobOptions +{ + public const string Section = "ReconciliationJobs"; + + // How often the scheduler fires all three checks. + // Production: 30 minutes. Set lower in development to observe behavior quickly. + public int IntervalMinutes { get; init; } = 30; + + // Check 1: critical alerts open longer than this are a patient safety failure. + public int UnacknowledgedAlertThresholdMinutes { get; init; } = 30; + + // Check 2: orders pending longer than this may indicate a lost sample or LIS failure. + public int PendingOrderThresholdHours { get; init; } = 4; + + // Check 3: active inpatients without an observation in this window may have + // a disconnected monitor or an undocumented physical move. + public int NoObservationThresholdHours { get; init; } = 2; +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Controllers/AlertsController.cs b/VigilCareClinicalAPI/Controllers/AlertsController.cs index 9bbcd74..c1ff575 100644 --- a/VigilCareClinicalAPI/Controllers/AlertsController.cs +++ b/VigilCareClinicalAPI/Controllers/AlertsController.cs @@ -98,7 +98,20 @@ public class AlertsController : ControllerBase } } - var result = await _alerts.ListGlobalAsync(parsedStatus, parsedSeverity, department, page, pageSize); + Department? parsedDepartment = null; + if (!string.IsNullOrEmpty(department)) + { + try + { + parsedDepartment = DepartmentExtensions.FromDbString(department); + } + catch (ArgumentOutOfRangeException) + { + return BadRequest(ApiResponse.Fail(400, "Invalid department filter.", "INVALID_DEPARTMENT")); + } + } + + var result = await _alerts.ListGlobalAsync(parsedStatus, parsedSeverity, parsedDepartment, page, pageSize); return Ok(ApiResponse.Ok(new { items = result.Items, diff --git a/VigilCareClinicalAPI/Data/Configurations/EncounterConfiguration.cs b/VigilCareClinicalAPI/Data/Configurations/EncounterConfiguration.cs index 23c3f20..a695880 100644 --- a/VigilCareClinicalAPI/Data/Configurations/EncounterConfiguration.cs +++ b/VigilCareClinicalAPI/Data/Configurations/EncounterConfiguration.cs @@ -11,6 +11,8 @@ public class EncounterConfiguration : IEntityTypeConfiguration "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')"); t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')"); + t.HasCheckConstraint("chk_encounters_department", + "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')"); }); builder.HasKey(e => e.Id); builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); @@ -30,7 +32,13 @@ public class EncounterConfiguration : IEntityTypeConfiguration v => EncounterStatusExtensions.FromDbString(v)) .HasDefaultValueSql("'SCHEDULED'") .HasSentinel((EncounterStatus)(-1)); - builder.Property(e => e.Department).HasColumnName("department").HasMaxLength(100).IsRequired(); + builder.Property(e => e.Department) + .HasColumnName("department") + .HasMaxLength(100) + .HasConversion( + v => v.ToDbString(), + v => DepartmentExtensions.FromDbString(v)) + .IsRequired(); builder.Property(e => e.AttendingPhysician).HasColumnName("attending_physician").HasMaxLength(200).IsRequired(); builder.Property(e => e.AdmittedAt).HasColumnName("admitted_at").HasDefaultValueSql("NOW()"); builder.Property(e => e.DischargedAt).HasColumnName("discharged_at"); diff --git a/VigilCareClinicalAPI/Data/Configurations/OrderConfiguration.cs b/VigilCareClinicalAPI/Data/Configurations/OrderConfiguration.cs index 8d67583..2b40f0d 100644 --- a/VigilCareClinicalAPI/Data/Configurations/OrderConfiguration.cs +++ b/VigilCareClinicalAPI/Data/Configurations/OrderConfiguration.cs @@ -9,6 +9,8 @@ public class OrderConfiguration : IEntityTypeConfiguration { 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')"); }); builder.HasKey(o => o.Id); builder.Property(o => o.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); @@ -22,7 +24,14 @@ public class OrderConfiguration : IEntityTypeConfiguration .IsRequired(); builder.Property(o => o.Description).HasColumnName("description").IsRequired(); builder.Property(o => o.OrderedBy).HasColumnName("ordered_by").HasMaxLength(200).IsRequired(); - builder.Property(o => o.Status).HasColumnName("status").HasMaxLength(20).HasDefaultValue("pending"); + builder.Property(o => o.Status) + .HasColumnName("status") + .HasMaxLength(20) + .HasConversion( + v => v.ToDbString(), + v => OrderStatusExtensions.FromDbString(v)) + .HasDefaultValueSql("'PENDING'") + .HasSentinel((OrderStatus)(-1)); builder.Property(o => o.OrderedAt).HasColumnName("ordered_at").HasDefaultValueSql("NOW()"); builder.Property(o => o.ResultedAt).HasColumnName("resulted_at"); @@ -33,6 +42,6 @@ public class OrderConfiguration : IEntityTypeConfiguration builder.HasIndex(o => new { o.EncounterId, o.OrderedAt }); builder.HasIndex(o => new { o.Status, o.OrderedAt }) - .HasFilter("status IN ('pending', 'in_progress')"); + .HasFilter("status IN ('PENDING', 'IN_PROGRESS')"); } } diff --git a/VigilCareClinicalAPI/Data/Configurations/ReconciliationAlertConfiguration.cs b/VigilCareClinicalAPI/Data/Configurations/ReconciliationAlertConfiguration.cs index 2bad19d..c35882d 100644 --- a/VigilCareClinicalAPI/Data/Configurations/ReconciliationAlertConfiguration.cs +++ b/VigilCareClinicalAPI/Data/Configurations/ReconciliationAlertConfiguration.cs @@ -25,6 +25,15 @@ public class ReconciliationAlertConfiguration : IEntityTypeConfiguration r.ResolvedAt).HasColumnName("resolved_at"); builder.Property(r => r.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + builder.HasOne(r => r.Encounter) + .WithMany() + .HasForeignKey(r => r.EncounterId) + .OnDelete(DeleteBehavior.SetNull); + builder.HasOne(r => r.Patient) + .WithMany() + .HasForeignKey(r => r.PatientId) + .OnDelete(DeleteBehavior.SetNull); + builder.HasIndex(r => new { r.CheckType, r.EncounterId }) .HasFilter("resolved_at IS NULL"); } diff --git a/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs b/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs index 3d0d689..75ebaf4 100644 --- a/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs +++ b/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs @@ -27,14 +27,14 @@ public static class DataSeeder var encounter1 = new Encounter { Id = Guid.NewGuid(), PatientId = patient1.Id, EncounterType = EncounterType.Inpatient, - Status = EncounterStatus.Active, Department = "ICU", + Status = EncounterStatus.Active, Department = Department.Icu, AttendingPhysician = "Dr. Osei", AdmittedAt = DateTimeOffset.UtcNow.AddHours(-6), CreatedAt = DateTimeOffset.UtcNow.AddHours(-6) }; var encounter2 = new Encounter { Id = Guid.NewGuid(), PatientId = patient2.Id, EncounterType = EncounterType.Inpatient, - Status = EncounterStatus.Active, Department = "General Medicine", + Status = EncounterStatus.Active, Department = Department.GeneralMedicine, AttendingPhysician = "Dr. Patel", AdmittedAt = DateTimeOffset.UtcNow.AddHours(-12), CreatedAt = DateTimeOffset.UtcNow.AddHours(-12) }; diff --git a/VigilCareClinicalAPI/Domains/Entities/Encounter.cs b/VigilCareClinicalAPI/Domains/Entities/Encounter.cs index 026bd2f..d0aa71c 100644 --- a/VigilCareClinicalAPI/Domains/Entities/Encounter.cs +++ b/VigilCareClinicalAPI/Domains/Entities/Encounter.cs @@ -4,7 +4,7 @@ public class Encounter public Guid PatientId { get; set; } public EncounterType EncounterType { get; set; } public EncounterStatus Status { get; set; } - public string Department { get; set; } = null!; + public Department Department { get; set; } public string AttendingPhysician { get; set; } = null!; public DateTimeOffset AdmittedAt { get; set; } public DateTimeOffset? DischargedAt { get; set; } diff --git a/VigilCareClinicalAPI/Domains/Entities/Order.cs b/VigilCareClinicalAPI/Domains/Entities/Order.cs index 86ed15a..7908897 100644 --- a/VigilCareClinicalAPI/Domains/Entities/Order.cs +++ b/VigilCareClinicalAPI/Domains/Entities/Order.cs @@ -5,7 +5,7 @@ public class Order public OrderType OrderType { get; set; } public string Description { get; set; } = null!; public string OrderedBy { get; set; } = null!; - public string Status { get; set; } = "pending"; + public OrderStatus Status { get; set; } = OrderStatus.Pending; public DateTimeOffset OrderedAt { get; set; } public DateTimeOffset? ResultedAt { get; set; } diff --git a/VigilCareClinicalAPI/Domains/Entities/ReconciliationAlert.cs b/VigilCareClinicalAPI/Domains/Entities/ReconciliationAlert.cs index dd1fcf3..83cc3ad 100644 --- a/VigilCareClinicalAPI/Domains/Entities/ReconciliationAlert.cs +++ b/VigilCareClinicalAPI/Domains/Entities/ReconciliationAlert.cs @@ -7,4 +7,7 @@ public class ReconciliationAlert public string Details { get; set; } = null!; public DateTimeOffset? ResolvedAt { get; set; } public DateTimeOffset CreatedAt { get; set; } + + public Encounter? Encounter { get; set; } + public Patient? Patient { get; set; } } \ No newline at end of file diff --git a/VigilCareClinicalAPI/Domains/Enums/Department.cs b/VigilCareClinicalAPI/Domains/Enums/Department.cs new file mode 100644 index 0000000..77f30b7 --- /dev/null +++ b/VigilCareClinicalAPI/Domains/Enums/Department.cs @@ -0,0 +1,34 @@ +public enum Department +{ + Icu, + GeneralMedicine, + Emergency, + Cardiology, + Surgery, + Pediatrics +} + +public static class DepartmentExtensions +{ + public static string ToDbString(this Department d) => d switch + { + Department.Icu => "ICU", + Department.GeneralMedicine => "GENERAL_MEDICINE", + Department.Emergency => "EMERGENCY", + Department.Cardiology => "CARDIOLOGY", + Department.Surgery => "SURGERY", + Department.Pediatrics => "PEDIATRICS", + _ => throw new ArgumentOutOfRangeException(nameof(d)) + }; + + public static Department FromDbString(string v) => v switch + { + "ICU" => Department.Icu, + "GENERAL_MEDICINE" => Department.GeneralMedicine, + "EMERGENCY" => Department.Emergency, + "CARDIOLOGY" => Department.Cardiology, + "SURGERY" => Department.Surgery, + "PEDIATRICS" => Department.Pediatrics, + _ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown department: '{v}'") + }; +} diff --git a/VigilCareClinicalAPI/Domains/Enums/OrderStatus.cs b/VigilCareClinicalAPI/Domains/Enums/OrderStatus.cs new file mode 100644 index 0000000..596eba4 --- /dev/null +++ b/VigilCareClinicalAPI/Domains/Enums/OrderStatus.cs @@ -0,0 +1,22 @@ +public enum OrderStatus { Pending, InProgress, Resulted, Cancelled } + +public static class OrderStatusExtensions +{ + public static string ToDbString(this OrderStatus s) => s switch + { + OrderStatus.Pending => "PENDING", + OrderStatus.InProgress => "IN_PROGRESS", + OrderStatus.Resulted => "RESULTED", + OrderStatus.Cancelled => "CANCELLED", + _ => throw new ArgumentOutOfRangeException(nameof(s)) + }; + + public static OrderStatus FromDbString(string v) => v switch + { + "PENDING" => OrderStatus.Pending, + "IN_PROGRESS" => OrderStatus.InProgress, + "RESULTED" => OrderStatus.Resulted, + "CANCELLED" => OrderStatus.Cancelled, + _ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown order status: '{v}'") + }; +} diff --git a/VigilCareClinicalAPI/Domains/Json/DepartmentJsonConverter.cs b/VigilCareClinicalAPI/Domains/Json/DepartmentJsonConverter.cs new file mode 100644 index 0000000..96eb264 --- /dev/null +++ b/VigilCareClinicalAPI/Domains/Json/DepartmentJsonConverter.cs @@ -0,0 +1,11 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +public sealed class DepartmentJsonConverter : JsonConverter +{ + public override Department Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => DepartmentExtensions.FromDbString(reader.GetString()!); + + public override void Write(Utf8JsonWriter writer, Department value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToDbString()); +} diff --git a/VigilCareClinicalAPI/Migrations/20260617053159_AddReconciliationAlerts.Designer.cs b/VigilCareClinicalAPI/Migrations/20260617053159_AddReconciliationAlerts.Designer.cs new file mode 100644 index 0000000..d2b960f --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260617053159_AddReconciliationAlerts.Designer.cs @@ -0,0 +1,589 @@ +// +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("20260617053159_AddReconciliationAlerts")] + partial class AddReconciliationAlerts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AlertThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("CriticalHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_high"); + + b.Property("CriticalLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_low"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("WarningHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_high"); + + b.Property("WarningLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_low"); + + b.HasKey("Id"); + + b.HasIndex("ObservationCode") + .IsUnique(); + + b.ToTable("alert_thresholds", (string)null); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acknowledged_at"); + + b.Property("AcknowledgedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("acknowledged_by"); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alert_type"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("ObservationId") + .HasColumnType("uuid") + .HasColumnName("observation_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'OPEN'"); + + b.Property("TriggeredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("triggered_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "TriggeredAt"); + + b.HasIndex("PatientId", "TriggeredAt"); + + b.HasIndex("Severity", "TriggeredAt") + .HasFilter("status = 'OPEN'"); + + b.ToTable("clinical_alerts", null, t => + { + t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL')"); + + t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')"); + + t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')"); + }); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("admitted_at") + .HasDefaultValueSql("NOW()"); + + b.Property("AttendingPhysician") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("attending_physician"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("discharged_at"); + + b.Property("EncounterType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("encounter_type"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'SCHEDULED'"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "AdmittedAt"); + + b.HasIndex("Status", "AdmittedAt") + .HasFilter("status = 'ACTIVE'"); + + b.ToTable("encounters", null, t => + { + t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')"); + + t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("Observation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IdempotencyKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("source") + .HasDefaultValueSql("'MANUAL'"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("IdempotencyKey") + .IsUnique() + .HasFilter("idempotency_key IS NOT NULL"); + + b.HasIndex("EncounterId", "ObservationCode", "RecordedAt"); + + b.ToTable("observations", null, t => + { + t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')"); + }); + }); + + modelBuilder.Entity("Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("OrderType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("order_type"); + + b.Property("OrderedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("ordered_at") + .HasDefaultValueSql("NOW()"); + + b.Property("OrderedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("ordered_by"); + + b.Property("ResultedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resulted_at"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("pending") + .HasColumnName("status"); + + 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')"); + }); + }); + + modelBuilder.Entity("OutboxEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("PartitionKey") + .HasMaxLength(36) + .HasColumnType("character varying(36)") + .HasColumnName("partition_key"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("Topic") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("topic"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasFilter("processed_at IS NULL"); + + b.ToTable("outbox_events", (string)null); + }); + + modelBuilder.Entity("Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("first_name"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("gender"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_name"); + + b.Property("Mrn") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mrn"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("active") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("Mrn") + .IsUnique(); + + b.ToTable("patients", (string)null); + }); + + modelBuilder.Entity("ReconciliationAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("check_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId"); + + b.HasIndex("PatientId"); + + b.HasIndex("CheckType", "EncounterId") + .HasFilter("resolved_at IS NULL"); + + b.ToTable("reconciliation_alerts", null, t => + { + t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')"); + }); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Alerts") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.HasOne("Patient", "Patient") + .WithMany("Encounters") + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("Observation", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Observations") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Order", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Orders") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("ReconciliationAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Encounter"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Navigation("Alerts"); + + b.Navigation("Observations"); + + b.Navigation("Orders"); + }); + + modelBuilder.Entity("Patient", b => + { + b.Navigation("Encounters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/20260617053159_AddReconciliationAlerts.cs b/VigilCareClinicalAPI/Migrations/20260617053159_AddReconciliationAlerts.cs new file mode 100644 index 0000000..e1926c3 --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260617053159_AddReconciliationAlerts.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + /// + public partial class AddReconciliationAlerts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_reconciliation_alerts_encounter_id", + table: "reconciliation_alerts", + column: "encounter_id"); + + migrationBuilder.CreateIndex( + name: "IX_reconciliation_alerts_patient_id", + table: "reconciliation_alerts", + column: "patient_id"); + + migrationBuilder.AddForeignKey( + name: "FK_reconciliation_alerts_encounters_encounter_id", + table: "reconciliation_alerts", + column: "encounter_id", + principalTable: "encounters", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_reconciliation_alerts_patients_patient_id", + table: "reconciliation_alerts", + column: "patient_id", + principalTable: "patients", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_reconciliation_alerts_encounters_encounter_id", + table: "reconciliation_alerts"); + + migrationBuilder.DropForeignKey( + name: "FK_reconciliation_alerts_patients_patient_id", + table: "reconciliation_alerts"); + + migrationBuilder.DropIndex( + name: "IX_reconciliation_alerts_encounter_id", + table: "reconciliation_alerts"); + + migrationBuilder.DropIndex( + name: "IX_reconciliation_alerts_patient_id", + table: "reconciliation_alerts"); + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/20260617054821_AddOrderStatusAndDepartmentEnums.Designer.cs b/VigilCareClinicalAPI/Migrations/20260617054821_AddOrderStatusAndDepartmentEnums.Designer.cs new file mode 100644 index 0000000..8b95e50 --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260617054821_AddOrderStatusAndDepartmentEnums.Designer.cs @@ -0,0 +1,593 @@ +// +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("20260617054821_AddOrderStatusAndDepartmentEnums")] + partial class AddOrderStatusAndDepartmentEnums + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AlertThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("CriticalHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_high"); + + b.Property("CriticalLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_low"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("WarningHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_high"); + + b.Property("WarningLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_low"); + + b.HasKey("Id"); + + b.HasIndex("ObservationCode") + .IsUnique(); + + b.ToTable("alert_thresholds", (string)null); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acknowledged_at"); + + b.Property("AcknowledgedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("acknowledged_by"); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alert_type"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("ObservationId") + .HasColumnType("uuid") + .HasColumnName("observation_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'OPEN'"); + + b.Property("TriggeredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("triggered_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "TriggeredAt"); + + b.HasIndex("PatientId", "TriggeredAt"); + + b.HasIndex("Severity", "TriggeredAt") + .HasFilter("status = 'OPEN'"); + + b.ToTable("clinical_alerts", null, t => + { + t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL')"); + + t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')"); + + t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')"); + }); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("admitted_at") + .HasDefaultValueSql("NOW()"); + + b.Property("AttendingPhysician") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("attending_physician"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("discharged_at"); + + b.Property("EncounterType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("encounter_type"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'SCHEDULED'"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "AdmittedAt"); + + b.HasIndex("Status", "AdmittedAt") + .HasFilter("status = 'ACTIVE'"); + + b.ToTable("encounters", null, t => + { + t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')"); + + t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')"); + + t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("Observation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IdempotencyKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("source") + .HasDefaultValueSql("'MANUAL'"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("IdempotencyKey") + .IsUnique() + .HasFilter("idempotency_key IS NOT NULL"); + + b.HasIndex("EncounterId", "ObservationCode", "RecordedAt"); + + b.ToTable("observations", null, t => + { + t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')"); + }); + }); + + modelBuilder.Entity("Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("OrderType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("order_type"); + + b.Property("OrderedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("ordered_at") + .HasDefaultValueSql("NOW()"); + + b.Property("OrderedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("ordered_by"); + + b.Property("ResultedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resulted_at"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'PENDING'"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "OrderedAt"); + + b.HasIndex("Status", "OrderedAt") + .HasFilter("status IN ('PENDING', 'IN_PROGRESS')"); + + b.ToTable("orders", null, t => + { + t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')"); + + t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("OutboxEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("PartitionKey") + .HasMaxLength(36) + .HasColumnType("character varying(36)") + .HasColumnName("partition_key"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("Topic") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("topic"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasFilter("processed_at IS NULL"); + + b.ToTable("outbox_events", (string)null); + }); + + modelBuilder.Entity("Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("first_name"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("gender"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_name"); + + b.Property("Mrn") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mrn"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("active") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("Mrn") + .IsUnique(); + + b.ToTable("patients", (string)null); + }); + + modelBuilder.Entity("ReconciliationAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("check_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId"); + + b.HasIndex("PatientId"); + + b.HasIndex("CheckType", "EncounterId") + .HasFilter("resolved_at IS NULL"); + + b.ToTable("reconciliation_alerts", null, t => + { + t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')"); + }); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Alerts") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.HasOne("Patient", "Patient") + .WithMany("Encounters") + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("Observation", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Observations") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Order", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Orders") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("ReconciliationAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Encounter"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Navigation("Alerts"); + + b.Navigation("Observations"); + + b.Navigation("Orders"); + }); + + modelBuilder.Entity("Patient", b => + { + b.Navigation("Encounters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/20260617054821_AddOrderStatusAndDepartmentEnums.cs b/VigilCareClinicalAPI/Migrations/20260617054821_AddOrderStatusAndDepartmentEnums.cs new file mode 100644 index 0000000..c4ba688 --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260617054821_AddOrderStatusAndDepartmentEnums.cs @@ -0,0 +1,102 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + /// + public partial class AddOrderStatusAndDepartmentEnums : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_orders_status_ordered_at", + table: "orders"); + + migrationBuilder.Sql(""" + UPDATE orders SET status = 'PENDING' WHERE status = 'pending'; + UPDATE orders SET status = 'IN_PROGRESS' WHERE status = 'in_progress'; + UPDATE orders SET status = 'RESULTED' WHERE status = 'resulted'; + UPDATE orders SET status = 'CANCELLED' WHERE status = 'cancelled'; + """); + + migrationBuilder.Sql(""" + UPDATE encounters SET department = 'GENERAL_MEDICINE' WHERE department = 'General Medicine'; + """); + + migrationBuilder.AlterColumn( + name: "status", + table: "orders", + type: "character varying(20)", + maxLength: 20, + nullable: false, + defaultValueSql: "'PENDING'", + oldClrType: typeof(string), + oldType: "character varying(20)", + oldMaxLength: 20, + oldDefaultValue: "pending"); + + migrationBuilder.CreateIndex( + name: "IX_orders_status_ordered_at", + table: "orders", + columns: new[] { "status", "ordered_at" }, + filter: "status IN ('PENDING', 'IN_PROGRESS')"); + + migrationBuilder.AddCheckConstraint( + name: "chk_orders_status", + table: "orders", + sql: "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')"); + + migrationBuilder.AddCheckConstraint( + name: "chk_encounters_department", + table: "encounters", + sql: "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_orders_status_ordered_at", + table: "orders"); + + migrationBuilder.DropCheckConstraint( + name: "chk_orders_status", + table: "orders"); + + migrationBuilder.DropCheckConstraint( + name: "chk_encounters_department", + table: "encounters"); + + migrationBuilder.Sql(""" + UPDATE orders SET status = 'pending' WHERE status = 'PENDING'; + UPDATE orders SET status = 'in_progress' WHERE status = 'IN_PROGRESS'; + UPDATE orders SET status = 'resulted' WHERE status = 'RESULTED'; + UPDATE orders SET status = 'cancelled' WHERE status = 'CANCELLED'; + """); + + migrationBuilder.Sql(""" + UPDATE encounters SET department = 'General Medicine' WHERE department = 'GENERAL_MEDICINE'; + """); + + migrationBuilder.AlterColumn( + name: "status", + table: "orders", + type: "character varying(20)", + maxLength: 20, + nullable: false, + defaultValue: "pending", + oldClrType: typeof(string), + oldType: "character varying(20)", + oldMaxLength: 20, + oldDefaultValueSql: "'PENDING'"); + + migrationBuilder.CreateIndex( + name: "IX_orders_status_ordered_at", + table: "orders", + columns: new[] { "status", "ordered_at" }, + filter: "status IN ('pending', 'in_progress')"); + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs index acf6840..3814372 100644 --- a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs +++ b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs @@ -223,6 +223,8 @@ namespace VigilCareClinicalAPI.Migrations 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')"); @@ -338,19 +340,21 @@ namespace VigilCareClinicalAPI.Migrations .ValueGeneratedOnAdd() .HasMaxLength(20) .HasColumnType("character varying(20)") - .HasDefaultValue("pending") - .HasColumnName("status"); + .HasColumnName("status") + .HasDefaultValueSql("'PENDING'"); b.HasKey("Id"); b.HasIndex("EncounterId", "OrderedAt"); b.HasIndex("Status", "OrderedAt") - .HasFilter("status IN ('pending', 'in_progress')"); + .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')"); }); }); @@ -493,6 +497,10 @@ namespace VigilCareClinicalAPI.Migrations b.HasKey("Id"); + b.HasIndex("EncounterId"); + + b.HasIndex("PatientId"); + b.HasIndex("CheckType", "EncounterId") .HasFilter("resolved_at IS NULL"); @@ -546,6 +554,23 @@ namespace VigilCareClinicalAPI.Migrations 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"); diff --git a/VigilCareClinicalAPI/Models/Records/Encounter/OpenEncounterRequest.cs b/VigilCareClinicalAPI/Models/Records/Encounter/OpenEncounterRequest.cs index 5d6dde1..d59d60c 100644 --- a/VigilCareClinicalAPI/Models/Records/Encounter/OpenEncounterRequest.cs +++ b/VigilCareClinicalAPI/Models/Records/Encounter/OpenEncounterRequest.cs @@ -1,4 +1,4 @@ public record OpenEncounterRequest( EncounterType EncounterType, - string Department, + Department Department, string AttendingPhysician); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Notifications/RabbitMqTopologyProvisioner.cs b/VigilCareClinicalAPI/Notifications/RabbitMqTopologyProvisioner.cs index daa4752..b57afaa 100644 --- a/VigilCareClinicalAPI/Notifications/RabbitMqTopologyProvisioner.cs +++ b/VigilCareClinicalAPI/Notifications/RabbitMqTopologyProvisioner.cs @@ -9,6 +9,7 @@ public sealed class RabbitMqTopologyProvisioner : IHostedService public const string PagingKey = "alerts.paging"; public const string EscalKey = "alerts.escalation"; public const string DischargeKey = "notifications.discharge"; + public const string ReconciliationKey = "notifications.reconciliation"; private readonly RabbitMqOptions _opts; private readonly IHostEnvironment _env; @@ -30,7 +31,7 @@ public sealed class RabbitMqTopologyProvisioner : IHostedService using var connection = factory.CreateConnection(); using var channel = connection.CreateModel(); - if (_env.IsEnvironment("Testing")) + if (_env.IsDevelopment() || _env.IsEnvironment("Testing")) { // Use a throwaway channel — a failed purge on a missing queue closes the // channel, which would break the declare calls below. @@ -38,8 +39,8 @@ public sealed class RabbitMqTopologyProvisioner : IHostedService try { - // Dev runs provision the DLQ with a 5-minute TTL; delete it so the - // test timeout (5 s) is applied when the queue is re-declared below. + // x-message-ttl is immutable once the queue exists. Integration tests + // use 5 s while local dev uses 5 min — delete so config drives the TTL. cleanup.QueueDelete("alerts.paging.dlq", ifUnused: false, ifEmpty: false); } catch (OperationInterruptedException ex) @@ -47,12 +48,15 @@ public sealed class RabbitMqTopologyProvisioner : IHostedService _logger.LogDebug(ex, "DLQ delete skipped — queue may not exist yet"); } - foreach (var queue in new[] { "alerts.paging.queue", "alerts.escalation.queue" }) + if (_env.IsEnvironment("Testing")) { - try { cleanup.QueuePurge(queue); } - catch (OperationInterruptedException ex) + foreach (var queue in new[] { "alerts.paging.queue", "alerts.escalation.queue" }) { - _logger.LogDebug(ex, "Queue purge skipped for {Queue}", queue); + try { cleanup.QueuePurge(queue); } + catch (OperationInterruptedException ex) + { + _logger.LogDebug(ex, "Queue purge skipped for {Queue}", queue); + } } } } @@ -119,6 +123,14 @@ public sealed class RabbitMqTopologyProvisioner : IHostedService arguments: null); channel.QueueBind("notifications.appointment.queue", Exchange, "notifications.appointment"); + channel.QueueDeclare( + queue: "notifications.reconciliation.queue", + durable: true, + exclusive: false, + autoDelete: false, + arguments: null); + channel.QueueBind("notifications.reconciliation.queue", Exchange, ReconciliationKey); + _logger.LogInformation( "RabbitMQ topology provisioned. Exchange={Exchange} PagingDlqTtlMs={Ttl}", Exchange, _opts.PagingAckTimeoutMs); diff --git a/VigilCareClinicalAPI/Program.cs b/VigilCareClinicalAPI/Program.cs index 9353a7e..b7b9a44 100644 --- a/VigilCareClinicalAPI/Program.cs +++ b/VigilCareClinicalAPI/Program.cs @@ -49,6 +49,9 @@ try builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.Configure( + builder.Configuration.GetSection(ReconciliationJobOptions.Section)); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -57,6 +60,11 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddHostedService(); builder.Services.AddHostedService(); @@ -68,6 +76,7 @@ try builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); builder.Services.AddControllers() .AddJsonOptions(opts => @@ -75,6 +84,7 @@ try opts.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; opts.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); opts.JsonSerializerOptions.Converters.Add(new ObservationSourceJsonConverter()); + opts.JsonSerializerOptions.Converters.Add(new DepartmentJsonConverter()); }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); diff --git a/VigilCareClinicalAPI/Services/AlertService.cs b/VigilCareClinicalAPI/Services/AlertService.cs index 89cf7ad..0c96441 100644 --- a/VigilCareClinicalAPI/Services/AlertService.cs +++ b/VigilCareClinicalAPI/Services/AlertService.cs @@ -28,7 +28,7 @@ public class AlertService : IAlertService } public async Task> ListGlobalAsync( - AlertStatus? status, AlertSeverity? severity, string? department, int page, int pageSize) + AlertStatus? status, AlertSeverity? severity, Department? department, int page, int pageSize) { var query = _db.ClinicalAlerts .AsNoTracking() @@ -41,8 +41,8 @@ public class AlertService : IAlertService if (severity.HasValue) query = query.Where(a => a.Severity == severity.Value); - if (!string.IsNullOrEmpty(department)) - query = query.Where(a => a.Encounter.Department == department); + if (department.HasValue) + query = query.Where(a => a.Encounter.Department == department.Value); var total = await query.CountAsync(); var alerts = await query diff --git a/VigilCareClinicalAPI/Services/EncounterService.cs b/VigilCareClinicalAPI/Services/EncounterService.cs index 9560d23..743a1d9 100644 --- a/VigilCareClinicalAPI/Services/EncounterService.cs +++ b/VigilCareClinicalAPI/Services/EncounterService.cs @@ -64,7 +64,7 @@ public class EncounterService : IEncounterService patientName = $"{encounter.Patient.FirstName} {encounter.Patient.LastName}", previousStatus = previousStatus.ToDbString(), newStatus = targetStatus.ToDbString(), - department = encounter.Department, + department = encounter.Department.ToDbString(), attendingPhysician = encounter.AttendingPhysician, admittedAt = encounter.AdmittedAt, changedAt = DateTimeOffset.UtcNow diff --git a/VigilCareClinicalAPI/Services/Interfaces/IAlertService.cs b/VigilCareClinicalAPI/Services/Interfaces/IAlertService.cs index 76ba91d..6b16d02 100644 --- a/VigilCareClinicalAPI/Services/Interfaces/IAlertService.cs +++ b/VigilCareClinicalAPI/Services/Interfaces/IAlertService.cs @@ -4,7 +4,7 @@ public interface IAlertService Guid encounterId, AlertStatus? status, int page, int pageSize); Task> ListGlobalAsync( - AlertStatus? status, AlertSeverity? severity, string? department, int page, int pageSize); + AlertStatus? status, AlertSeverity? severity, Department? department, int page, int pageSize); Task GetByIdAsync(Guid id); diff --git a/VigilCareClinicalAPI/Services/ObservationService.cs b/VigilCareClinicalAPI/Services/ObservationService.cs index c8a334d..c9aa0dc 100644 --- a/VigilCareClinicalAPI/Services/ObservationService.cs +++ b/VigilCareClinicalAPI/Services/ObservationService.cs @@ -113,7 +113,7 @@ public class ObservationService : IObservationService alertId = alert.Id, encounterId, patientId = encounter.PatientId, - department = encounter.Department, + department = encounter.Department.ToDbString(), alertType = alert.AlertType.ToDbString(), severity = alert.Severity.ToDbString(), details = alert.Details, diff --git a/VigilCareClinicalAPI/Services/PatientService.cs b/VigilCareClinicalAPI/Services/PatientService.cs index ecea453..3c56c6f 100644 --- a/VigilCareClinicalAPI/Services/PatientService.cs +++ b/VigilCareClinicalAPI/Services/PatientService.cs @@ -101,7 +101,7 @@ public class PatientService : IPatientService patientName = $"{patient.FirstName} {patient.LastName}", previousStatus = (string?)null, newStatus = encounter.Status.ToDbString(), - department = encounter.Department, + department = encounter.Department.ToDbString(), attendingPhysician = encounter.AttendingPhysician, admittedAt = encounter.AdmittedAt, changedAt = DateTimeOffset.UtcNow diff --git a/VigilCareClinicalAPI/appsettings.json b/VigilCareClinicalAPI/appsettings.json index c033486..fe4b3d1 100644 --- a/VigilCareClinicalAPI/appsettings.json +++ b/VigilCareClinicalAPI/appsettings.json @@ -68,5 +68,11 @@ "SecretKey": "minioadmin", "BucketName": "vigilcare", "UseSSL": false + }, + "ReconciliationJobs": { + "IntervalMinutes": 30, + "UnacknowledgedAlertThresholdMinutes": 30, + "PendingOrderThresholdHours": 4, + "NoObservationThresholdHours": 2 } } diff --git a/scripts/run-reconciliation-tests.sh b/scripts/run-reconciliation-tests.sh new file mode 100755 index 0000000..e309f4a --- /dev/null +++ b/scripts/run-reconciliation-tests.sh @@ -0,0 +1,496 @@ +#!/usr/bin/env bash +# Phase 7 verification — reconciliation jobs (see docs/plans/phase-7-plan.md). +# +# Prerequisites: +# docker compose up -d +# dotnet run --project VigilCareClinicalAPI +# +# For a practical runtime, set ReconciliationJobs.IntervalMinutes to 1 in appsettings.json +# and restart the API before running this script (default 30 min is too slow). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPOSE_FILE="${COMPOSE_FILE:-${SCRIPT_DIR}/../docker-compose.yml}" + +BASE_URL="${BASE_URL:-http://localhost:5270}" +RABBITMQ_MGMT_URL="${RABBITMQ_MGMT_URL:-http://localhost:15674}" +RABBITMQ_USER="${RABBITMQ_USER:-guest}" +RABBITMQ_PASS="${RABBITMQ_PASS:-guest}" + +PGHOST="${PGHOST:-localhost}" +PGPORT="${PGPORT:-5436}" +PGDATABASE="${PGDATABASE:-vigilcare}" +PGUSER="${PGUSER:-postgres}" +PGPASSWORD="${PGPASSWORD:-password}" + +# Scheduler waits: first tick ~15s after API start, then every IntervalMinutes. +RECONCILIATION_WAIT_SECS="${RECONCILIATION_WAIT_SECS:-120}" +RECONCILIATION_CYCLE_WAIT_SECS="${RECONCILIATION_CYCLE_WAIT_SECS:-70}" + +SCRIPT_RUN_ID="$(date -u +"%Y%m%d%H%M%S")" +RECORDED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +CHECK1="UNACKNOWLEDGED_CRITICAL_ALERT" +CHECK2="PENDING_ORDER_NO_RESULT" +CHECK3="ACTIVE_INPATIENT_NO_OBSERVATION" + +EXPECTED_QUEUES=( + "alerts.paging.queue" + "alerts.paging.dlq" + "alerts.escalation.queue" + "notifications.discharge.queue" + "notifications.appointment.queue" + "notifications.reconciliation.queue" +) + +TMP_FILES=() + +cleanup() { + local f + for f in "${TMP_FILES[@]}"; do + rm -f "${f}" "${f}.status" 2>/dev/null || true + done +} +trap cleanup EXIT + +if ! command -v curl >/dev/null 2>&1; then + echo "Missing dependency: curl" + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "Missing dependency: jq" + exit 1 +fi + +compose() { + docker compose -f "${COMPOSE_FILE}" "$@" +} + +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_body + tmp_body="$(mktemp)" + TMP_FILES+=("${tmp_body}") + local status + + if [[ -n "${body}" ]]; then + status="$(curl -sS -o "${tmp_body}" -w "%{http_code}" -X "${method}" "${url}" \ + -H "Content-Type: application/json" -d "${body}")" + else + status="$(curl -sS -o "${tmp_body}" -w "%{http_code}" -X "${method}" "${url}")" + fi + + echo "${status}" > "${tmp_body}.status" + echo "${tmp_body}" +} + +assert_status() { + local expected="$1" + local body_file="$2" + local status + status="$(cat "${body_file}.status")" + if [[ "${status}" != "${expected}" ]]; then + echo "Expected HTTP ${expected}, got ${status}" >&2 + echo "Response body:" >&2 + cat "${body_file}" >&2 + echo >&2 + exit 1 + fi +} + +rabbit_api() { + curl -sS -u "${RABBITMQ_USER}:${RABBITMQ_PASS}" "${RABBITMQ_MGMT_URL}/api/${1}" +} + +queue_field() { + local queue="$1" + local field="$2" + rabbit_api "queues/%2F/${queue}" | jq -r ".${field} // 0" +} + +wait_for_queue_increase() { + local queue="$1" + local baseline="$2" + local timeout_secs="${3:-15}" + local elapsed=0 + while (( elapsed < timeout_secs )); do + local depth + depth="$(queue_field "${queue}" "messages")" + if [[ "${depth}" -gt "${baseline}" ]]; then + echo "${depth}" + return 0 + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + echo "${depth:-${baseline}}" + return 1 +} + +reconciliation_count() { + local encounter_id="$1" + local check_type="$2" + psql_cmd "SELECT COUNT(*) FROM reconciliation_alerts + WHERE encounter_id = '${encounter_id}' + AND check_type = '${check_type}' + AND resolved_at IS NULL" +} + +wait_for_reconciliation_row() { + local encounter_id="$1" + local check_type="$2" + local timeout_secs="$3" + local elapsed=0 + while (( elapsed < timeout_secs )); do + local count + count="$(reconciliation_count "${encounter_id}" "${check_type}")" + if [[ "${count}" -ge 1 ]]; then + return 0 + fi + sleep 5 + elapsed=$((elapsed + 5)) + done + echo "No open reconciliation_alert (${check_type}) for encounter ${encounter_id} within ${timeout_secs}s" + return 1 +} + +wait_for_scheduler_cycle() { + echo " (waiting ${RECONCILIATION_CYCLE_WAIT_SECS}s for reconciliation scheduler cycle...)" + sleep "${RECONCILIATION_CYCLE_WAIT_SECS}" +} + +create_patient() { + local suffix="$1" + local payload + payload="$(jq -nc \ + --arg first "Recon${suffix}" \ + --arg last "Verify${SCRIPT_RUN_ID}" \ + '{firstName:$first,lastName:$last,dateOfBirth:"1970-06-01",gender:"Other"}')" + local resp + resp="$(request POST "${BASE_URL}/api/v1/patients" "${payload}")" + assert_status "201" "${resp}" + jq -r '.data.id' "${resp}" +} + +create_encounter() { + local patient_id="$1" + local encounter_type="$2" + local department="${3:-ICU}" + local payload + payload="$(jq -nc \ + --arg et "${encounter_type}" \ + --arg dept "${department}" \ + --arg physician "Dr. Recon ${SCRIPT_RUN_ID}" \ + '{encounterType:$et,department:$dept,attendingPhysician:$physician}')" + local resp + resp="$(request POST "${BASE_URL}/api/v1/patients/${patient_id}/encounters" "${payload}")" + assert_status "201" "${resp}" + jq -r '.data.id' "${resp}" +} + +ingest_critical_potassium() { + local encounter_id="$1" + local idempotency_key="$2" + local payload + payload="$(jq -nc \ + --arg recordedAt "${RECORDED_AT}" \ + --arg key "${idempotency_key}" \ + '{observations:[{observationCode:"POTASSIUM_MEQ_L",value:2.1,unit:"mEq/L",source:"LAB",recordedAt:$recordedAt,idempotencyKey:$key}]}')" + local resp + resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${payload}")" + assert_status "201" "${resp}" + if [[ "$(jq -r '.data.alertGenerated' "${resp}")" != "true" ]]; then + echo "Expected critical potassium ingest to generate an alert" + exit 1 + fi + jq -r '.data.alertId' "${resp}" +} + +ingest_heart_rate() { + local encounter_id="$1" + local recorded_at="$2" + local idempotency_key="$3" + local payload + payload="$(jq -nc \ + --arg recordedAt "${recorded_at}" \ + --arg key "${idempotency_key}" \ + '{observations:[{observationCode:"HEART_RATE",value:72,unit:"bpm",source:"DEVICE",recordedAt:$recordedAt,idempotencyKey:$key}]}')" + local resp + resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${payload}")" + assert_status "201" "${resp}" +} + +insert_stale_pending_order() { + local encounter_id="$1" + local description="$2" + psql_cmd "INSERT INTO orders (id, encounter_id, order_type, description, ordered_by, status, ordered_at, resulted_at) + VALUES (gen_random_uuid(), '${encounter_id}', 'LAB', '${description}', 'Dr. Osei', 'PENDING', + NOW() - INTERVAL '5 hours', NULL)" +} + +insert_resulted_order() { + local encounter_id="$1" + psql_cmd "INSERT INTO orders (id, encounter_id, order_type, description, ordered_by, status, ordered_at, resulted_at) + VALUES (gen_random_uuid(), '${encounter_id}', 'LAB', 'CBC', 'Dr. Osei', 'RESULTED', + NOW() - INTERVAL '5 hours', NOW() - INTERVAL '4 hours')" +} + +backdate_alert_triggered_at() { + local alert_id="$1" + local minutes_ago="$2" + psql_cmd "UPDATE clinical_alerts + SET triggered_at = NOW() - INTERVAL '${minutes_ago} minutes' + WHERE id = '${alert_id}'" +} + +TOTAL_STEPS=11 + +echo "Running Phase 7 reconciliation verification against ${BASE_URL}" +echo "Script run id: ${SCRIPT_RUN_ID}" +echo "Hint: set ReconciliationJobs.IntervalMinutes=1 and restart the API for ~${RECONCILIATION_CYCLE_WAIT_SECS}s waits per cycle." + +echo "" +echo "[0/${TOTAL_STEPS}] Preflight — API, Postgres, and RabbitMQ reachable" +preflight_status="$(curl -sS -o /dev/null -w "%{http_code}" "${BASE_URL}/api/v1/alert-thresholds" || true)" +if [[ "${preflight_status}" != "200" ]]; then + echo "API not reachable at ${BASE_URL} (HTTP ${preflight_status})." + echo "Start the API with: dotnet run --project VigilCareClinicalAPI" + exit 1 +fi + +if ! psql_cmd "SELECT 1" >/dev/null 2>&1; then + echo "Postgres not reachable on ${PGHOST}:${PGPORT}." + echo "Start the stack with: docker compose up -d" + exit 1 +fi + +rabbit_health="$(rabbit_api "health/checks/alarms" 2>/dev/null | jq -r '.status // empty' || true)" +if [[ -z "${rabbit_health}" ]]; then + echo "RabbitMQ management API not reachable at ${RABBITMQ_MGMT_URL}." + exit 1 +fi +potassium_threshold="$(psql_cmd "SELECT COUNT(*) FROM alert_thresholds WHERE observation_code = 'POTASSIUM_MEQ_L'")" +heart_rate_threshold="$(psql_cmd "SELECT COUNT(*) FROM alert_thresholds WHERE observation_code = 'HEART_RATE'")" +if [[ "${potassium_threshold}" == "0" || "${heart_rate_threshold}" == "0" ]]; then + echo "Missing alert thresholds (POTASSIUM_MEQ_L and/or HEART_RATE)." + echo "Start the API once against an empty database so DataSeeder runs, or register thresholds via the API." + exit 1 +fi +echo "OK: API, Postgres, RabbitMQ, and alert thresholds are ready" + +echo "" +echo "[1/${TOTAL_STEPS}] Verifying reconciliation_alerts migration" +table_exists="$(psql_cmd "SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'reconciliation_alerts' +)")" +if [[ "${table_exists}" != "t" ]]; then + echo "Table reconciliation_alerts not found. Run: dotnet ef database update --project VigilCareClinicalAPI" + exit 1 +fi + +columns="$(psql_cmd "SELECT string_agg(column_name, ',' ORDER BY ordinal_position) + FROM information_schema.columns + WHERE table_name = 'reconciliation_alerts'")" +for col in check_type encounter_id patient_id details resolved_at created_at; do + if [[ "${columns}" != *"${col}"* ]]; then + echo "reconciliation_alerts missing column: ${col}" + exit 1 + fi +done +echo "OK: reconciliation_alerts table and columns present" + +echo "" +echo "[2/${TOTAL_STEPS}] Verifying RabbitMQ topology includes notifications.reconciliation.queue" +exchange_name="$(rabbit_api "exchanges/%2F/clinical.notifications.exchange" | jq -r '.name // empty')" +if [[ "${exchange_name}" != "clinical.notifications.exchange" ]]; then + echo "Exchange clinical.notifications.exchange not found. Start the API so RabbitMqTopologyProvisioner runs." + exit 1 +fi + +for queue in "${EXPECTED_QUEUES[@]}"; do + queue_name="$(rabbit_api "queues/%2F/${queue}" | jq -r '.name // empty')" + if [[ "${queue_name}" != "${queue}" ]]; then + echo "Queue ${queue} not found (got '${queue_name}')." + exit 1 + fi +done +recon_queue_before="$(queue_field "notifications.reconciliation.queue" "messages")" +echo "OK: 6 queues provisioned (including notifications.reconciliation.queue); messages=${recon_queue_before}" + +echo "" +echo "[3/${TOTAL_STEPS}] Check 1 — stale critical alert creates reconciliation_alert" +patient_c1="$(create_patient "C1")" +encounter_c1="$(create_encounter "${patient_c1}" "Inpatient")" +alert_c1="$(ingest_critical_potassium "${encounter_c1}" "recon-c1-${SCRIPT_RUN_ID}")" +backdate_alert_triggered_at "${alert_c1}" 31 + +if ! wait_for_reconciliation_row "${encounter_c1}" "${CHECK1}" "${RECONCILIATION_WAIT_SECS}"; then + echo "Hint: set ReconciliationJobs.IntervalMinutes=1 in appsettings.json and restart the API." + exit 1 +fi + +details_c1="$(psql_cmd "SELECT details FROM reconciliation_alerts + WHERE encounter_id = '${encounter_c1}' AND check_type = '${CHECK1}' AND resolved_at IS NULL + ORDER BY created_at DESC LIMIT 1")" +if [[ "${details_c1}" != *"${alert_c1}"* ]]; then + echo "Check 1 details should reference alert id ${alert_c1}" + echo "Got: ${details_c1}" + exit 1 +fi +if ! recon_queue_after_c1="$(wait_for_queue_increase "notifications.reconciliation.queue" "${recon_queue_before}" 15)"; then + echo "Expected messages on notifications.reconciliation.queue to increase after Check 1" + echo "Before=${recon_queue_before}, after=${recon_queue_after_c1}" + exit 1 +fi +echo "OK: Check 1 reconciliation_alert created; queue depth ${recon_queue_before} -> ${recon_queue_after_c1}" + +echo "" +echo "[4/${TOTAL_STEPS}] Check 1 — second scheduler cycle does not duplicate" +count_before_dup="$(reconciliation_count "${encounter_c1}" "${CHECK1}")" +wait_for_scheduler_cycle +count_after_dup="$(reconciliation_count "${encounter_c1}" "${CHECK1}")" +if [[ "${count_after_dup}" != "${count_before_dup}" ]]; then + echo "Expected deduplication to keep count at ${count_before_dup}, got ${count_after_dup}" + exit 1 +fi +echo "OK: open reconciliation_alert count unchanged (${count_after_dup})" + +echo "" +echo "[5/${TOTAL_STEPS}] Check 1 — acknowledged critical alert is not flagged" +patient_c1a="$(create_patient "C1A")" +encounter_c1a="$(create_encounter "${patient_c1a}" "Inpatient")" +alert_c1a="$(ingest_critical_potassium "${encounter_c1a}" "recon-c1a-${SCRIPT_RUN_ID}")" + +ack_payload='{"clinicianId":"DR-RECON-SCRIPT","note":"Acknowledged during reconciliation verification."}' +resp="$(request POST "${BASE_URL}/api/v1/alerts/${alert_c1a}/acknowledge" "${ack_payload}")" +assert_status "200" "${resp}" + +backdate_alert_triggered_at "${alert_c1a}" 31 +wait_for_scheduler_cycle + +if [[ "$(reconciliation_count "${encounter_c1a}" "${CHECK1}")" != "0" ]]; then + echo "Acknowledged alert should not produce a Check 1 reconciliation_alert" + exit 1 +fi +echo "OK: acknowledged alert produced no Check 1 row" + +echo "" +echo "[6/${TOTAL_STEPS}] Check 2 — stale pending order creates reconciliation_alert" +patient_c2="$(create_patient "C2")" +encounter_c2="$(create_encounter "${patient_c2}" "Inpatient")" +insert_stale_pending_order "${encounter_c2}" "Comprehensive metabolic panel" + +if ! wait_for_reconciliation_row "${encounter_c2}" "${CHECK2}" "${RECONCILIATION_WAIT_SECS}"; then + exit 1 +fi +details_c2="$(psql_cmd "SELECT details FROM reconciliation_alerts + WHERE encounter_id = '${encounter_c2}' AND check_type = '${CHECK2}' AND resolved_at IS NULL + ORDER BY created_at DESC LIMIT 1")" +if [[ "${details_c2}" != *"Comprehensive metabolic panel"* ]]; then + echo "Check 2 details should mention the stale order" + echo "Got: ${details_c2}" + exit 1 +fi +echo "OK: Check 2 reconciliation_alert created for stale pending order" + +echo "" +echo "[7/${TOTAL_STEPS}] Check 2 — resulted order is not flagged" +patient_c2r="$(create_patient "C2R")" +encounter_c2r="$(create_encounter "${patient_c2r}" "Inpatient")" +insert_resulted_order "${encounter_c2r}" +wait_for_scheduler_cycle + +if [[ "$(reconciliation_count "${encounter_c2r}" "${CHECK2}")" != "0" ]]; then + echo "Resulted order should not produce a Check 2 reconciliation_alert" + exit 1 +fi +echo "OK: resulted order produced no Check 2 row" + +echo "" +echo "[8/${TOTAL_STEPS}] Check 3 — active inpatient with no observations is flagged" +patient_c3="$(create_patient "C3")" +encounter_c3="$(create_encounter "${patient_c3}" "Inpatient")" + +if ! wait_for_reconciliation_row "${encounter_c3}" "${CHECK3}" "${RECONCILIATION_WAIT_SECS}"; then + exit 1 +fi +details_c3="$(psql_cmd "SELECT details FROM reconciliation_alerts + WHERE encounter_id = '${encounter_c3}' AND check_type = '${CHECK3}' AND resolved_at IS NULL + ORDER BY created_at DESC LIMIT 1")" +if [[ "${details_c3}" != *"no observations ever recorded"* ]]; then + echo "Check 3 details should mention no observations ever recorded" + echo "Got: ${details_c3}" + exit 1 +fi +echo "OK: Check 3 reconciliation_alert created for inpatient without observations" + +echo "" +echo "[9/${TOTAL_STEPS}] Check 3 — stale observation (3 hours ago) is flagged" +patient_c3s="$(create_patient "C3S")" +encounter_c3s="$(create_encounter "${patient_c3s}" "Inpatient")" +stale_recorded_at="$(date -u -d '3 hours ago' +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-3H +"%Y-%m-%dT%H:%M:%SZ")" +ingest_heart_rate "${encounter_c3s}" "${stale_recorded_at}" "recon-c3s-${SCRIPT_RUN_ID}" + +if ! wait_for_reconciliation_row "${encounter_c3s}" "${CHECK3}" "${RECONCILIATION_WAIT_SECS}"; then + exit 1 +fi +echo "OK: Check 3 reconciliation_alert created for stale observation" + +echo "" +echo "[10/${TOTAL_STEPS}] Check 3 — recent observation (30 minutes ago) is not flagged" +patient_c3r="$(create_patient "C3R")" +encounter_c3r="$(create_encounter "${patient_c3r}" "Inpatient")" +recent_recorded_at="$(date -u -d '30 minutes ago' +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-30M +"%Y-%m-%dT%H:%M:%SZ")" +ingest_heart_rate "${encounter_c3r}" "${recent_recorded_at}" "recon-c3r-${SCRIPT_RUN_ID}" +wait_for_scheduler_cycle + +if [[ "$(reconciliation_count "${encounter_c3r}" "${CHECK3}")" != "0" ]]; then + echo "Recent observation should not produce a Check 3 reconciliation_alert" + exit 1 +fi +echo "OK: recent observation produced no Check 3 row" + +echo "" +echo "[11/${TOTAL_STEPS}] Check 3 — outpatient encounter without observations is ignored" +patient_c3o="$(create_patient "C3O")" +encounter_c3o="$(create_encounter "${patient_c3o}" "Outpatient" "GeneralMedicine")" +wait_for_scheduler_cycle + +if [[ "$(reconciliation_count "${encounter_c3o}" "${CHECK3}")" != "0" ]]; then + echo "Outpatient encounter should not produce a Check 3 reconciliation_alert" + exit 1 +fi + +# Plan verification query — candidate inpatients with no recent observations +candidate_count="$(psql_cmd " + SELECT COUNT(*) FROM ( + SELECT e.id + FROM encounters e + LEFT JOIN observations o ON o.encounter_id = e.id + WHERE e.status = 'ACTIVE' AND e.encounter_type = 'INPATIENT' + GROUP BY e.id + HAVING MAX(o.recorded_at) < NOW() - INTERVAL '2 hours' + OR MAX(o.recorded_at) IS NULL + ) t")" +echo "OK: outpatient not flagged; ${candidate_count} active inpatient(s) match Check 3 candidate query" + +echo "" +echo "All ${TOTAL_STEPS} Phase 7 reconciliation checks passed." +echo "" +echo "Prerequisites: docker compose up -d && dotnet run --project VigilCareClinicalAPI" +echo "Recommended: ReconciliationJobs.IntervalMinutes=1 in appsettings.json (restart API before running)." +echo "Optional: RECONCILIATION_WAIT_SECS=180 RECONCILIATION_CYCLE_WAIT_SECS=75"