From d8e142fffec73489499f4cd6af8bc5ce13b7c8b7 Mon Sep 17 00:00:00 2001 From: voltsrage Date: Tue, 23 Jun 2026 05:17:22 +0800 Subject: [PATCH] test: create test for concurrency --- .../BackgroundServiceTests.cs | 534 ++++++ .../ConcurrencyTests.cs | 340 ++++ .../Configurations/EncounterConfiguration.cs | 4 + ...EncounterActiveTypeUniqueIndex.Designer.cs | 1640 +++++++++++++++++ ...10831_AddEncounterActiveTypeUniqueIndex.cs | 29 + .../Migrations/AppDbContextModelSnapshot.cs | 5 + .../Services/PatientService.cs | 15 +- .../Services/SepsisBundleService.cs | 116 +- 8 files changed, 2625 insertions(+), 58 deletions(-) create mode 100644 VigilCareClinicalAPI.Tests/BackgroundServiceTests.cs create mode 100644 VigilCareClinicalAPI.Tests/ConcurrencyTests.cs create mode 100644 VigilCareClinicalAPI/Migrations/20260622210831_AddEncounterActiveTypeUniqueIndex.Designer.cs create mode 100644 VigilCareClinicalAPI/Migrations/20260622210831_AddEncounterActiveTypeUniqueIndex.cs diff --git a/VigilCareClinicalAPI.Tests/BackgroundServiceTests.cs b/VigilCareClinicalAPI.Tests/BackgroundServiceTests.cs new file mode 100644 index 0000000..9ab7875 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/BackgroundServiceTests.cs @@ -0,0 +1,534 @@ +using System.Text.Json; +using Confluent.Kafka; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using StackExchange.Redis; + +[Collection("Integration")] +public class BackgroundServiceTests : IAsyncLifetime +{ + private readonly ApiFixture _fixture; + + public BackgroundServiceTests(ApiFixture fixture) => _fixture = fixture; + + public async Task InitializeAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + + var redis = scope.ServiceProvider.GetRequiredService(); + var server = redis.GetServer(redis.GetEndPoints().First()); + await server.FlushDatabaseAsync(1); + } + + public Task DisposeAsync() => Task.CompletedTask; + + // ======================================================================= + // PoisonPillGuard — unit tests (no infrastructure needed) + // ======================================================================= + + [Fact] + public void PoisonPillGuard_PermanentError_SkipsImmediately() + { + var guard = new PoisonPillGuard("test-group", maxRetries: 5, NullLogger.Instance); + var result = BuildConsumeResult("test-topic", 0, 0, "bad payload"); + + var shouldSkip = guard.ShouldSkip(result, new JsonException("Invalid JSON")); + + shouldSkip.Should().BeTrue("permanent errors (JsonException) must be skipped immediately"); + } + + [Fact] + public void PoisonPillGuard_FormatException_SkipsImmediately() + { + var guard = new PoisonPillGuard("test-group", maxRetries: 5, NullLogger.Instance); + var result = BuildConsumeResult("test-topic", 0, 0, "not-a-guid"); + + var shouldSkip = guard.ShouldSkip(result, new FormatException("Input string was not in a correct format")); + + shouldSkip.Should().BeTrue("FormatException is a permanent error and must be skipped immediately"); + } + + [Fact] + public void PoisonPillGuard_WrappedPermanentError_SkipsImmediately() + { + var guard = new PoisonPillGuard("test-group", maxRetries: 5, NullLogger.Instance); + var result = BuildConsumeResult("test-topic", 0, 0, "bad"); + + var wrappedException = new InvalidOperationException( + "Processing failed", new JsonException("Invalid JSON")); + + var shouldSkip = guard.ShouldSkip(result, wrappedException); + + shouldSkip.Should().BeTrue( + "permanent errors wrapped in other exceptions must still be detected via root cause"); + } + + [Fact] + public void PoisonPillGuard_TransientError_RetriesUpToMax() + { + const int maxRetries = 3; + var guard = new PoisonPillGuard("test-group", maxRetries, NullLogger.Instance); + var result = BuildConsumeResult("test-topic", 0, 42, "payload"); + var transientError = new TimeoutException("Connection timed out"); + + for (var i = 1; i < maxRetries; i++) + { + var shouldSkip = guard.ShouldSkip(result, transientError); + shouldSkip.Should().BeFalse($"retry {i}/{maxRetries} should not skip"); + } + + var finalSkip = guard.ShouldSkip(result, transientError); + finalSkip.Should().BeTrue( + $"after {maxRetries} retries, the message must be skipped"); + } + + [Fact] + public void PoisonPillGuard_SuccessResetsRetryCount() + { + const int maxRetries = 3; + var guard = new PoisonPillGuard("test-group", maxRetries, NullLogger.Instance); + var result = BuildConsumeResult("test-topic", 0, 42, "payload"); + var transientError = new TimeoutException("Connection timed out"); + + guard.ShouldSkip(result, transientError); + guard.ShouldSkip(result, transientError); + + guard.OnSuccess(); + + guard.ShouldSkip(result, transientError).Should().BeFalse( + "after OnSuccess, retry count resets and first failure should not skip"); + } + + [Fact] + public void PoisonPillGuard_DifferentOffset_ResetsRetryCount() + { + const int maxRetries = 3; + var guard = new PoisonPillGuard("test-group", maxRetries, NullLogger.Instance); + var transientError = new TimeoutException("Connection timed out"); + + var result1 = BuildConsumeResult("test-topic", 0, 42, "payload-a"); + guard.ShouldSkip(result1, transientError); + guard.ShouldSkip(result1, transientError); + + var result2 = BuildConsumeResult("test-topic", 0, 43, "payload-b"); + guard.ShouldSkip(result2, transientError).Should().BeFalse( + "a new offset resets the retry counter"); + } + + // ======================================================================= + // Outbox relay — retry count and dead-letter behavior + // ======================================================================= + + [Fact] + public async Task OutboxEvent_UnprocessedEvents_ArePickedUpByRelay() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var encounterId = Guid.NewGuid(); + db.OutboxEvents.Add(new OutboxEvent + { + Id = Guid.NewGuid(), + Topic = "observation.recorded", + Payload = JsonSerializer.Serialize(new { encounterId, code = "HEART_RATE", value = 80 }), + PartitionKey = encounterId.ToString(), + CreatedAt = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + + var pendingBefore = await db.OutboxEvents + .CountAsync(e => e.ProcessedAt == null && e.FailedAt == null); + + pendingBefore.Should().BeGreaterThanOrEqualTo(1); + + var deadline = DateTimeOffset.UtcNow.AddSeconds(15); + while (DateTimeOffset.UtcNow < deadline) + { + await Task.Delay(500); + var remaining = await db.OutboxEvents + .AsNoTracking() + .CountAsync(e => e.ProcessedAt == null && e.FailedAt == null); + if (remaining == 0) break; + } + + var unprocessed = await db.OutboxEvents + .AsNoTracking() + .CountAsync(e => e.ProcessedAt == null && e.FailedAt == null); + + unprocessed.Should().Be(0, "the outbox relay should process all pending events"); + } + + [Fact] + public async Task OutboxEvent_FailedEvents_HaveRetryCountAndError() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var failedEvent = new OutboxEvent + { + Id = Guid.NewGuid(), + Topic = "nonexistent.topic.that.will.fail", + Payload = "{}", + PartitionKey = "test", + CreatedAt = DateTimeOffset.UtcNow, + RetryCount = 9, + LastError = "Simulated prior failure" + }; + db.OutboxEvents.Add(failedEvent); + await db.SaveChangesAsync(); + + failedEvent.RetryCount.Should().Be(9); + failedEvent.LastError.Should().NotBeNullOrEmpty(); + failedEvent.ProcessedAt.Should().BeNull(); + } + + [Fact] + public async Task OutboxEvent_FailedAtSet_EventIsNotReprocessed() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var deadLetteredEvent = new OutboxEvent + { + Id = Guid.NewGuid(), + Topic = "test.topic", + Payload = "{}", + PartitionKey = "test", + CreatedAt = DateTimeOffset.UtcNow, + RetryCount = 10, + LastError = "Exceeded max retries", + FailedAt = DateTimeOffset.UtcNow.AddMinutes(-1) + }; + db.OutboxEvents.Add(deadLetteredEvent); + await db.SaveChangesAsync(); + + await Task.Delay(3_000); + + var evt = await db.OutboxEvents + .AsNoTracking() + .SingleAsync(e => e.Id == deadLetteredEvent.Id); + + evt.ProcessedAt.Should().BeNull("dead-lettered events must not be reprocessed"); + evt.FailedAt.Should().NotBeNull(); + } + + // ======================================================================= + // SepsisBundleMonitor — concurrent scan safety + // ======================================================================= + + [Fact] + public async Task SepsisBundleMonitor_ConcurrentScans_NoDuplicateUpdates() + { + var (_, encounterId) = await SeedPatientEncounterAndBundleAsync(); + + using (var scope = _fixture.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var bundle = await db.SepsisBundles.SingleAsync(b => b.EncounterId == encounterId); + bundle.DeadlineAt = DateTimeOffset.UtcNow.AddHours(-1); + await db.SaveChangesAsync(); + } + + var tasks = Enumerable.Range(0, 5).Select(_ => + { + return Task.Run(async () => + { + var monitor = new SepsisBundleMonitorService( + _fixture.Services, + _fixture.Services.GetRequiredService(), + _fixture.Services.GetRequiredService>()); + await monitor.ScanOverdueBundlesAsync(CancellationToken.None); + }); + }); + + await Task.WhenAll(tasks); + + using var verifyScope = _fixture.Services.CreateScope(); + var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); + + var bundles = await verifyDb.SepsisBundles + .AsNoTracking() + .Where(b => b.EncounterId == encounterId) + .ToListAsync(); + + bundles.Should().HaveCount(1); + bundles[0].ComplianceStatus.Should().Be(SepsisBundleComplianceStatus.NonCompliant); + } + + [Fact] + public async Task SepsisBundleMonitor_CompletedBundle_NotMarkedOverdue() + { + var (_, encounterId) = await SeedPatientEncounterAndBundleAsync(); + + using (var scope = _fixture.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var bundle = await db.SepsisBundles.SingleAsync(b => b.EncounterId == encounterId); + bundle.ComplianceStatus = SepsisBundleComplianceStatus.Compliant; + bundle.CompletedAt = DateTimeOffset.UtcNow; + bundle.DeadlineAt = DateTimeOffset.UtcNow.AddHours(-1); + await db.SaveChangesAsync(); + } + + var monitor = new SepsisBundleMonitorService( + _fixture.Services, + _fixture.Services.GetRequiredService(), + _fixture.Services.GetRequiredService>()); + + await monitor.ScanOverdueBundlesAsync(CancellationToken.None); + + using var verifyScope = _fixture.Services.CreateScope(); + var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); + + var bundle2 = await verifyDb.SepsisBundles + .AsNoTracking() + .SingleAsync(b => b.EncounterId == encounterId); + + bundle2.ComplianceStatus.Should().Be(SepsisBundleComplianceStatus.Compliant, + "already-completed bundles must not be marked non-compliant by the monitor"); + } + + // ======================================================================= + // EscalationWorker — only escalates open alerts + // ======================================================================= + + [Fact] + public async Task EscalationWorker_AcknowledgedAlert_NotEscalated() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var (patientId, encounterId) = await SeedPatientAndEncounterAsync(); + + var alertId = Guid.NewGuid(); + db.ClinicalAlerts.Add(new ClinicalAlert + { + Id = alertId, + EncounterId = encounterId, + PatientId = patientId, + AlertType = AlertType.SofaSepsis, + Severity = AlertSeverity.Critical, + Details = "SOFA delta +2", + Status = AlertStatus.Acknowledged, + TriggeredAt = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + + var escalationService = new EscalationWorkerService( + _fixture.Services.GetRequiredService>(), + _fixture.Services.GetRequiredService(), + _fixture.Services.GetRequiredService(), + _fixture.Services.GetRequiredService>()); + + var escalated = await InvokeUpdateAlertStatusEscalatedAsync(escalationService, alertId); + + escalated.Should().BeFalse("acknowledged alerts must not be escalated"); + + var alert = await db.ClinicalAlerts.AsNoTracking().SingleAsync(a => a.Id == alertId); + alert.Status.Should().Be(AlertStatus.Acknowledged); + } + + [Fact] + public async Task EscalationWorker_ResolvedAlert_NotEscalated() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var (patientId, encounterId) = await SeedPatientAndEncounterAsync(); + + var alertId = Guid.NewGuid(); + db.ClinicalAlerts.Add(new ClinicalAlert + { + Id = alertId, + EncounterId = encounterId, + PatientId = patientId, + AlertType = AlertType.SofaSepsis, + Severity = AlertSeverity.Critical, + Details = "SOFA delta +2", + Status = AlertStatus.Resolved, + TriggeredAt = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + + var escalationService = new EscalationWorkerService( + _fixture.Services.GetRequiredService>(), + _fixture.Services.GetRequiredService(), + _fixture.Services.GetRequiredService(), + _fixture.Services.GetRequiredService>()); + + var escalated = await InvokeUpdateAlertStatusEscalatedAsync(escalationService, alertId); + + escalated.Should().BeFalse("resolved alerts must not be escalated"); + + var alert = await db.ClinicalAlerts.AsNoTracking().SingleAsync(a => a.Id == alertId); + alert.Status.Should().Be(AlertStatus.Resolved); + } + + [Fact] + public async Task EscalationWorker_OpenAlert_IsEscalated() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var (patientId, encounterId) = await SeedPatientAndEncounterAsync(); + + var alertId = Guid.NewGuid(); + db.ClinicalAlerts.Add(new ClinicalAlert + { + Id = alertId, + EncounterId = encounterId, + PatientId = patientId, + AlertType = AlertType.SofaSepsis, + Severity = AlertSeverity.Critical, + Details = "SOFA delta +2", + Status = AlertStatus.Open, + TriggeredAt = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + + var escalationService = new EscalationWorkerService( + _fixture.Services.GetRequiredService>(), + _fixture.Services.GetRequiredService(), + _fixture.Services.GetRequiredService(), + _fixture.Services.GetRequiredService>()); + + var escalated = await InvokeUpdateAlertStatusEscalatedAsync(escalationService, alertId); + + escalated.Should().BeTrue("open alerts must be escalated"); + + var alert = await db.ClinicalAlerts.AsNoTracking().SingleAsync(a => a.Id == alertId); + alert.Status.Should().Be(AlertStatus.Escalated); + } + + [Fact] + public async Task EscalationWorker_NonexistentAlert_ReturnsFalse() + { + var escalationService = new EscalationWorkerService( + _fixture.Services.GetRequiredService>(), + _fixture.Services.GetRequiredService(), + _fixture.Services.GetRequiredService(), + _fixture.Services.GetRequiredService>()); + + var escalated = await InvokeUpdateAlertStatusEscalatedAsync(escalationService, Guid.NewGuid()); + + escalated.Should().BeFalse("nonexistent alerts must not cause errors"); + } + + // ======================================================================= + // Cancellation — background services respect CancellationToken + // ======================================================================= + + [Fact] + public async Task SepsisBundleMonitor_CancellationRequested_StopsGracefully() + { + var monitor = new SepsisBundleMonitorService( + _fixture.Services, + _fixture.Services.GetRequiredService(), + _fixture.Services.GetRequiredService>()); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => monitor.ScanOverdueBundlesAsync(cts.Token); + + await act.Should().ThrowAsync(); + } + + // ======================================================================= + // Helpers + // ======================================================================= + + private static ConsumeResult BuildConsumeResult( + string topic, int partition, long offset, string value) + { + return new ConsumeResult + { + Topic = topic, + Partition = new Partition(partition), + Offset = new Offset(offset), + Message = new Message + { + Key = "test-key", + Value = value + } + }; + } + + private async Task<(Guid PatientId, Guid EncounterId)> SeedPatientAndEncounterAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var patient = new Patient + { + Id = Guid.NewGuid(), + Mrn = $"MRN-BG-{Guid.NewGuid():N}"[..20], + FirstName = "Background", + LastName = "Test", + DateOfBirth = new DateOnly(1970, 1, 1), + Gender = "M", + CreatedAt = DateTimeOffset.UtcNow + }; + var encounter = new Encounter + { + Id = Guid.NewGuid(), + PatientId = patient.Id, + EncounterType = EncounterType.Inpatient, + Status = EncounterStatus.Active, + Department = Department.Icu, + AttendingPhysician = "Dr. Background", + AdmittedAt = DateTimeOffset.UtcNow, + CreatedAt = DateTimeOffset.UtcNow + }; + + db.Patients.Add(patient); + db.Encounters.Add(encounter); + await db.SaveChangesAsync(); + + return (patient.Id, encounter.Id); + } + + private async Task<(Guid PatientId, Guid EncounterId)> SeedPatientEncounterAndBundleAsync() + { + var (patientId, encounterId) = await SeedPatientAndEncounterAsync(); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var handler = scope.ServiceProvider.GetRequiredService(); + + var alertId = Guid.NewGuid(); + db.ClinicalAlerts.Add(new ClinicalAlert + { + Id = alertId, + EncounterId = encounterId, + PatientId = patientId, + AlertType = AlertType.SofaSepsis, + Severity = AlertSeverity.Critical, + Details = "SOFA delta +2", + Status = AlertStatus.Open, + TriggeredAt = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + + await handler.OnSepsisAlertCreatedAsync( + encounterId, alertId, AlertType.SofaSepsis, CancellationToken.None); + + return (patientId, encounterId); + } + + private static async Task InvokeUpdateAlertStatusEscalatedAsync( + EscalationWorkerService service, Guid alertId) + { + var method = typeof(EscalationWorkerService).GetMethod( + "UpdateAlertStatusEscalatedAsync", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + var task = (Task)method!.Invoke(service, new object[] { alertId, CancellationToken.None })!; + return await task; + } +} diff --git a/VigilCareClinicalAPI.Tests/ConcurrencyTests.cs b/VigilCareClinicalAPI.Tests/ConcurrencyTests.cs new file mode 100644 index 0000000..4d961b7 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/ConcurrencyTests.cs @@ -0,0 +1,340 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +[Collection("Integration")] +public class ConcurrencyTests : IAsyncLifetime +{ + private readonly ApiFixture _fixture; + private readonly HttpClient _http; + + public ConcurrencyTests(ApiFixture fixture) + { + _fixture = fixture; + _http = fixture.CreateClient(); + _http.ClearAuth(); + _http.AsAdmin(); + } + + public async Task InitializeAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + + var redis = scope.ServiceProvider.GetRequiredService(); + var server = redis.GetServer(redis.GetEndPoints().First()); + await server.FlushDatabaseAsync(1); + } + + public Task DisposeAsync() => Task.CompletedTask; + + // ----------------------------------------------------------------------- + // Parallel patient registration — each must get a unique MRN + // ----------------------------------------------------------------------- + + [Fact] + public async Task ParallelPatientRegistration_AllGetUniqueMrns() + { + const int parallelCount = 10; + + var tasks = Enumerable.Range(0, parallelCount).Select(i => + { + var client = _fixture.CreateClient(); + client.ClearAuth(); + client.AsAdmin(); + return client.PostAsJsonAsync( + "/api/v1/patients", + new RegisterPatientRequest( + $"Parallel{i}", "Patient", + new DateOnly(1980, 1, 1).AddDays(i), "Female")); + }); + + var responses = await Task.WhenAll(tasks); + + var patientIds = new List(); + foreach (var resp in responses) + { + resp.StatusCode.Should().Be(HttpStatusCode.Created); + var body = await resp.Content.ReadFromJsonAsync(); + patientIds.Add(body!.RootElement.GetProperty("data").GetProperty("id").GetGuid()); + } + + patientIds.Should().OnlyHaveUniqueItems("every parallel registration must receive a distinct ID"); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var mrns = await db.Patients + .Where(p => patientIds.Contains(p.Id)) + .Select(p => p.Mrn) + .ToListAsync(); + + mrns.Should().HaveCount(parallelCount); + mrns.Should().OnlyHaveUniqueItems("every parallel registration must receive a distinct MRN"); + } + + // ----------------------------------------------------------------------- + // Parallel SOFA_SEPSIS alerts for same encounter — only one bundle + // ----------------------------------------------------------------------- + + [Fact] + public async Task ParallelSepsisAlerts_SingleBundleCreated() + { + var (patientId, encounterId) = await SeedPatientAndEncounterAsync(); + const int parallelCount = 5; + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var alertIds = new List(); + for (var i = 0; i < parallelCount; i++) + { + var alertId = Guid.NewGuid(); + db.ClinicalAlerts.Add(new ClinicalAlert + { + Id = alertId, + EncounterId = encounterId, + PatientId = patientId, + AlertType = AlertType.SofaSepsis, + Severity = AlertSeverity.Critical, + Details = $"SOFA delta +{i + 2}", + Status = AlertStatus.Open, + TriggeredAt = DateTimeOffset.UtcNow + }); + alertIds.Add(alertId); + } + await db.SaveChangesAsync(); + + var tasks = alertIds.Select(alertId => + { + return Task.Run(async () => + { + using var innerScope = _fixture.Services.CreateScope(); + var handler = innerScope.ServiceProvider.GetRequiredService(); + await handler.OnSepsisAlertCreatedAsync( + encounterId, alertId, AlertType.SofaSepsis, CancellationToken.None); + }); + }); + + await Task.WhenAll(tasks); + + var bundleCount = await db.SepsisBundles + .AsNoTracking() + .CountAsync(b => b.EncounterId == encounterId); + + bundleCount.Should().Be(1, "concurrent SOFA_SEPSIS alerts must produce exactly one bundle"); + + var orderCount = await db.Orders + .AsNoTracking() + .CountAsync(o => o.EncounterId == encounterId); + + orderCount.Should().Be(4, "only one bundle's orders (4) should exist"); + } + + // ----------------------------------------------------------------------- + // Parallel observation ingest with same idempotency key — single record + // ----------------------------------------------------------------------- + + [Fact] + public async Task ParallelObservationIngest_SameIdempotencyKey_SingleRecord() + { + var (_, encounterId) = await SeedPatientAndEncounterAsync(); + await SeedThresholdsAsync(); + + const int parallelCount = 5; + var idempotencyKey = $"device-parallel-{Guid.NewGuid():N}"; + + var tasks = Enumerable.Range(0, parallelCount).Select(_ => + { + var client = _fixture.CreateClient(); + client.ClearAuth(); + client.AsAdmin(); + return client.PostAsJsonAsync( + $"/api/v1/encounters/{encounterId}/observations", + new BatchIngestRequest(new List + { + new("HEART_RATE", 78, "bpm", ObservationSource.Device, + DateTimeOffset.UtcNow, idempotencyKey) + })); + }); + + var responses = await Task.WhenAll(tasks); + + foreach (var resp in responses) + resp.StatusCode.Should().Be(HttpStatusCode.Created); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var obsCount = await db.Observations + .CountAsync(o => o.EncounterId == encounterId); + + obsCount.Should().Be(1, "parallel ingests with the same idempotency key must produce exactly one record"); + } + + // ----------------------------------------------------------------------- + // Parallel observation ingest without idempotency key — all succeed + // ----------------------------------------------------------------------- + + [Fact] + public async Task ParallelObservationIngest_NoIdempotencyKey_AllCreateRecords() + { + var (_, encounterId) = await SeedPatientAndEncounterAsync(); + await SeedThresholdsAsync(); + + const int parallelCount = 5; + + var tasks = Enumerable.Range(0, parallelCount).Select(i => + { + var client = _fixture.CreateClient(); + client.ClearAuth(); + client.AsAdmin(); + return client.PostAsJsonAsync( + $"/api/v1/encounters/{encounterId}/observations", + new BatchIngestRequest(new List + { + new("HEART_RATE", 70 + i, "bpm", ObservationSource.Device, + DateTimeOffset.UtcNow, null) + })); + }); + + var responses = await Task.WhenAll(tasks); + + foreach (var resp in responses) + resp.StatusCode.Should().Be(HttpStatusCode.Created); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var obsCount = await db.Observations + .CountAsync(o => o.EncounterId == encounterId); + + obsCount.Should().Be(parallelCount, + "each observation without an idempotency key is distinct and must be stored"); + } + + // ----------------------------------------------------------------------- + // Parallel encounter open — duplicate active encounter rejected + // ----------------------------------------------------------------------- + + [Fact] + public async Task ParallelEncounterOpen_SameType_OnlyOneSucceeds() + { + var patientId = await CreatePatientViaApiAsync(); + const int parallelCount = 5; + + var tasks = Enumerable.Range(0, parallelCount).Select(_ => + { + var client = _fixture.CreateClient(); + client.ClearAuth(); + client.AsAdmin(); + return client.PostAsJsonAsync( + $"/api/v1/patients/{patientId}/encounters", + new OpenEncounterRequest(EncounterType.Inpatient, Department.Icu, "Dr. Race")); + }); + + var responses = await Task.WhenAll(tasks); + + var created = responses.Count(r => r.StatusCode == HttpStatusCode.Created); + var conflicts = responses.Count(r => r.StatusCode == HttpStatusCode.Conflict); + + created.Should().BeGreaterThanOrEqualTo(1, "at least one encounter must be created"); + (created + conflicts).Should().Be(parallelCount, + "all responses must be either Created or Conflict"); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var activeCount = await db.Encounters + .CountAsync(e => e.PatientId == patientId + && e.EncounterType == EncounterType.Inpatient + && e.Status == EncounterStatus.Active); + + activeCount.Should().Be(1, + "only one active encounter of the same type should exist per patient"); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private async Task<(Guid PatientId, Guid EncounterId)> SeedPatientAndEncounterAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var patient = new Patient + { + Id = Guid.NewGuid(), + Mrn = $"MRN-CONC-{Guid.NewGuid():N}"[..20], + FirstName = "Concurrent", + LastName = "Test", + DateOfBirth = new DateOnly(1975, 6, 15), + Gender = "M", + CreatedAt = DateTimeOffset.UtcNow + }; + var encounter = new Encounter + { + Id = Guid.NewGuid(), + PatientId = patient.Id, + EncounterType = EncounterType.Inpatient, + Status = EncounterStatus.Active, + Department = Department.Icu, + AttendingPhysician = "Dr. Concurrent", + AdmittedAt = DateTimeOffset.UtcNow, + CreatedAt = DateTimeOffset.UtcNow + }; + + db.Patients.Add(patient); + db.Encounters.Add(encounter); + await db.SaveChangesAsync(); + + return (patient.Id, encounter.Id); + } + + private async Task SeedThresholdsAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + if (!await db.AlertThresholds.AnyAsync(t => t.ObservationCode == "HEART_RATE")) + { + db.AlertThresholds.Add(new AlertThreshold + { + Id = Guid.NewGuid(), + ObservationCode = "HEART_RATE", + DisplayName = "Heart Rate", + Unit = "bpm", + CriticalLow = 30, + WarningLow = 50, + WarningHigh = 100, + CriticalHigh = 150, + CreatedAt = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + } + + var redis = scope.ServiceProvider.GetRequiredService(); + await redis.GetDatabase(1).StringSetAsync( + "threshold:HEART_RATE", + """{"ObservationCode":"HEART_RATE","CriticalLow":30,"WarningLow":50,"WarningHigh":100,"CriticalHigh":150}"""); + } + + private async Task CreatePatientViaApiAsync() + { + var resp = await _http.PostAsJsonAsync( + "/api/v1/patients", + new RegisterPatientRequest("Race", "Condition", + new DateOnly(1985, 3, 20), "Male")); + resp.EnsureSuccessStatusCode(); + + var body = await resp.Content.ReadFromJsonAsync(); + return body!.RootElement.GetProperty("data").GetProperty("id").GetGuid(); + } +} diff --git a/VigilCareClinicalAPI/Data/Configurations/EncounterConfiguration.cs b/VigilCareClinicalAPI/Data/Configurations/EncounterConfiguration.cs index 494ea2e..ee1753f 100644 --- a/VigilCareClinicalAPI/Data/Configurations/EncounterConfiguration.cs +++ b/VigilCareClinicalAPI/Data/Configurations/EncounterConfiguration.cs @@ -55,5 +55,9 @@ public class EncounterConfiguration : IEntityTypeConfiguration builder.HasIndex(e => new { e.PatientId, e.AdmittedAt }); builder.HasIndex(e => new { e.Status, e.AdmittedAt }) .HasFilter("status = 'ACTIVE'"); + builder.HasIndex(e => new { e.PatientId, e.EncounterType }) + .IsUnique() + .HasDatabaseName("ix_encounters_patient_active_type") + .HasFilter("status = 'ACTIVE'"); } } diff --git a/VigilCareClinicalAPI/Migrations/20260622210831_AddEncounterActiveTypeUniqueIndex.Designer.cs b/VigilCareClinicalAPI/Migrations/20260622210831_AddEncounterActiveTypeUniqueIndex.Designer.cs new file mode 100644 index 0000000..4c50e3c --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260622210831_AddEncounterActiveTypeUniqueIndex.Designer.cs @@ -0,0 +1,1640 @@ +// +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("20260622210831_AddEncounterActiveTypeUniqueIndex")] + partial class AddEncounterActiveTypeUniqueIndex + { + /// + 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("SuppressionWindowMinutes") + .HasColumnType("integer") + .HasColumnName("suppression_window_minutes"); + + 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("ClientAlertId") + .HasColumnType("uuid") + .HasColumnName("client_alert_id"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("ObservationCode") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + 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("SyncedFromGateway") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("synced_from_gateway"); + + b.Property("TriggeredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("triggered_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("ClientAlertId") + .IsUnique() + .HasFilter("client_alert_id IS NOT NULL"); + + b.HasIndex("EncounterId", "TriggeredAt"); + + b.HasIndex("PatientId", "TriggeredAt"); + + b.HasIndex("Severity", "TriggeredAt") + .HasFilter("status = 'OPEN'"); + + b.HasIndex("EncounterId", "AlertType", "ObservationCode") + .HasFilter("status IN ('OPEN', 'ESCALATED')"); + + 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', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')"); + + t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')"); + + t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')"); + }); + }); + + modelBuilder.Entity("ClinicalAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("action"); + + b.Property("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EntityId") + .HasColumnType("uuid") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("entity_type"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("ip_address"); + + b.Property("NewValueJson") + .HasColumnType("jsonb") + .HasColumnName("new_value_json"); + + b.Property("PreviousValueJson") + .HasColumnType("jsonb") + .HasColumnName("previous_value_json"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("UserDisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("user_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("UserId"); + + b.ToTable("clinical_audit_logs", (string)null); + }); + + modelBuilder.Entity("ClinicalSite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Active") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("active"); + + b.Property("Address") + .HasColumnType("text") + .HasColumnName("address"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("SiteCode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("site_code"); + + b.HasKey("Id"); + + b.HasIndex("SiteCode") + .IsUnique(); + + b.ToTable("clinical_sites", (string)null); + }); + + modelBuilder.Entity("ClinicalSyncBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchReference") + .HasColumnType("uuid") + .HasColumnName("batch_reference"); + + b.Property("GatewayId") + .HasColumnType("uuid") + .HasColumnName("gateway_id"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status") + .HasDefaultValueSql("'RECEIVED'"); + + b.Property("SubmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchReference") + .IsUnique(); + + b.HasIndex("SiteId"); + + b.HasIndex("GatewayId", "SubmittedAt"); + + b.ToTable("clinical_sync_batches", null, t => + { + t.HasCheckConstraint("chk_clinical_sync_batches_status", "status IN ('RECEIVED','PROCESSING','APPLIED','CONFLICT','REJECTED')"); + }); + }); + + modelBuilder.Entity("ClinicalSyncConflict", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("ClientRef") + .HasColumnType("uuid") + .HasColumnName("client_ref"); + + b.Property("ConflictReason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("conflict_reason"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ItemType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("item_type"); + + b.HasKey("Id"); + + b.HasIndex("BatchId"); + + b.ToTable("clinical_sync_conflicts", (string)null); + }); + + modelBuilder.Entity("ClinicalUser", 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("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("clinical_users", (string)null); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("AdmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("admitted_at") + .HasDefaultValueSql("NOW()"); + + b.Property("AttendingPhysician") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("attending_physician"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("DischargedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("discharged_at"); + + b.Property("EncounterType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("encounter_type"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("room_bed"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'SCHEDULED'"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "AdmittedAt"); + + b.HasIndex("PatientId", "EncounterType") + .IsUnique() + .HasDatabaseName("ix_encounters_patient_active_type") + .HasFilter("status = 'ACTIVE'"); + + 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("ExternalResourceIdentifier", 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("InternalId") + .HasColumnType("uuid") + .HasColumnName("internal_id"); + + b.Property("ResourceType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("resource_type"); + + b.Property("System") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("system"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("ResourceType", "InternalId"); + + b.HasIndex("ResourceType", "System", "Value") + .IsUnique(); + + b.ToTable("external_resource_identifiers", (string)null); + }); + + modelBuilder.Entity("GcsScore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("Classification") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("classification"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("EyeScore") + .HasColumnType("integer") + .HasColumnName("eye_score"); + + b.Property("MotorScore") + .HasColumnType("integer") + .HasColumnName("motor_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.Property("VerbalScore") + .HasColumnType("integer") + .HasColumnName("verbal_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.ToTable("gcs_scores", null, t => + { + t.HasCheckConstraint("chk_gcs_scores_classification", "classification IN ('MILD', 'MODERATE', 'SEVERE')"); + }); + }); + + modelBuilder.Entity("MedicationAdministration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdministeredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("administered_at"); + + b.Property("AdministeredBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("administered_by"); + + b.Property("Dose") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)") + .HasColumnName("dose"); + + b.Property("DoseUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("dose_unit"); + + b.Property("DrugName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("drug_name"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("Route") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("route"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "AdministeredAt"); + + b.HasIndex("EncounterId", "DrugName"); + + b.ToTable("medication_administrations", (string)null); + }); + + modelBuilder.Entity("News2Score", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("ConsciousnessScore") + .HasColumnType("integer") + .HasColumnName("consciousness_score"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("HasSingleParamThree") + .HasColumnType("boolean") + .HasColumnName("has_single_param_three"); + + b.Property("HeartRateScore") + .HasColumnType("integer") + .HasColumnName("heart_rate_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RespRateScore") + .HasColumnType("integer") + .HasColumnName("resp_rate_score"); + + b.Property("RiskLevel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("risk_level"); + + b.Property("Spo2Score") + .HasColumnType("integer") + .HasColumnName("spo2_score"); + + b.Property("SupplementalO2Score") + .HasColumnType("integer") + .HasColumnName("supplemental_o2_score"); + + b.Property("SystolicBpScore") + .HasColumnType("integer") + .HasColumnName("systolic_bp_score"); + + b.Property("TemperatureScore") + .HasColumnType("integer") + .HasColumnName("temperature_score"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.HasIndex("PatientId", "CalculatedAt"); + + b.ToTable("news2_scores", null, t => + { + t.HasCheckConstraint("chk_news2_scores_risk_level", "risk_level IN ('LOW', 'LOW_MEDIUM', 'MEDIUM', 'HIGH')"); + }); + }); + + modelBuilder.Entity("Observation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IdempotencyKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("source") + .HasDefaultValueSql("'MANUAL'"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("IdempotencyKey") + .IsUnique() + .HasFilter("idempotency_key IS NOT NULL"); + + b.HasIndex("EncounterId", "ObservationCode", "RecordedAt"); + + b.ToTable("observations", null, t => + { + t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')"); + }); + }); + + modelBuilder.Entity("Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("OrderType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("order_type"); + + b.Property("OrderedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("ordered_at") + .HasDefaultValueSql("NOW()"); + + b.Property("OrderedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("ordered_by"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("ResultedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resulted_at"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'PENDING'"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "OrderedAt"); + + b.HasIndex("Status", "OrderedAt") + .HasFilter("status IN ('PENDING', 'IN_PROGRESS')"); + + b.ToTable("orders", null, t => + { + t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')"); + + t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("OutboxEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("FailedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("failed_at"); + + b.Property("LastError") + .HasColumnType("text") + .HasColumnName("last_error"); + + 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("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("retry_count"); + + b.Property("Topic") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("topic"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasFilter("processed_at IS NULL AND failed_at IS NULL"); + + b.ToTable("outbox_events", (string)null); + }); + + modelBuilder.Entity("Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Allergies") + .HasColumnType("text") + .HasColumnName("allergies"); + + b.Property("BloodType") + .HasMaxLength(5) + .HasColumnType("character varying(5)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .IsRequired() + .HasColumnType("text") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("text") + .HasColumnName("emergency_contact_name"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(20) + .HasColumnType("text") + .HasColumnName("emergency_contact_phone"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("text") + .HasColumnName("first_name"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("gender"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("text") + .HasColumnName("last_name"); + + b.Property("Mrn") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mrn"); + + b.Property("NameSearchToken") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name_search_token"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("active") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("Mrn") + .IsUnique(); + + b.HasIndex("NameSearchToken"); + + b.ToTable("patients", (string)null); + }); + + modelBuilder.Entity("PhiAccessLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("access_type"); + + b.Property("AccessedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("accessed_at") + .HasDefaultValueSql("NOW()"); + + b.Property("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("correlation_id"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("ip_address"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResourcePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("resource_path"); + + b.Property("ResultCount") + .HasColumnType("integer") + .HasColumnName("result_count"); + + b.Property("SearchQueryHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("search_query_hash"); + + b.Property("UserDisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("user_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("AccessedAt"); + + b.HasIndex("PatientId"); + + b.HasIndex("UserId"); + + b.ToTable("phi_access_logs", (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("SepsisBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("ComplianceStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("compliance_status") + .HasDefaultValueSql("'IN_PROGRESS'"); + + b.Property("DeadlineAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deadline_at"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("RecognizedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recognized_at"); + + b.Property("TriggeringAlertId") + .HasColumnType("uuid") + .HasColumnName("triggering_alert_id"); + + b.Property("TriggeringAlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("triggering_alert_type"); + + b.HasKey("Id"); + + b.HasIndex("ComplianceStatus"); + + b.HasIndex("TriggeringAlertId"); + + b.HasIndex("EncounterId", "RecognizedAt"); + + b.ToTable("sepsis_bundles", null, t => + { + t.HasCheckConstraint("chk_sepsis_bundles_compliance_status", "compliance_status IN ('IN_PROGRESS', 'COMPLIANT', 'NON_COMPLIANT')"); + }); + }); + + modelBuilder.Entity("SepsisBundleElement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BundleId") + .HasColumnType("uuid") + .HasColumnName("bundle_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("ElementType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("element_type"); + + b.Property("OrderId") + .HasColumnType("uuid") + .HasColumnName("order_id"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'PENDING'"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("BundleId", "ElementType") + .IsUnique(); + + b.ToTable("sepsis_bundle_elements", null, t => + { + t.HasCheckConstraint("chk_sepsis_bundle_elements_element_type", "element_type IN ('BLOOD_CULTURES', 'SERUM_LACTATE', 'BROAD_SPECTRUM_ANTIBIOTICS', 'IV_FLUID_RESUSCITATION')"); + + t.HasCheckConstraint("chk_sepsis_bundle_elements_status", "status IN ('PENDING', 'COMPLETED')"); + }); + }); + + modelBuilder.Entity("SofaScore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("CardiovascularScore") + .HasColumnType("integer") + .HasColumnName("cardiovascular_score"); + + b.Property("CnsScore") + .HasColumnType("integer") + .HasColumnName("cns_score"); + + b.Property("CoagulationScore") + .HasColumnType("integer") + .HasColumnName("coagulation_score"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeltaFromBaseline") + .HasColumnType("integer") + .HasColumnName("delta_from_baseline"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IsBaseline") + .HasColumnType("boolean") + .HasColumnName("is_baseline"); + + b.Property("LiverScore") + .HasColumnType("integer") + .HasColumnName("liver_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RenalScore") + .HasColumnType("integer") + .HasColumnName("renal_score"); + + b.Property("RespiratoryScore") + .HasColumnType("integer") + .HasColumnName("respiratory_score"); + + b.Property("StalenessFlags") + .HasColumnType("jsonb") + .HasColumnName("staleness_flags"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId") + .HasDatabaseName("idx_sofa_scores_baseline") + .HasFilter("is_baseline = true"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.ToTable("sofa_scores", (string)null); + }); + + modelBuilder.Entity("WardGateway", 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("Department") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("GatewayCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("gateway_code"); + + b.Property("LastHeartbeatAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_heartbeat_at"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sync_at"); + + b.Property("ReportedBufferDepth") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("reported_buffer_depth"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status") + .HasDefaultValueSql("'OFFLINE'"); + + b.HasKey("Id"); + + b.HasIndex("Status") + .HasFilter("status != 'ONLINE'"); + + b.HasIndex("SiteId", "Department"); + + b.HasIndex("SiteId", "GatewayCode") + .IsUnique(); + + b.ToTable("ward_gateways", null, t => + { + t.HasCheckConstraint("chk_ward_gateways_status", "status IN ('ONLINE','DEGRADED','OFFLINE')"); + }); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Alerts") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("ClinicalSyncBatch", b => + { + b.HasOne("WardGateway", "Gateway") + .WithMany() + .HasForeignKey("GatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ClinicalSite", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Gateway"); + }); + + modelBuilder.Entity("ClinicalSyncConflict", b => + { + b.HasOne("ClinicalSyncBatch", "Batch") + .WithMany("Conflicts") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.HasOne("Patient", "Patient") + .WithMany("Encounters") + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("GcsScore", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("MedicationAdministration", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("News2Score", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Observation", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Observations") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Order", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Orders") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("ReconciliationAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Encounter"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ClinicalAlert", "TriggeringAlert") + .WithMany() + .HasForeignKey("TriggeringAlertId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + + b.Navigation("TriggeringAlert"); + }); + + modelBuilder.Entity("SepsisBundleElement", b => + { + b.HasOne("SepsisBundle", "Bundle") + .WithMany("Elements") + .HasForeignKey("BundleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Bundle"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("SofaScore", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("WardGateway", b => + { + b.HasOne("ClinicalSite", "Site") + .WithMany("Gateways") + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("ClinicalSite", b => + { + b.Navigation("Gateways"); + }); + + modelBuilder.Entity("ClinicalSyncBatch", b => + { + b.Navigation("Conflicts"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Navigation("Alerts"); + + b.Navigation("Observations"); + + b.Navigation("Orders"); + }); + + modelBuilder.Entity("Patient", b => + { + b.Navigation("Encounters"); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.Navigation("Elements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/20260622210831_AddEncounterActiveTypeUniqueIndex.cs b/VigilCareClinicalAPI/Migrations/20260622210831_AddEncounterActiveTypeUniqueIndex.cs new file mode 100644 index 0000000..ec396be --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260622210831_AddEncounterActiveTypeUniqueIndex.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + /// + public partial class AddEncounterActiveTypeUniqueIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "ix_encounters_patient_active_type", + table: "encounters", + columns: new[] { "patient_id", "encounter_type" }, + unique: true, + filter: "status = 'ACTIVE'"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_encounters_patient_active_type", + table: "encounters"); + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs index e995fe4..0098ec0 100644 --- a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs +++ b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs @@ -532,6 +532,11 @@ namespace VigilCareClinicalAPI.Migrations b.HasIndex("PatientId", "AdmittedAt"); + b.HasIndex("PatientId", "EncounterType") + .IsUnique() + .HasDatabaseName("ix_encounters_patient_active_type") + .HasFilter("status = 'ACTIVE'"); + b.HasIndex("Status", "AdmittedAt") .HasFilter("status = 'ACTIVE'"); diff --git a/VigilCareClinicalAPI/Services/PatientService.cs b/VigilCareClinicalAPI/Services/PatientService.cs index 71fb046..c1cc91e 100644 --- a/VigilCareClinicalAPI/Services/PatientService.cs +++ b/VigilCareClinicalAPI/Services/PatientService.cs @@ -232,7 +232,20 @@ public class PatientService : IPatientService CreatedAt = DateTimeOffset.UtcNow }); - await _db.SaveChangesAsync(); + try + { + await _db.SaveChangesAsync(); + } + catch (DbUpdateException ex) + when (ex.InnerException is Npgsql.PostgresException { SqlState: "23505" } pg + && pg.ConstraintName == "ix_encounters_patient_active_type") + { + _db.ChangeTracker.Clear(); + throw new ConflictException( + "Patient already has an active encounter of this type.", + "DUPLICATE_ACTIVE_ENCOUNTER"); + } + return encounter; } diff --git a/VigilCareClinicalAPI/Services/SepsisBundleService.cs b/VigilCareClinicalAPI/Services/SepsisBundleService.cs index 98169f7..f661e55 100644 --- a/VigilCareClinicalAPI/Services/SepsisBundleService.cs +++ b/VigilCareClinicalAPI/Services/SepsisBundleService.cs @@ -20,7 +20,7 @@ public class SepsisBundleService : ISepsisBundleService if (alertType != AlertType.SofaSepsis) throw new InvalidOperationException( $"Sepsis bundle can only be triggered by SOFA_SEPSIS, not {alertType.ToDbString()}."); - + var exists = await _db.SepsisBundles .AnyAsync(b => b.EncounterId == encounterId && b.ComplianceStatus == SepsisBundleComplianceStatus.InProgress, ct); @@ -28,73 +28,75 @@ public class SepsisBundleService : ISepsisBundleService if (exists) return null; - var recognizedAt = DateTimeOffset.UtcNow; - var bundle = new SepsisBundle - { - Id = Guid.NewGuid(), - EncounterId = encounterId, - TriggeringAlertId = triggeringAlertId, - TriggeringAlertType = alertType.ToDbString(), - RecognizedAt = recognizedAt, - DeadlineAt = recognizedAt.AddHours(1), - ComplianceStatus = SepsisBundleComplianceStatus.InProgress - }; - - var elementDefs = new (SepsisBundleElementType Type, OrderType OrderType, string Description)[] - { - (SepsisBundleElementType.BloodCultures, OrderType.Lab, "SEP-1: Blood cultures"), - (SepsisBundleElementType.SerumLactate, OrderType.Lab, "SEP-1: Serum lactate"), - (SepsisBundleElementType.BroadSpectrumAntibiotics, OrderType.Medication, "SEP-1: Broad-spectrum antibiotics"), - (SepsisBundleElementType.IvFluidResuscitation, OrderType.Procedure, "SEP-1: IV fluid bolus"), - }; - - foreach (var (elementType, orderType, description) in elementDefs) - { - var order = await _orderService.CreateAsync(encounterId, new CreateOrderRequest( - orderType, description, "sepsis-bundle-engine")); - - bundle.Elements.Add(new SepsisBundleElement - { - Id = Guid.NewGuid(), - ElementType = elementType, - Status = SepsisBundleElementStatus.Pending, - OrderId = order.Id - }); - } - - _db.SepsisBundles.Add(bundle); - - _db.OutboxEvents.Add(new OutboxEvent - { - Id = Guid.NewGuid(), - Topic = "sepsis.bundle.created", - Payload = JsonSerializer.Serialize(new - { - bundleId = bundle.Id, - encounterId, - triggeringAlertId, - triggeringAlertType = alertType.ToDbString(), - recognizedAt, - deadlineAt = bundle.DeadlineAt, - partitionKey = encounterId.ToString() - }), - PartitionKey = encounterId.ToString(), - CreatedAt = DateTimeOffset.UtcNow - }); - + await using var tx = await _db.Database.BeginTransactionAsync(ct); try { + var recognizedAt = DateTimeOffset.UtcNow; + var bundle = new SepsisBundle + { + Id = Guid.NewGuid(), + EncounterId = encounterId, + TriggeringAlertId = triggeringAlertId, + TriggeringAlertType = alertType.ToDbString(), + RecognizedAt = recognizedAt, + DeadlineAt = recognizedAt.AddHours(1), + ComplianceStatus = SepsisBundleComplianceStatus.InProgress + }; + + var elementDefs = new (SepsisBundleElementType Type, OrderType OrderType, string Description)[] + { + (SepsisBundleElementType.BloodCultures, OrderType.Lab, "SEP-1: Blood cultures"), + (SepsisBundleElementType.SerumLactate, OrderType.Lab, "SEP-1: Serum lactate"), + (SepsisBundleElementType.BroadSpectrumAntibiotics, OrderType.Medication, "SEP-1: Broad-spectrum antibiotics"), + (SepsisBundleElementType.IvFluidResuscitation, OrderType.Procedure, "SEP-1: IV fluid bolus"), + }; + + foreach (var (elementType, orderType, description) in elementDefs) + { + var order = await _orderService.CreateAsync(encounterId, new CreateOrderRequest( + orderType, description, "sepsis-bundle-engine")); + + bundle.Elements.Add(new SepsisBundleElement + { + Id = Guid.NewGuid(), + ElementType = elementType, + Status = SepsisBundleElementStatus.Pending, + OrderId = order.Id + }); + } + + _db.SepsisBundles.Add(bundle); + + _db.OutboxEvents.Add(new OutboxEvent + { + Id = Guid.NewGuid(), + Topic = "sepsis.bundle.created", + Payload = JsonSerializer.Serialize(new + { + bundleId = bundle.Id, + encounterId, + triggeringAlertId, + triggeringAlertType = alertType.ToDbString(), + recognizedAt, + deadlineAt = bundle.DeadlineAt, + partitionKey = encounterId.ToString() + }), + PartitionKey = encounterId.ToString(), + CreatedAt = DateTimeOffset.UtcNow + }); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + return bundle; } catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException { SqlState: "23505" } pg && pg.ConstraintName == "ix_sepsis_bundles_encounter_in_progress") { + await tx.RollbackAsync(ct); _db.ChangeTracker.Clear(); return null; } - - return bundle; } public async Task GetCurrentByEncounterAsync(Guid encounterId)