From de603df151efdc4cd30fbdea63748122e7653c59 Mon Sep 17 00:00:00 2001 From: voltsrage Date: Tue, 16 Jun 2026 21:05:06 +0800 Subject: [PATCH] feature: Observation Ingest, Synchronous Alert Detection, and Alert Lifecycle --- VigilCareClinical.sln | 6 + .../AlertLifecycleTests.cs | 94 +++++++ .../Fixtures/ApiFixture.cs | 43 +++ .../Fixtures/IntegrationTestCollection.cs | 2 + .../Helpers/DbResetHelper.cs | 16 ++ .../ObservationIngestTests.cs | 233 ++++++++++++++++ .../VigilCareClinicalAPI.Tests.csproj | 31 +++ VigilCareClinicalAPI/Common/CursorPage.cs | 1 + .../Controllers/AlertsController.cs | 156 +++++++++++ .../Controllers/ObservationsController.cs | 87 ++++++ .../Records/Alert/AcknowledgeAlertRequest.cs | 1 + .../Records/Observation/BatchIngestRequest.cs | 1 + .../Observation/IngestObservationRequest.cs | 8 + .../Records/Observation/IngestResult.cs | 8 + .../Records/Observation/ObservationCursor.cs | 25 ++ .../Observation/ThresholdCacheEntry.cs | 6 + VigilCareClinicalAPI/Program.cs | 24 +- VigilCareClinicalAPI/Services/AlertService.cs | 123 +++++++++ .../Services/Interfaces/IAlertService.cs | 14 + .../Interfaces/IObservationQueryService.cs | 10 + .../Interfaces/IObservationService.cs | 4 + .../Services/ObservationQueryService.cs | 59 ++++ .../Services/ObservationService.cs | 215 +++++++++++++++ .../Services/PlausibilityValidator.cs | 35 +++ VigilCareClinicalAPI/appsettings.Testing.json | 14 + scripts/run-api-redis-tests.sh | 260 ++++++++++++++++++ 26 files changed, 1471 insertions(+), 5 deletions(-) create mode 100644 VigilCareClinicalAPI.Tests/AlertLifecycleTests.cs create mode 100644 VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs create mode 100644 VigilCareClinicalAPI.Tests/Fixtures/IntegrationTestCollection.cs create mode 100644 VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs create mode 100644 VigilCareClinicalAPI.Tests/ObservationIngestTests.cs create mode 100644 VigilCareClinicalAPI.Tests/VigilCareClinicalAPI.Tests.csproj create mode 100644 VigilCareClinicalAPI/Common/CursorPage.cs create mode 100644 VigilCareClinicalAPI/Controllers/AlertsController.cs create mode 100644 VigilCareClinicalAPI/Controllers/ObservationsController.cs create mode 100644 VigilCareClinicalAPI/Models/Records/Alert/AcknowledgeAlertRequest.cs create mode 100644 VigilCareClinicalAPI/Models/Records/Observation/BatchIngestRequest.cs create mode 100644 VigilCareClinicalAPI/Models/Records/Observation/IngestObservationRequest.cs create mode 100644 VigilCareClinicalAPI/Models/Records/Observation/IngestResult.cs create mode 100644 VigilCareClinicalAPI/Models/Records/Observation/ObservationCursor.cs create mode 100644 VigilCareClinicalAPI/Models/Records/Observation/ThresholdCacheEntry.cs create mode 100644 VigilCareClinicalAPI/Services/AlertService.cs create mode 100644 VigilCareClinicalAPI/Services/Interfaces/IAlertService.cs create mode 100644 VigilCareClinicalAPI/Services/Interfaces/IObservationQueryService.cs create mode 100644 VigilCareClinicalAPI/Services/Interfaces/IObservationService.cs create mode 100644 VigilCareClinicalAPI/Services/ObservationQueryService.cs create mode 100644 VigilCareClinicalAPI/Services/ObservationService.cs create mode 100644 VigilCareClinicalAPI/Services/PlausibilityValidator.cs create mode 100644 VigilCareClinicalAPI/appsettings.Testing.json create mode 100755 scripts/run-api-redis-tests.sh diff --git a/VigilCareClinical.sln b/VigilCareClinical.sln index 6428853..6dd4281 100644 --- a/VigilCareClinical.sln +++ b/VigilCareClinical.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCareClinicalAPI", "VigilCareClinicalAPI\VigilCareClinicalAPI.csproj", "{245ED672-EF15-4854-9C06-AB369139F7BE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCareClinicalAPI.Tests", "VigilCareClinicalAPI.Tests\VigilCareClinicalAPI.Tests.csproj", "{EECD0F7B-CF7E-4F42-826E-BE0E67AAC0A9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -18,5 +20,9 @@ Global {245ED672-EF15-4854-9C06-AB369139F7BE}.Debug|Any CPU.Build.0 = Debug|Any CPU {245ED672-EF15-4854-9C06-AB369139F7BE}.Release|Any CPU.ActiveCfg = Release|Any CPU {245ED672-EF15-4854-9C06-AB369139F7BE}.Release|Any CPU.Build.0 = Release|Any CPU + {EECD0F7B-CF7E-4F42-826E-BE0E67AAC0A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EECD0F7B-CF7E-4F42-826E-BE0E67AAC0A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EECD0F7B-CF7E-4F42-826E-BE0E67AAC0A9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EECD0F7B-CF7E-4F42-826E-BE0E67AAC0A9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/VigilCareClinicalAPI.Tests/AlertLifecycleTests.cs b/VigilCareClinicalAPI.Tests/AlertLifecycleTests.cs new file mode 100644 index 0000000..0209a7c --- /dev/null +++ b/VigilCareClinicalAPI.Tests/AlertLifecycleTests.cs @@ -0,0 +1,94 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +[Collection("Integration")] +public class AlertLifecycleTests : IAsyncLifetime +{ + private readonly ApiFixture _fixture; + private readonly HttpClient _client; + private Guid _alertId; + + public AlertLifecycleTests(ApiFixture fixture) + { + _fixture = fixture; + _client = fixture.CreateClient(); + } + + public async Task InitializeAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + + var patient = new Patient + { + Id = Guid.NewGuid(), Mrn = "MRN-T002", FirstName = "Alert", LastName = "Test", + DateOfBirth = new DateOnly(1980, 6, 15), Gender = "M", CreatedAt = DateTimeOffset.UtcNow + }; + var encounter = new Encounter + { + Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient, + Status = EncounterStatus.Active, Department = "General Medicine", + AttendingPhysician = "Dr. Test", AdmittedAt = DateTimeOffset.UtcNow, + CreatedAt = DateTimeOffset.UtcNow + }; + var alert = new ClinicalAlert + { + Id = Guid.NewGuid(), EncounterId = encounter.Id, PatientId = patient.Id, + AlertType = AlertType.CriticalPotassiumMeqL, Severity = AlertSeverity.Critical, + Details = "Potassium 2.1 mEq/L is below critical low.", Status = AlertStatus.Open, + TriggeredAt = DateTimeOffset.UtcNow + }; + db.Patients.Add(patient); + db.Encounters.Add(encounter); + db.ClinicalAlerts.Add(alert); + await db.SaveChangesAsync(); + _alertId = alert.Id; + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task AcknowledgeAlert_TransitionsToAcknowledged() + { + var resp = await _client.PostAsJsonAsync( + $"/api/v1/alerts/{_alertId}/acknowledge", + new { clinicianId = "DR-OSEI", note = "Reviewing now, ordering repeat labs." }); + + resp.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await resp.Content.ReadFromJsonAsync(); + body!.RootElement.GetProperty("data").GetProperty("status").GetString() + .Should().Be("Acknowledged"); + body.RootElement.GetProperty("data").GetProperty("acknowledgedBy").GetString() + .Should().Be("DR-OSEI"); + } + + [Fact] + public async Task ResolveWithoutAcknowledge_Returns409() + { + var resp = await _client.PostAsync($"/api/v1/alerts/{_alertId}/resolve", null); + resp.StatusCode.Should().Be(HttpStatusCode.Conflict); + var body = await resp.Content.ReadFromJsonAsync(); + body!.RootElement.GetProperty("error").GetProperty("code").GetString() + .Should().Be("ALERT_NOT_ACKNOWLEDGED"); + } + + [Fact] + public async Task AcknowledgeThenResolve_FullLifecycle() + { + await _client.PostAsJsonAsync( + $"/api/v1/alerts/{_alertId}/acknowledge", + new { clinicianId = "DR-PATEL", note = "Treated." }); + + var resolveResp = await _client.PostAsync( + $"/api/v1/alerts/{_alertId}/resolve", null); + + resolveResp.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await resolveResp.Content.ReadFromJsonAsync(); + body!.RootElement.GetProperty("data").GetProperty("status").GetString() + .Should().Be("Resolved"); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs b/VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs new file mode 100644 index 0000000..a0f2440 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; + +public class ApiFixture : WebApplicationFactory, IAsyncLifetime +{ + // Override configuration to point at a test database — never run tests against + // the development database; a botched rollback could corrupt seed data. + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = + "Host=localhost;Port=5436;Database=vigilcare_test;Username=postgres;Password=password", + ["Redis:ConnectionString"] = "localhost:6382,defaultDatabase=1,allowAdmin=true" + }); + }); + } + + public async Task InitializeAsync() + { + // Apply migrations against the test database on first run + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + + // Flush the test Redis database (db=1) to avoid cross-test cache pollution + var redis = scope.ServiceProvider.GetRequiredService(); + var server = redis.GetServer(redis.GetEndPoints().First()); + await server.FlushDatabaseAsync(1); + } + + public new async Task DisposeAsync() + { + await base.DisposeAsync(); + } +} diff --git a/VigilCareClinicalAPI.Tests/Fixtures/IntegrationTestCollection.cs b/VigilCareClinicalAPI.Tests/Fixtures/IntegrationTestCollection.cs new file mode 100644 index 0000000..c48812f --- /dev/null +++ b/VigilCareClinicalAPI.Tests/Fixtures/IntegrationTestCollection.cs @@ -0,0 +1,2 @@ +[CollectionDefinition("Integration")] +public class IntegrationTestCollection : ICollectionFixture; diff --git a/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs b/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs new file mode 100644 index 0000000..84937a8 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; + +public static class DbResetHelper +{ + // Truncate all data between tests — faster than dropping and re-creating + // the database, and preserves the schema (migrations do not re-run). + public static async Task ResetAsync(AppDbContext db) + { + await db.Database.ExecuteSqlRawAsync(@" + TRUNCATE TABLE reconciliation_alerts, outbox_events, orders, + clinical_alerts, observations, encounters, + alert_thresholds, patients + RESTART IDENTITY CASCADE; + "); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI.Tests/ObservationIngestTests.cs b/VigilCareClinicalAPI.Tests/ObservationIngestTests.cs new file mode 100644 index 0000000..eeae802 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/ObservationIngestTests.cs @@ -0,0 +1,233 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; + +[Collection("Integration")] +public class ObservationIngestTests : IAsyncLifetime +{ + private readonly ApiFixture _fixture; + private readonly HttpClient _client; + private Guid _patientId; + private Guid _encounterId; + + public ObservationIngestTests(ApiFixture fixture) + { + _fixture = fixture; + _client = fixture.CreateClient(); + } + + public async Task InitializeAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + + // Seed one patient, one active encounter, and the four thresholds + var patient = new Patient + { + Id = Guid.NewGuid(), Mrn = "MRN-T001", FirstName = "Test", LastName = "Patient", + DateOfBirth = new DateOnly(1970, 1, 1), Gender = "F", CreatedAt = DateTimeOffset.UtcNow + }; + var encounter = new Encounter + { + Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient, + Status = EncounterStatus.Active, Department = "ICU", + AttendingPhysician = "Dr. Test", AdmittedAt = DateTimeOffset.UtcNow, + CreatedAt = DateTimeOffset.UtcNow + }; + db.Patients.Add(patient); + db.Encounters.Add(encounter); + db.AlertThresholds.AddRange( + new AlertThreshold { Id = Guid.NewGuid(), ObservationCode = "HEART_RATE", + DisplayName = "Heart Rate", Unit = "bpm", + CriticalLow = 30, WarningLow = 50, WarningHigh = 100, CriticalHigh = 150, + CreatedAt = DateTimeOffset.UtcNow }, + new AlertThreshold { Id = Guid.NewGuid(), ObservationCode = "POTASSIUM_MEQ_L", + DisplayName = "Serum Potassium", Unit = "mEq/L", + CriticalLow = 2.5m, WarningLow = 3.5m, WarningHigh = 5.0m, CriticalHigh = 6.5m, + CreatedAt = DateTimeOffset.UtcNow } + ); + await db.SaveChangesAsync(); + + // Pre-load thresholds into Redis (replicates what ThresholdCacheLoader does at startup) + var redis = scope.ServiceProvider.GetRequiredService(); + var cache = redis.GetDatabase(1); + await cache.StringSetAsync("threshold:HEART_RATE", + """{"ObservationCode":"HEART_RATE","CriticalLow":30,"WarningLow":50,"WarningHigh":100,"CriticalHigh":150}"""); + await cache.StringSetAsync("threshold:POTASSIUM_MEQ_L", + """{"ObservationCode":"POTASSIUM_MEQ_L","CriticalLow":2.5,"WarningLow":3.5,"WarningHigh":5.0,"CriticalHigh":6.5}"""); + + _patientId = patient.Id; + _encounterId = encounter.Id; + } + + public Task DisposeAsync() => Task.CompletedTask; + + // Test 1: normal observation — no alert created + [Fact] + public async Task NormalObservation_NoAlert_Created() + { + var resp = await _client.PostAsJsonAsync( + $"/api/v1/encounters/{_encounterId}/observations", + new BatchIngestRequest(new List + { + new("HEART_RATE", 78, "bpm", ObservationSource.Device, DateTimeOffset.UtcNow, null) + })); + + resp.StatusCode.Should().Be(HttpStatusCode.Created); + var body = await resp.Content.ReadFromJsonAsync(); + body!.RootElement.GetProperty("data").GetProperty("alertGenerated").GetBoolean() + .Should().BeFalse(); + body.RootElement.GetProperty("data").GetProperty("duplicate").GetBoolean() + .Should().BeFalse(); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var alertCount = await db.ClinicalAlerts.CountAsync(); + alertCount.Should().Be(0, "a normal value must not create an alert"); + + // Outbox event should still be written for the Kafka consumer (warning detection) + var outboxCount = await db.OutboxEvents.CountAsync(); + outboxCount.Should().Be(1, "observation.recorded outbox event must be written for every observation"); + var outboxTopic = await db.OutboxEvents.Select(e => e.Topic).FirstAsync(); + outboxTopic.Should().Be("observation.recorded"); + } + + // Test 2: critical threshold breach — alert and outbox event in same transaction + [Fact] + public async Task CriticalBreach_AlertCreated_InSameTransaction() + { + // Potassium 2.1 mEq/L is below critical_low of 2.5 — immediately life-threatening + var resp = await _client.PostAsJsonAsync( + $"/api/v1/encounters/{_encounterId}/observations", + new BatchIngestRequest(new List + { + new("POTASSIUM_MEQ_L", 2.1m, "mEq/L", ObservationSource.Lab, DateTimeOffset.UtcNow, "key-critical-001") + })); + + resp.StatusCode.Should().Be(HttpStatusCode.Created); + var body = await resp.Content.ReadFromJsonAsync(); + body!.RootElement.GetProperty("data").GetProperty("alertGenerated").GetBoolean() + .Should().BeTrue(); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var alert = await db.ClinicalAlerts.SingleAsync(); + alert.Severity.Should().Be(AlertSeverity.Critical); + alert.Status.Should().Be(AlertStatus.Open); + alert.EncounterId.Should().Be(_encounterId); + alert.PatientId.Should().Be(_patientId); + + // Both outbox events must exist: alert.generated AND observation.recorded + var topics = await db.OutboxEvents.Select(e => e.Topic).OrderBy(t => t).ToListAsync(); + topics.Should().BeEquivalentTo(new[] { "alert.generated", "observation.recorded" }); + } + + // Test 3: warning threshold breach — no alert created; only observation.recorded outbox event + [Fact] + public async Task WarningBreach_NoAlertCreated_OnlyObservationOutboxEvent() + { + // Heart rate 104 bpm is above warning_high (100) but below critical_high (150) + var resp = await _client.PostAsJsonAsync( + $"/api/v1/encounters/{_encounterId}/observations", + new BatchIngestRequest(new List + { + new("HEART_RATE", 104, "bpm", ObservationSource.Device, DateTimeOffset.UtcNow, null) + })); + + resp.StatusCode.Should().Be(HttpStatusCode.Created); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var alertCount = await db.ClinicalAlerts.CountAsync(); + alertCount.Should().Be(0, "warning detection is deferred to the Kafka consumer in Phase 3"); + + var topics = await db.OutboxEvents.Select(e => e.Topic).ToListAsync(); + topics.Should().ContainSingle().Which.Should().Be("observation.recorded"); + } + + // Test 4: duplicate idempotency key — returns 201 with original observation, no duplicate + [Fact] + public async Task DuplicateIdempotencyKey_Returns201_NoDuplicateRow() + { + var payload = new BatchIngestRequest(new List + { + new("HEART_RATE", 78, "bpm", ObservationSource.Device, DateTimeOffset.UtcNow, "device-key-abc123") + }); + + var resp1 = await _client.PostAsJsonAsync( + $"/api/v1/encounters/{_encounterId}/observations", payload); + var resp2 = await _client.PostAsJsonAsync( + $"/api/v1/encounters/{_encounterId}/observations", payload); + + resp1.StatusCode.Should().Be(HttpStatusCode.Created); + resp2.StatusCode.Should().Be(HttpStatusCode.Created); + + var body2 = await resp2.Content.ReadFromJsonAsync(); + body2!.RootElement.GetProperty("data").GetProperty("duplicate").GetBoolean() + .Should().BeTrue("the second call must be identified as a duplicate"); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var count = await db.Observations.CountAsync(); + count.Should().Be(1, "only one observation row must exist despite two identical requests"); + } + + // Test 5: discharged encounter — 409 returned, no observation written + [Fact] + public async Task DischargedEncounter_Returns409_NoObservationWritten() + { + // Discharge the encounter + var patchResp = await _client.PatchAsJsonAsync( + $"/api/v1/encounters/{_encounterId}/status", + new { status = "Discharged" }); + patchResp.StatusCode.Should().Be(HttpStatusCode.OK); + + // Attempt to ingest against a discharged encounter + var resp = await _client.PostAsJsonAsync( + $"/api/v1/encounters/{_encounterId}/observations", + new BatchIngestRequest(new List + { + new("HEART_RATE", 78, "bpm", ObservationSource.Device, DateTimeOffset.UtcNow, null) + })); + + resp.StatusCode.Should().Be(HttpStatusCode.Conflict); + var body = await resp.Content.ReadFromJsonAsync(); + body!.RootElement.GetProperty("error").GetProperty("code").GetString() + .Should().Be("ENCOUNTER_NOT_ACTIVE"); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var count = await db.Observations.CountAsync(); + count.Should().Be(0, "no observation must be written when encounter is not active"); + } + + // Test 6: plausibility violation — 422 returned, no rows written + [Fact] + public async Task ImplausibleValue_Returns422_NoRowsWritten() + { + // Heart rate of 350 bpm: above plausibility ceiling of 300 + var resp = await _client.PostAsJsonAsync( + $"/api/v1/encounters/{_encounterId}/observations", + new BatchIngestRequest(new List + { + new("HEART_RATE", 350, "bpm", ObservationSource.Device, DateTimeOffset.UtcNow, null) + })); + + resp.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + var body = await resp.Content.ReadFromJsonAsync(); + body!.RootElement.GetProperty("error").GetProperty("code").GetString() + .Should().Be("OBSERVATION_OUT_OF_PLAUSIBLE_RANGE"); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + (await db.Observations.CountAsync()).Should().Be(0); + (await db.OutboxEvents.CountAsync()).Should().Be(0); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI.Tests/VigilCareClinicalAPI.Tests.csproj b/VigilCareClinicalAPI.Tests/VigilCareClinicalAPI.Tests.csproj new file mode 100644 index 0000000..f03a013 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/VigilCareClinicalAPI.Tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/VigilCareClinicalAPI/Common/CursorPage.cs b/VigilCareClinicalAPI/Common/CursorPage.cs new file mode 100644 index 0000000..fe500f5 --- /dev/null +++ b/VigilCareClinicalAPI/Common/CursorPage.cs @@ -0,0 +1 @@ +public record CursorPage(List Items, string? NextCursor, bool HasMore); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Controllers/AlertsController.cs b/VigilCareClinicalAPI/Controllers/AlertsController.cs new file mode 100644 index 0000000..9bbcd74 --- /dev/null +++ b/VigilCareClinicalAPI/Controllers/AlertsController.cs @@ -0,0 +1,156 @@ +using Microsoft.AspNetCore.Mvc; + + +/// +/// Clinical alert listing, acknowledgment, and resolution. +/// +[ApiController] +[Produces("application/json")] +public class AlertsController : ControllerBase +{ + private readonly IAlertService _alerts; + + public AlertsController(IAlertService alerts) => _alerts = alerts; + + /// + /// Lists alerts for a single encounter with optional status filter. + /// + /// Encounter id. + /// Optional status filter (DB literal, e.g. OPEN). + /// Page number (1-based). + /// Results per page. + /// A paginated list of alerts for the encounter. + [HttpGet("api/v1/encounters/{encounterId:guid}/alerts")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + public async Task ListByEncounter( + Guid encounterId, + [FromQuery] string? status, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + { + AlertStatus? parsedStatus = null; + if (!string.IsNullOrEmpty(status)) + { + try + { + parsedStatus = AlertStatusExtensions.FromDbString(status); + } + catch (ArgumentOutOfRangeException) + { + return BadRequest(ApiResponse.Fail(400, "Invalid status filter.", "INVALID_STATUS")); + } + } + + var result = await _alerts.ListByEncounterAsync(encounterId, parsedStatus, page, pageSize); + return Ok(ApiResponse.Ok(new + { + items = result.Items, + page = result.Page, + pageSize = result.PageSize, + totalCount = result.TotalCount, + totalPages = result.TotalPages + })); + } + + /// + /// Lists alerts across all encounters with optional status, severity, and department filters. + /// + /// Optional status filter (DB literal, e.g. OPEN). + /// Optional severity filter (DB literal, e.g. CRITICAL). + /// Optional department filter. + /// Page number (1-based). + /// Results per page. + /// A paginated list of alerts. + [HttpGet("api/v1/alerts")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + public async Task ListGlobal( + [FromQuery] string? status, + [FromQuery] string? severity, + [FromQuery] string? department, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + { + AlertStatus? parsedStatus = null; + if (!string.IsNullOrEmpty(status)) + { + try + { + parsedStatus = AlertStatusExtensions.FromDbString(status); + } + catch (ArgumentOutOfRangeException) + { + return BadRequest(ApiResponse.Fail(400, "Invalid status filter.", "INVALID_STATUS")); + } + } + + AlertSeverity? parsedSeverity = null; + if (!string.IsNullOrEmpty(severity)) + { + try + { + parsedSeverity = AlertSeverityExtensions.FromDbString(severity); + } + catch (ArgumentOutOfRangeException) + { + return BadRequest(ApiResponse.Fail(400, "Invalid severity filter.", "INVALID_SEVERITY")); + } + } + + var result = await _alerts.ListGlobalAsync(parsedStatus, parsedSeverity, department, page, pageSize); + return Ok(ApiResponse.Ok(new + { + items = result.Items, + page = result.Page, + pageSize = result.PageSize, + totalCount = result.TotalCount, + totalPages = result.TotalPages + })); + } + + /// + /// Gets a single alert by id, including its encounter. + /// + /// Alert id. + /// The alert record. + [HttpGet("api/v1/alerts/{id:guid}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task Get(Guid id) + { + var alert = await _alerts.GetByIdAsync(id); + return Ok(ApiResponse.Ok(alert)); + } + + /// + /// Acknowledges an open or escalated alert and emits an outbox event for downstream consumers. + /// + /// Alert id. + /// Clinician id and optional note. + /// The updated alert. + [HttpPost("api/v1/alerts/{id:guid}/acknowledge")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + public async Task Acknowledge(Guid id, [FromBody] AcknowledgeAlertRequest req) + { + var alert = await _alerts.AcknowledgeAsync(id, req); + return Ok(ApiResponse.Ok(alert)); + } + + /// + /// Resolves an acknowledged alert. + /// + /// Alert id. + /// The updated alert. + [HttpPost("api/v1/alerts/{id:guid}/resolve")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + public async Task Resolve(Guid id) + { + var alert = await _alerts.ResolveAsync(id); + return Ok(ApiResponse.Ok(alert)); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Controllers/ObservationsController.cs b/VigilCareClinicalAPI/Controllers/ObservationsController.cs new file mode 100644 index 0000000..3ea5423 --- /dev/null +++ b/VigilCareClinicalAPI/Controllers/ObservationsController.cs @@ -0,0 +1,87 @@ +using Microsoft.AspNetCore.Mvc; + + +/// +/// Observation ingest and cursor-paginated history for an encounter. +/// +[ApiController] +[Route("api/v1/encounters/{encounterId:guid}/observations")] +[Produces("application/json")] +public class ObservationsController : ControllerBase +{ + private readonly IObservationService _ingest; + private readonly IObservationQueryService _query; + + public ObservationsController(IObservationService ingest, IObservationQueryService query) + { + _ingest = ingest; + _query = query; + } + + /// + /// Ingests one to ten observations for an encounter in a single request. + /// + /// Encounter id. + /// Batch of observations to record. + /// Per-observation ingest results, including any generated alerts. + [HttpPost] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task Ingest(Guid encounterId, [FromBody] BatchIngestRequest req) + { + if (req.Observations.Count == 0) + return BadRequest(ApiResponse.Fail(400, "At least one observation is required.", "EMPTY_BATCH")); + + if (req.Observations.Count > 10) + return BadRequest(ApiResponse.Fail(400, + "Batch size cannot exceed 10 observations.", "BATCH_TOO_LARGE")); + + var results = new List(); + foreach (var obs in req.Observations) + { + var result = await _ingest.IngestAsync(encounterId, obs); + results.Add(new + { + observation = result.Observation, + alertGenerated = result.AlertCreated is not null, + alertId = result.AlertCreated?.Id, + duplicate = result.IsDuplicate + }); + } + + return StatusCode(201, ApiResponse.Created( + req.Observations.Count == 1 ? (object)results[0] : results)); + } + + /// + /// Returns cursor-paginated observation history for an encounter. + /// + /// Encounter id. + /// Optional observation code filter. + /// Optional start of recorded-at range. + /// Optional end of recorded-at range. + /// Maximum items per page. + /// Opaque cursor from a previous page. + /// A page of observations with an optional next cursor. + [HttpGet] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task History( + Guid encounterId, + [FromQuery] string? code, + [FromQuery] DateTimeOffset? from, + [FromQuery] DateTimeOffset? to, + [FromQuery] int limit = 50, + [FromQuery] string? cursor = null) + { + var page = await _query.GetHistoryAsync(encounterId, code, from, to, limit, cursor); + return Ok(ApiResponse.Ok(new + { + items = page.Items, + nextCursor = page.NextCursor, + hasMore = page.HasMore + })); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Models/Records/Alert/AcknowledgeAlertRequest.cs b/VigilCareClinicalAPI/Models/Records/Alert/AcknowledgeAlertRequest.cs new file mode 100644 index 0000000..5c0446d --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Alert/AcknowledgeAlertRequest.cs @@ -0,0 +1 @@ +public record AcknowledgeAlertRequest(string ClinicianId, string? Note); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Models/Records/Observation/BatchIngestRequest.cs b/VigilCareClinicalAPI/Models/Records/Observation/BatchIngestRequest.cs new file mode 100644 index 0000000..820f3cd --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Observation/BatchIngestRequest.cs @@ -0,0 +1 @@ +public record BatchIngestRequest(List Observations); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Models/Records/Observation/IngestObservationRequest.cs b/VigilCareClinicalAPI/Models/Records/Observation/IngestObservationRequest.cs new file mode 100644 index 0000000..4ee04d5 --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Observation/IngestObservationRequest.cs @@ -0,0 +1,8 @@ +public record IngestObservationRequest( + string ObservationCode, + decimal Value, + string Unit, + ObservationSource Source, + DateTimeOffset RecordedAt, + string? IdempotencyKey +); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Models/Records/Observation/IngestResult.cs b/VigilCareClinicalAPI/Models/Records/Observation/IngestResult.cs new file mode 100644 index 0000000..e2cee0b --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Observation/IngestResult.cs @@ -0,0 +1,8 @@ +public record IngestResult(Observation Observation, ClinicalAlert? AlertCreated, bool IsDuplicate = false) +{ + public static IngestResult Created(Observation obs, ClinicalAlert? alert) => + new(obs, alert, false); + + public static IngestResult Duplicate(Observation obs) => + new(obs, null, true); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Models/Records/Observation/ObservationCursor.cs b/VigilCareClinicalAPI/Models/Records/Observation/ObservationCursor.cs new file mode 100644 index 0000000..b49b644 --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Observation/ObservationCursor.cs @@ -0,0 +1,25 @@ +using System.Text; +using System.Text.Json; + +public record ObservationCursor(DateTimeOffset RecordedAt, Guid Id) +{ + public string Encode() + { + var json = JsonSerializer.Serialize(this); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); + } + + public static ObservationCursor? Decode(string? encoded) + { + if (string.IsNullOrEmpty(encoded)) return null; + try + { + var json = Encoding.UTF8.GetString(Convert.FromBase64String(encoded)); + return JsonSerializer.Deserialize(json); + } + catch + { + return null; + } + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Models/Records/Observation/ThresholdCacheEntry.cs b/VigilCareClinicalAPI/Models/Records/Observation/ThresholdCacheEntry.cs new file mode 100644 index 0000000..275ab34 --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Observation/ThresholdCacheEntry.cs @@ -0,0 +1,6 @@ +public record ThresholdCacheEntry( + string ObservationCode, + decimal? CriticalLow, + decimal? WarningLow, + decimal? WarningHigh, + decimal? CriticalHigh); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Program.cs b/VigilCareClinicalAPI/Program.cs index 97f896f..81c11f1 100644 --- a/VigilCareClinicalAPI/Program.cs +++ b/VigilCareClinicalAPI/Program.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Serilog; using StackExchange.Redis; +using System.Text.Json.Serialization; Log.Logger = new LoggerConfiguration() .WriteTo.Console() @@ -18,23 +19,33 @@ try builder.Services.AddDbContext(opts => opts.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); - builder.Services.AddSingleton( - ConnectionMultiplexer.Connect(builder.Configuration["Redis:ConnectionString"]!)); + builder.Services.AddSingleton(sp => + ConnectionMultiplexer.Connect(sp.GetRequiredService()["Redis:ConnectionString"]!)); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddHostedService(); - builder.Services.AddControllers(); + builder.Services.AddControllers() + .AddJsonOptions(opts => + { + opts.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; + opts.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + opts.JsonSerializerOptions.Converters.Add(new ObservationSourceJsonConverter()); + }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app = builder.Build(); - using (var scope = app.Services.CreateScope()) + if (!app.Environment.IsEnvironment("Testing")) { + using var scope = app.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var redis = scope.ServiceProvider.GetRequiredService(); await DataSeeder.SeedAsync(db, redis); @@ -71,8 +82,11 @@ catch (HostAbortedException) catch (Exception ex) { Log.Fatal(ex, "Application failed to start."); + throw; } finally { Log.CloseAndFlush(); -} \ No newline at end of file +} + +public partial class Program { } \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/AlertService.cs b/VigilCareClinicalAPI/Services/AlertService.cs new file mode 100644 index 0000000..2ef3afa --- /dev/null +++ b/VigilCareClinicalAPI/Services/AlertService.cs @@ -0,0 +1,123 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; + +public class AlertService : IAlertService +{ + private readonly AppDbContext _db; + + public AlertService(AppDbContext db) => _db = db; + + public async Task> ListByEncounterAsync( + Guid encounterId, AlertStatus? status, int page, int pageSize) + { + var query = _db.ClinicalAlerts + .AsNoTracking() + .Where(a => a.EncounterId == encounterId); + + if (status.HasValue) + query = query.Where(a => a.Status == status.Value); + + var total = await query.CountAsync(); + var alerts = await query + .OrderByDescending(a => a.TriggeredAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return new PagedResult(alerts, page, pageSize, total); + } + + public async Task> ListGlobalAsync( + AlertStatus? status, AlertSeverity? severity, string? department, int page, int pageSize) + { + var query = _db.ClinicalAlerts + .AsNoTracking() + .Include(a => a.Encounter) + .AsQueryable(); + + if (status.HasValue) + query = query.Where(a => a.Status == status.Value); + + if (severity.HasValue) + query = query.Where(a => a.Severity == severity.Value); + + if (!string.IsNullOrEmpty(department)) + query = query.Where(a => a.Encounter.Department == department); + + var total = await query.CountAsync(); + var alerts = await query + .OrderByDescending(a => a.TriggeredAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return new PagedResult(alerts, page, pageSize, total); + } + + public async Task GetByIdAsync(Guid id) + { + var alert = await _db.ClinicalAlerts + .AsNoTracking() + .Include(a => a.Encounter) + .FirstOrDefaultAsync(a => a.Id == id); + + if (alert is null) + throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND"); + + return alert; + } + + public async Task AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req) + { + var alert = await _db.ClinicalAlerts.FindAsync(id); + if (alert is null) + throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND"); + + if (alert.Status != AlertStatus.Open && alert.Status != AlertStatus.Escalated) + throw new ConflictException( + $"Alert cannot be acknowledged from status '{alert.Status}'.", + "ALERT_NOT_ACKNOWLEDGEABLE"); + + alert.Status = AlertStatus.Acknowledged; + alert.AcknowledgedAt = DateTimeOffset.UtcNow; + alert.AcknowledgedBy = req.ClinicianId; + + // Write an outbox event so the Kafka consumer (Phase 6) can cancel the + // pending RabbitMQ escalation timer when it sees this acknowledgment. + _db.OutboxEvents.Add(new OutboxEvent + { + Id = Guid.NewGuid(), + Topic = "alert.acknowledged", + Payload = JsonSerializer.Serialize(new + { + alertId = alert.Id, + encounterId = alert.EncounterId, + acknowledgedBy = req.ClinicianId, + acknowledgedAt = alert.AcknowledgedAt, + note = req.Note + }), + CreatedAt = DateTimeOffset.UtcNow + }); + + await _db.SaveChangesAsync(); + return alert; + } + + public async Task ResolveAsync(Guid id) + { + var alert = await _db.ClinicalAlerts.FindAsync(id); + if (alert is null) + throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND"); + + if (alert.Status != AlertStatus.Acknowledged) + throw new ConflictException( + "Alert must be acknowledged before it can be resolved.", + "ALERT_NOT_ACKNOWLEDGED"); + + alert.Status = AlertStatus.Resolved; + alert.ResolvedAt = DateTimeOffset.UtcNow; + await _db.SaveChangesAsync(); + + return alert; + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/Interfaces/IAlertService.cs b/VigilCareClinicalAPI/Services/Interfaces/IAlertService.cs new file mode 100644 index 0000000..76ba91d --- /dev/null +++ b/VigilCareClinicalAPI/Services/Interfaces/IAlertService.cs @@ -0,0 +1,14 @@ +public interface IAlertService +{ + Task> ListByEncounterAsync( + Guid encounterId, AlertStatus? status, int page, int pageSize); + + Task> ListGlobalAsync( + AlertStatus? status, AlertSeverity? severity, string? department, int page, int pageSize); + + Task GetByIdAsync(Guid id); + + Task AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req); + + Task ResolveAsync(Guid id); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/Interfaces/IObservationQueryService.cs b/VigilCareClinicalAPI/Services/Interfaces/IObservationQueryService.cs new file mode 100644 index 0000000..0f4b188 --- /dev/null +++ b/VigilCareClinicalAPI/Services/Interfaces/IObservationQueryService.cs @@ -0,0 +1,10 @@ +public interface IObservationQueryService +{ + Task> GetHistoryAsync( + Guid encounterId, + string? code, + DateTimeOffset? from, + DateTimeOffset? to, + int limit, + string? cursorToken); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/Interfaces/IObservationService.cs b/VigilCareClinicalAPI/Services/Interfaces/IObservationService.cs new file mode 100644 index 0000000..fc5073f --- /dev/null +++ b/VigilCareClinicalAPI/Services/Interfaces/IObservationService.cs @@ -0,0 +1,4 @@ +public interface IObservationService +{ + Task IngestAsync(Guid encounterId, IngestObservationRequest req); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/ObservationQueryService.cs b/VigilCareClinicalAPI/Services/ObservationQueryService.cs new file mode 100644 index 0000000..db7f17d --- /dev/null +++ b/VigilCareClinicalAPI/Services/ObservationQueryService.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore; + +public class ObservationQueryService : IObservationQueryService +{ + private readonly AppDbContext _db; + + public ObservationQueryService(AppDbContext db) => _db = db; + + public async Task> GetHistoryAsync( + Guid encounterId, + string? code, + DateTimeOffset? from, + DateTimeOffset? to, + int limit, + string? cursorToken) + { + limit = Math.Clamp(limit, 1, 100); + var cursor = ObservationCursor.Decode(cursorToken); + + var query = _db.Observations + .AsNoTracking() + .Where(o => o.EncounterId == encounterId); + + if (!string.IsNullOrEmpty(code)) + query = query.Where(o => o.ObservationCode == code); + + if (from.HasValue) + query = query.Where(o => o.RecordedAt >= from.Value); + + if (to.HasValue) + query = query.Where(o => o.RecordedAt <= to.Value); + + if (cursor is not null) + { + // Keyset condition for ORDER BY recorded_at DESC, id DESC: + // next page starts just below the cursor position + var cursorTime = cursor.RecordedAt; + var cursorId = cursor.Id; + query = query.Where(o => + o.RecordedAt < cursorTime || + (o.RecordedAt == cursorTime && o.Id.CompareTo(cursorId) < 0)); + } + + var items = await query + .OrderByDescending(o => o.RecordedAt) + .ThenByDescending(o => o.Id) + .Take(limit + 1) // fetch one extra to know if there is a next page + .ToListAsync(); + + var hasMore = items.Count > limit; + if (hasMore) items.RemoveAt(limit); + + var nextCursor = hasMore + ? new ObservationCursor(items[^1].RecordedAt, items[^1].Id).Encode() + : null; + + return new CursorPage(items, nextCursor, hasMore); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/ObservationService.cs b/VigilCareClinicalAPI/Services/ObservationService.cs new file mode 100644 index 0000000..621d1bf --- /dev/null +++ b/VigilCareClinicalAPI/Services/ObservationService.cs @@ -0,0 +1,215 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using StackExchange.Redis; + +public class ObservationService : IObservationService +{ + private readonly AppDbContext _db; + private readonly IConnectionMultiplexer _redis; + private readonly ILogger _logger; + + public ObservationService( + AppDbContext db, + IConnectionMultiplexer redis, + ILogger logger) + { + _db = db; + _redis = redis; + _logger = logger; + } + + public async Task IngestAsync(Guid encounterId, IngestObservationRequest req) + { + // Step 1 — encounter must be active + var encounter = await _db.Encounters + .AsNoTracking() + .FirstOrDefaultAsync(e => e.Id == encounterId); + + if (encounter is null) + throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND"); + + if (encounter.Status != EncounterStatus.Active) + throw new ConflictException( + $"Cannot record observations for an encounter with status '{encounter.Status}'.", + "ENCOUNTER_NOT_ACTIVE"); + + // Step 2 — idempotency check before entering the transaction + // The unique partial index is the database safety net for concurrent retries. + // The pre-check here avoids the exception-and-rollback path for the common retry case. + if (!string.IsNullOrEmpty(req.IdempotencyKey)) + { + var existing = await _db.Observations + .AsNoTracking() + .FirstOrDefaultAsync(o => o.IdempotencyKey == req.IdempotencyKey); + + if (existing is not null) + { + _logger.LogInformation( + "Duplicate idempotency key {Key} for encounter {EncounterId} — returning original", + req.IdempotencyKey, encounterId); + return IngestResult.Duplicate(existing); + } + } + + // Step 3 — plausibility check + if (!PlausibilityValidator.IsPlausible(req.ObservationCode, req.Value, out var reason)) + throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE"); + + // Steps 4–8 are one atomic transaction + await using var tx = await _db.Database.BeginTransactionAsync(); + try + { + // Step 4 — insert observation + var observation = new Observation + { + Id = Guid.NewGuid(), + EncounterId = encounterId, + ObservationCode = req.ObservationCode, + Value = req.Value, + Unit = req.Unit, + Source = req.Source, + IdempotencyKey = req.IdempotencyKey, + RecordedAt = req.RecordedAt, + CreatedAt = DateTimeOffset.UtcNow + }; + _db.Observations.Add(observation); + + // Step 5 — load threshold from Redis; fall back to PostgreSQL on miss + var threshold = await LoadThresholdAsync(req.ObservationCode); + + if (threshold is null) + throw new ValidationException( + $"No alert threshold is configured for observation code '{req.ObservationCode}'. " + + "Register a threshold before recording observations for this code.", + "UNKNOWN_OBSERVATION_CODE"); + + ClinicalAlert? alert = null; + + // Step 6 — critical threshold detection (synchronous) + // WARNING detection is intentionally deferred to the Kafka consumer. + // A critical potassium of 2.1 mEq/L is immediately life-threatening — the alert + // must exist before this API call returns. A warning heart rate of 95 bpm warrants + // attention but not an emergency page; the additional Kafka latency is clinically safe. + if (IsCriticalBreach(req.Value, threshold)) + { + alert = new ClinicalAlert + { + Id = Guid.NewGuid(), + EncounterId = encounterId, + PatientId = encounter.PatientId, + ObservationId = observation.Id, + AlertType = AlertTypeExtensions.CriticalFor(req.ObservationCode), + Severity = AlertSeverity.Critical, + Details = BuildCriticalDetails(req, threshold), + Status = AlertStatus.Open, + TriggeredAt = DateTimeOffset.UtcNow + }; + _db.ClinicalAlerts.Add(alert); + + // Step 6b — outbox event for the alert (relay picks this up in Phase 3) + _db.OutboxEvents.Add(BuildOutboxEvent("alert.generated", new + { + alertId = alert.Id, + encounterId, + patientId = encounter.PatientId, + alertType = alert.AlertType.ToDbString(), + severity = alert.Severity.ToDbString(), + triggeredAt = alert.TriggeredAt, + partitionKey = encounterId.ToString() + })); + } + + // Step 7 — outbox event for the observation (always; Kafka consumer handles warnings) + _db.OutboxEvents.Add(BuildOutboxEvent("observation.recorded", new + { + observationId = observation.Id, + encounterId, + patientId = encounter.PatientId, + observationCode = req.ObservationCode, + value = req.Value, + unit = req.Unit, + source = req.Source.ToDbString(), + recordedAt = req.RecordedAt, + partitionKey = encounterId.ToString() + })); + + // Step 8 — COMMIT + await _db.SaveChangesAsync(); + await tx.CommitAsync(); + + _logger.LogInformation( + "Observation {ObservationId} ingested for encounter {EncounterId}. AlertCreated={AlertCreated}", + observation.Id, encounterId, alert is not null); + + return IngestResult.Created(observation, alert); + } + catch (DbUpdateException ex) when (IsUniqueViolation(ex)) + { + // Race condition: two concurrent retries both passed the pre-check above. + // The unique partial index caught it. Roll back and return the existing row. + await tx.RollbackAsync(); + var existing = await _db.Observations + .AsNoTracking() + .FirstOrDefaultAsync(o => o.IdempotencyKey == req.IdempotencyKey); + if (existing is not null) + return IngestResult.Duplicate(existing); + throw new ConflictException("Concurrent duplicate submission.", "CONCURRENT_DUPLICATE"); + } + catch + { + await tx.RollbackAsync(); + throw; + } + } + + private async Task LoadThresholdAsync(string observationCode) + { + var cache = _redis.GetDatabase(); + var cacheKey = $"threshold:{observationCode}"; + + var cached = await cache.StringGetAsync(cacheKey); + if (cached.HasValue) + return JsonSerializer.Deserialize(cached!); + + // Cache miss — read from PostgreSQL and write back + var threshold = await _db.AlertThresholds + .AsNoTracking() + .FirstOrDefaultAsync(t => t.ObservationCode == observationCode); + + if (threshold is null) return null; + + var entry = new ThresholdCacheEntry( + threshold.ObservationCode, + threshold.CriticalLow, + threshold.WarningLow, + threshold.WarningHigh, + threshold.CriticalHigh); + + await cache.StringSetAsync(cacheKey, JsonSerializer.Serialize(entry)); + + _logger.LogDebug("Cache miss for threshold {Code} — loaded from PostgreSQL", observationCode); + return entry; + } + + private static bool IsCriticalBreach(decimal value, ThresholdCacheEntry t) => + (t.CriticalLow.HasValue && value < t.CriticalLow.Value) || + (t.CriticalHigh.HasValue && value > t.CriticalHigh.Value); + + private static string BuildCriticalDetails(IngestObservationRequest req, ThresholdCacheEntry t) + { + if (t.CriticalLow.HasValue && req.Value < t.CriticalLow.Value) + return $"{req.ObservationCode} value {req.Value} {req.Unit} is below critical low of {t.CriticalLow} {req.Unit}."; + return $"{req.ObservationCode} value {req.Value} {req.Unit} is above critical high of {t.CriticalHigh} {req.Unit}."; + } + + private static OutboxEvent BuildOutboxEvent(string topic, object payload) => new() + { + Id = Guid.NewGuid(), + Topic = topic, + Payload = JsonSerializer.Serialize(payload), + CreatedAt = DateTimeOffset.UtcNow + }; + + private static bool IsUniqueViolation(DbUpdateException ex) => + ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23505"; +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/PlausibilityValidator.cs b/VigilCareClinicalAPI/Services/PlausibilityValidator.cs new file mode 100644 index 0000000..e11dd64 --- /dev/null +++ b/VigilCareClinicalAPI/Services/PlausibilityValidator.cs @@ -0,0 +1,35 @@ +public static class PlausibilityValidator +{ + // Plausible ranges define the outer boundary of physically possible values. + // These are NOT clinical thresholds — they catch device malfunctions and typos. + // A heart rate of 300 is clinically impossible; 150 is critical but possible. + private static readonly Dictionary _ranges = new() + { + ["HEART_RATE"] = (1, 300), + ["TEMP_C"] = (20, 50), + ["POTASSIUM_MEQ_L"] = (0.1m, 15), + ["SPO2"] = (50, 100), + ["RESP_RATE"] = (1, 80), + ["WBC_K_UL"] = (0.1m, 500), + ["GLUCOSE_MG_DL"] = (10, 1500), + }; + + public static bool IsPlausible(string observationCode, decimal value, out string? reason) + { + if (!_ranges.TryGetValue(observationCode, out var range)) + { + // Unknown codes pass plausibility — threshold lookup will validate the code + reason = null; + return true; + } + + if (value < range.Min || value > range.Max) + { + reason = $"Value {value} is outside the plausible range [{range.Min}–{range.Max}] for {observationCode}."; + return false; + } + + reason = null; + return true; + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/appsettings.Testing.json b/VigilCareClinicalAPI/appsettings.Testing.json new file mode 100644 index 0000000..27d4508 --- /dev/null +++ b/VigilCareClinicalAPI/appsettings.Testing.json @@ -0,0 +1,14 @@ +{ + "Serilog": { + "Using": [ "Serilog.Sinks.Console" ], + "MinimumLevel": { + "Default": "Warning", + "Override": { + "Microsoft.AspNetCore": "Warning" + } + }, + "WriteTo": [ + { "Name": "Console" } + ] + } +} diff --git a/scripts/run-api-redis-tests.sh b/scripts/run-api-redis-tests.sh new file mode 100755 index 0000000..93832b7 --- /dev/null +++ b/scripts/run-api-redis-tests.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash + +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}" +REDIS_PORT="${REDIS_PORT:-6382}" +REDIS_KEY="${REDIS_KEY:-threshold:HEART_RATE}" +RECORDED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +TMP_FILES=() + +cleanup() { + local f + for f in "${TMP_FILES[@]}"; do + rm -f "${f}" "${f}.status" + 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 + +redis_cmd() { + if command -v redis-cli >/dev/null 2>&1; then + redis-cli -p "${REDIS_PORT}" "$@" + elif command -v docker >/dev/null 2>&1 && [[ -f "${COMPOSE_FILE}" ]]; then + docker compose -f "${COMPOSE_FILE}" exec -T redis redis-cli "$@" + else + echo "Missing dependency: redis-cli (or docker compose with redis service)" + exit 1 + 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}" + echo "Response body:" + cat "${body_file}" + echo + return 1 + fi +} + +assert_json() { + local body_file="$1" + local jq_expr="$2" + local expected="$3" + local actual + actual="$(jq -r "${jq_expr}" "${body_file}")" + if [[ "${actual}" != "${expected}" ]]; then + echo "Expected ${jq_expr} = ${expected}, got ${actual}" + echo "Response body:" + cat "${body_file}" + echo + return 1 + fi +} + +TOTAL_STEPS=16 + +echo "Running API + Redis verification against ${BASE_URL}" +echo "Recorded-at timestamp: ${RECORDED_AT}" + +echo "" +echo "[0/${TOTAL_STEPS}] Preflight — API 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 +echo "OK: API is up" + +echo "" +echo "[1/${TOTAL_STEPS}] Listing thresholds" +resp="$(request GET "${BASE_URL}/api/v1/alert-thresholds")" +assert_status "200" "${resp}" +threshold_id="$(jq -r '.data[] | select(.observationCode=="HEART_RATE") | .id' "${resp}" | head -n 1)" +if [[ -z "${threshold_id}" || "${threshold_id}" == "null" ]]; then + echo "Could not find HEART_RATE threshold id." + exit 1 +fi +echo "OK: HEART_RATE threshold id = ${threshold_id}" + +echo "" +echo "[2/${TOTAL_STEPS}] Registering patient" +patient_payload='{"firstName":"Test","lastName":"Runner","dateOfBirth":"1988-01-10","gender":"F"}' +resp="$(request POST "${BASE_URL}/api/v1/patients" "${patient_payload}")" +assert_status "201" "${resp}" +patient_id="$(jq -r '.data.id' "${resp}")" +if [[ -z "${patient_id}" || "${patient_id}" == "null" ]]; then + echo "Could not parse patient id." + exit 1 +fi +echo "OK: patient id = ${patient_id}" + +echo "" +echo "[3/${TOTAL_STEPS}] Opening encounter" +enc_payload='{"encounterType":"Inpatient","department":"ICU","attendingPhysician":"Dr. Script"}' +resp="$(request POST "${BASE_URL}/api/v1/patients/${patient_id}/encounters" "${enc_payload}")" +assert_status "201" "${resp}" +encounter_id="$(jq -r '.data.id' "${resp}")" +if [[ -z "${encounter_id}" || "${encounter_id}" == "null" ]]; then + echo "Could not parse encounter id." + exit 1 +fi +echo "OK: encounter id = ${encounter_id}" + +echo "" +echo "[4/${TOTAL_STEPS}] Verifying illegal status transition returns 409" +transition_payload='{"status":"Scheduled"}' +resp="$(request PATCH "${BASE_URL}/api/v1/encounters/${encounter_id}/status" "${transition_payload}")" +assert_status "409" "${resp}" +error_code="$(jq -r '.error.code' "${resp}")" +echo "OK: illegal transition blocked (${error_code})" + +echo "" +echo "[5/${TOTAL_STEPS}] Updating HEART_RATE threshold" +update_payload='{"observationCode":"HEART_RATE","displayName":"Heart Rate","unit":"bpm","criticalLow":30,"warningLow":50,"warningHigh":110,"criticalHigh":160}' +resp="$(request PUT "${BASE_URL}/api/v1/alert-thresholds/${threshold_id}" "${update_payload}")" +assert_status "200" "${resp}" +echo "OK: threshold update accepted" + +echo "" +echo "[6/${TOTAL_STEPS}] Verifying Redis invalidation" +redis_value="$(redis_cmd GET "${REDIS_KEY}" | tr -d '\r')" +if [[ "${redis_value}" != "(nil)" && -n "${redis_value}" ]]; then + echo "Expected Redis key ${REDIS_KEY} to be invalidated, but found value." + echo "Value: ${redis_value}" + exit 1 +fi +echo "OK: Redis key invalidated (${REDIS_KEY})" + +echo "" +echo "[7/${TOTAL_STEPS}] Listing patient" +resp="$(request GET "${BASE_URL}/api/v1/patients/${patient_id}")" +assert_status "200" "${resp}" +echo "OK: patient lookup succeeds" + +echo "" +echo "[8/${TOTAL_STEPS}] Ingesting normal observation (no alert)" +normal_obs_payload="$(jq -nc \ + --arg recordedAt "${RECORDED_AT}" \ + '{observations:[{observationCode:"HEART_RATE",value:78,unit:"bpm",source:"DEVICE",recordedAt:$recordedAt}]}')" +resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${normal_obs_payload}")" +assert_status "201" "${resp}" +assert_json "${resp}" '.data.alertGenerated' 'false' +echo "OK: normal observation ingested without alert" + +echo "" +echo "[9/${TOTAL_STEPS}] Ingesting critical potassium (alert expected)" +critical_obs_payload="$(jq -nc \ + --arg recordedAt "${RECORDED_AT}" \ + '{observations:[{observationCode:"POTASSIUM_MEQ_L",value:2.1,unit:"mEq/L",source:"LAB",recordedAt:$recordedAt,idempotencyKey:"script-critical-001"}]}')" +resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${critical_obs_payload}")" +assert_status "201" "${resp}" +assert_json "${resp}" '.data.alertGenerated' 'true' +alert_id="$(jq -r '.data.alertId' "${resp}")" +if [[ -z "${alert_id}" || "${alert_id}" == "null" ]]; then + echo "Could not parse alert id from critical ingest." + exit 1 +fi +echo "OK: critical alert generated (alert id = ${alert_id})" + +echo "" +echo "[10/${TOTAL_STEPS}] Fetching observation history" +resp="$(request GET "${BASE_URL}/api/v1/encounters/${encounter_id}/observations?code=HEART_RATE&limit=10")" +assert_status "200" "${resp}" +history_count="$(jq -r '.data.items | length' "${resp}")" +if [[ "${history_count}" -lt 1 ]]; then + echo "Expected at least one HEART_RATE observation in history, got ${history_count}" + exit 1 +fi +echo "OK: observation history returned ${history_count} item(s)" + +echo "" +echo "[11/${TOTAL_STEPS}] Resolving unacknowledged alert returns 409" +resp="$(request POST "${BASE_URL}/api/v1/alerts/${alert_id}/resolve")" +assert_status "409" "${resp}" +assert_json "${resp}" '.error.code' 'ALERT_NOT_ACKNOWLEDGED' +echo "OK: resolve blocked before acknowledge" + +echo "" +echo "[12/${TOTAL_STEPS}] Acknowledging alert" +ack_payload='{"clinicianId":"DR-SCRIPT","note":"Reviewing from verification script."}' +resp="$(request POST "${BASE_URL}/api/v1/alerts/${alert_id}/acknowledge" "${ack_payload}")" +assert_status "200" "${resp}" +assert_json "${resp}" '.data.status' 'Acknowledged' +echo "OK: alert acknowledged" + +echo "" +echo "[13/${TOTAL_STEPS}] Resolving acknowledged alert" +resp="$(request POST "${BASE_URL}/api/v1/alerts/${alert_id}/resolve")" +assert_status "200" "${resp}" +assert_json "${resp}" '.data.status' 'Resolved' +echo "OK: alert resolved" + +echo "" +echo "[14/${TOTAL_STEPS}] Rejecting implausible observation with 422" +implausible_payload="$(jq -nc \ + --arg recordedAt "${RECORDED_AT}" \ + '{observations:[{observationCode:"HEART_RATE",value:350,unit:"bpm",source:"DEVICE",recordedAt:$recordedAt}]}')" +resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${implausible_payload}")" +assert_status "422" "${resp}" +assert_json "${resp}" '.error.code' 'OBSERVATION_OUT_OF_PLAUSIBLE_RANGE' +echo "OK: implausible value rejected" + +echo "" +echo "[15/${TOTAL_STEPS}] Discharging encounter" +discharge_payload='{"status":"Discharged"}' +resp="$(request PATCH "${BASE_URL}/api/v1/encounters/${encounter_id}/status" "${discharge_payload}")" +assert_status "200" "${resp}" +echo "OK: encounter discharged" + +echo "" +echo "[16/${TOTAL_STEPS}] Ingest against discharged encounter returns 409" +post_discharge_payload="$(jq -nc \ + --arg recordedAt "${RECORDED_AT}" \ + '{observations:[{observationCode:"HEART_RATE",value:78,unit:"bpm",source:"DEVICE",recordedAt:$recordedAt}]}')" +resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${post_discharge_payload}")" +assert_status "409" "${resp}" +assert_json "${resp}" '.error.code' 'ENCOUNTER_NOT_ACTIVE' +echo "OK: ingest blocked for discharged encounter" + +echo "" +echo "All ${TOTAL_STEPS} API + Redis checks passed."