From 706318e5d26dfec93457516dfc4cb94adb98ff59 Mon Sep 17 00:00:00 2001 From: voltsrage Date: Fri, 26 Jun 2026 15:05:02 +0800 Subject: [PATCH] feature: Approval and Promotion to VigilCareClinical --- .../Helpers/BatchPipelineHelper.cs | 144 +++ .../Helpers/DbResetHelper.cs | 9 +- VigilCareRecordsAPI.Tests/PromotionTests.cs | 448 +++++++ .../Controllers/ApprovalController.cs | 86 ++ VigilCareRecordsAPI/Data/AppDbContext.cs | 5 + .../Clinical/EncounterConfiguration.cs | 34 + .../IdempotencyRecordConfiguration.cs | 26 + .../Clinical/ObservationConfiguration.cs | 28 + .../Clinical/OutboxEventConfiguration.cs | 23 + .../Clinical/PatientConfiguration.cs | 33 + .../DraftEncounterConfiguration.cs | 0 .../DraftObservationConfiguration.cs | 0 .../{ => Draft}/DraftPatientConfiguration.cs | 0 ...26060803_AddIdempotencyRecords.Designer.cs | 768 ++++++++++++ .../20260626060803_AddIdempotencyRecords.cs | 51 + ..._AddClinicalSchemaAndPromotion.Designer.cs | 1069 +++++++++++++++++ ...626062059_AddClinicalSchemaAndPromotion.cs | 186 +++ .../20260626065328_AddMrnSequence.Designer.cs | 1069 +++++++++++++++++ .../20260626065328_AddMrnSequence.cs | 23 + .../Migrations/AppDbContextModelSnapshot.cs | 356 ++++++ .../Domain/Entities/Clinical/Encounter.cs | 16 + .../Entities/Clinical/IdempotencyRecord.cs | 11 + .../Domain/Entities/Clinical/Observation.cs | 18 + .../Domain/Entities/Clinical/OutboxEvent.cs | 11 + .../Domain/Entities/Clinical/Patient.cs | 14 + .../Entities/{ => Draft}/DraftEncounter.cs | 0 .../Entities/{ => Draft}/DraftObservation.cs | 0 .../Entities/{ => Draft}/DraftPatient.cs | 0 .../Middlewares/CorrelationIdMiddleware.cs | 0 .../Middlewares/ExceptionHandlerMiddleware.cs | 0 .../Records/Promotion/ApproveRequest.cs | 3 + .../Promotion/PromotionResultResponse.cs | 10 + VigilCareRecordsAPI/Program.cs | 3 + .../Services/IdempotencyService.cs | 50 + .../Interfaces/IIdempotencyService.cs | 14 + .../Services/Interfaces/IMrnGenerator.cs | 8 + .../Services/Interfaces/IPromotionService.cs | 23 + VigilCareRecordsAPI/Services/MrnGenerator.cs | 44 + .../Services/PromotionService.cs | 392 ++++++ ...-vigilcare-records-phase-4-verification.sh | 667 ++++++++++ 40 files changed, 5639 insertions(+), 3 deletions(-) create mode 100644 VigilCareRecordsAPI.Tests/Helpers/BatchPipelineHelper.cs create mode 100644 VigilCareRecordsAPI.Tests/PromotionTests.cs create mode 100644 VigilCareRecordsAPI/Controllers/ApprovalController.cs create mode 100644 VigilCareRecordsAPI/Data/Configurations/Clinical/EncounterConfiguration.cs create mode 100644 VigilCareRecordsAPI/Data/Configurations/Clinical/IdempotencyRecordConfiguration.cs create mode 100644 VigilCareRecordsAPI/Data/Configurations/Clinical/ObservationConfiguration.cs create mode 100644 VigilCareRecordsAPI/Data/Configurations/Clinical/OutboxEventConfiguration.cs create mode 100644 VigilCareRecordsAPI/Data/Configurations/Clinical/PatientConfiguration.cs rename VigilCareRecordsAPI/Data/Configurations/{ => Draft}/DraftEncounterConfiguration.cs (100%) rename VigilCareRecordsAPI/Data/Configurations/{ => Draft}/DraftObservationConfiguration.cs (100%) rename VigilCareRecordsAPI/Data/Configurations/{ => Draft}/DraftPatientConfiguration.cs (100%) create mode 100644 VigilCareRecordsAPI/Data/Migrations/20260626060803_AddIdempotencyRecords.Designer.cs create mode 100644 VigilCareRecordsAPI/Data/Migrations/20260626060803_AddIdempotencyRecords.cs create mode 100644 VigilCareRecordsAPI/Data/Migrations/20260626062059_AddClinicalSchemaAndPromotion.Designer.cs create mode 100644 VigilCareRecordsAPI/Data/Migrations/20260626062059_AddClinicalSchemaAndPromotion.cs create mode 100644 VigilCareRecordsAPI/Data/Migrations/20260626065328_AddMrnSequence.Designer.cs create mode 100644 VigilCareRecordsAPI/Data/Migrations/20260626065328_AddMrnSequence.cs create mode 100644 VigilCareRecordsAPI/Domain/Entities/Clinical/Encounter.cs create mode 100644 VigilCareRecordsAPI/Domain/Entities/Clinical/IdempotencyRecord.cs create mode 100644 VigilCareRecordsAPI/Domain/Entities/Clinical/Observation.cs create mode 100644 VigilCareRecordsAPI/Domain/Entities/Clinical/OutboxEvent.cs create mode 100644 VigilCareRecordsAPI/Domain/Entities/Clinical/Patient.cs rename VigilCareRecordsAPI/Domain/Entities/{ => Draft}/DraftEncounter.cs (100%) rename VigilCareRecordsAPI/Domain/Entities/{ => Draft}/DraftObservation.cs (100%) rename VigilCareRecordsAPI/Domain/Entities/{ => Draft}/DraftPatient.cs (100%) delete mode 100644 VigilCareRecordsAPI/Middlewares/CorrelationIdMiddleware.cs delete mode 100644 VigilCareRecordsAPI/Middlewares/ExceptionHandlerMiddleware.cs create mode 100644 VigilCareRecordsAPI/Models/Records/Promotion/ApproveRequest.cs create mode 100644 VigilCareRecordsAPI/Models/Records/Promotion/PromotionResultResponse.cs create mode 100644 VigilCareRecordsAPI/Services/IdempotencyService.cs create mode 100644 VigilCareRecordsAPI/Services/Interfaces/IIdempotencyService.cs create mode 100644 VigilCareRecordsAPI/Services/Interfaces/IMrnGenerator.cs create mode 100644 VigilCareRecordsAPI/Services/Interfaces/IPromotionService.cs create mode 100644 VigilCareRecordsAPI/Services/MrnGenerator.cs create mode 100644 VigilCareRecordsAPI/Services/PromotionService.cs create mode 100755 scripts/run-vigilcare-records-phase-4-verification.sh diff --git a/VigilCareRecordsAPI.Tests/Helpers/BatchPipelineHelper.cs b/VigilCareRecordsAPI.Tests/Helpers/BatchPipelineHelper.cs new file mode 100644 index 0000000..b33545e --- /dev/null +++ b/VigilCareRecordsAPI.Tests/Helpers/BatchPipelineHelper.cs @@ -0,0 +1,144 @@ +using System.Net.Http.Json; +using System.Text.Json; + +public static class BatchPipelineHelper +{ + /// + /// Drives a batch from upload through to verified status, returning the batch ID. + /// Uses intake1 for upload, entry1 for data entry, verifier1 for verification. + /// + public static async Task CreateAndVerifyBatchAsync( + ApiFixture fixture, + string batchType = "VITALS_SHEET", + string track = "BACKFILL", + string patientFullName = "Test Patient", + string patientDateOfBirth = "1990-05-15") + { + // === Upload as intake1 === + var client = await AuthHelper.LoginAsync(fixture, "intake1"); + + var fileContent = new ByteArrayContent(GenerateTestPdf()); + fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf"); + + var formData = new MultipartFormDataContent + { + { fileContent, "file", $"test-scan-{Guid.NewGuid()}.pdf" }, + { new StringContent(batchType), "batchType" }, + { new StringContent(track), "track" } + }; + + var uploadResponse = await client.PostAsync("/api/v1/digitization-batches", formData); + uploadResponse.EnsureSuccessStatusCode(); + + var uploadResult = await uploadResponse.Content.ReadFromJsonAsync(); + var batchId = uploadResult.GetProperty("data").GetProperty("id").GetGuid(); + + // === Assign to entry1 === + var assignResponse = await client.PatchAsJsonAsync( + $"/api/v1/digitization-batches/{batchId}/assign", + new { entryClerkUserId = await GetUserIdAsync(fixture, "entry1") }); + assignResponse.EnsureSuccessStatusCode(); + + // === Data entry as entry1 === + client = await AuthHelper.LoginAsync(fixture, "entry1"); + + // Add draft patient + (await client.PutAsJsonAsync( + $"/api/v1/digitization-batches/{batchId}/draft/patient", + new + { + fullName = patientFullName, + dateOfBirth = patientDateOfBirth, + sex = "Male", + bloodType = "A+", + noKnownAllergies = true + })).EnsureSuccessStatusCode(); + + // Add draft encounter + (await client.PutAsJsonAsync( + $"/api/v1/digitization-batches/{batchId}/draft/encounter", + new + { + admissionDate = "2024-01-15T08:00:00Z", + department = Department.GeneralMedicine.ToDbString(), + roomBed = "301-A", + admissionReason = "Routine checkup", + status = "active" + })).EnsureSuccessStatusCode(); + + // Add draft observations + (await client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batchId}/draft/observations", + new + { + observationCode = "HR", + value = 88.0, + unit = "bpm", + recordedAt = "2024-01-15T09:30:00Z", + note = "Resting heart rate" + })).EnsureSuccessStatusCode(); + + (await client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batchId}/draft/observations", + new + { + observationCode = "TEMP", + value = 37.2, + unit = "C", + recordedAt = "2024-01-15T09:30:00Z", + note = "Oral temperature" + })).EnsureSuccessStatusCode(); + + (await client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batchId}/draft/observations", + new + { + observationCode = "BP_SYS", + value = 120.0, + unit = "mmHg", + recordedAt = "2024-01-15T09:31:00Z" + })).EnsureSuccessStatusCode(); + + // Submit for verification + (await client.PostAsync( + $"/api/v1/digitization-batches/{batchId}/submit-for-verification", + null)).EnsureSuccessStatusCode(); + + // === Verify as verifier1 === + client = await AuthHelper.LoginAsync(fixture, "verifier1"); + + var verifyRequest = new VerifyBatchRequest( + new List + { + new("patient.fullName", "ok", null), + new("observation.heartRate", "ok", null), + new("observation.temperature", "ok", null), + new("observation.bloodPressureSystolic", "ok", null) + }, + Passed: true + ); + + var verifyResponse = await client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batchId}/verify", verifyRequest); + verifyResponse.EnsureSuccessStatusCode(); + + return batchId; + } + + private static async Task GetUserIdAsync(ApiFixture fixture, string username) + { + var client = await AuthHelper.LoginAsync(fixture, username); + var meResponse = await client.GetFromJsonAsync("/api/v1/auth/me"); + return meResponse.GetProperty("data").GetProperty("id").GetGuid(); + } + + private static byte[] GenerateTestPdf() + { + // Minimal valid PDF for testing + var pdf = "%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" + + "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + + "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n" + + $"%%EOF\n%% test-{Guid.NewGuid()}"; + return System.Text.Encoding.ASCII.GetBytes(pdf); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs b/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs index 4852745..7dffd83 100644 --- a/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs +++ b/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs @@ -7,9 +7,12 @@ public static class DbResetHelper public static async Task ResetAsync(AppDbContext db) { await db.Database.ExecuteSqlRawAsync(@" - TRUNCATE TABLE digitization_events, draft_observations, - draft_encounters, draft_patients, - scanned_documents, digitization_batches, users + TRUNCATE TABLE clinical.outbox_events, clinical.observations, + clinical.encounters, clinical.patients, + idempotency_records, + digitization_events, draft_observations, + draft_encounters, draft_patients, + scanned_documents, digitization_batches, users RESTART IDENTITY CASCADE; "); } diff --git a/VigilCareRecordsAPI.Tests/PromotionTests.cs b/VigilCareRecordsAPI.Tests/PromotionTests.cs new file mode 100644 index 0000000..dafc955 --- /dev/null +++ b/VigilCareRecordsAPI.Tests/PromotionTests.cs @@ -0,0 +1,448 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +[Collection("Database")] +public class PromotionTests : IAsyncLifetime +{ + private readonly ApiFixture _fixture; + private HttpClient _client = null!; + + public PromotionTests(ApiFixture fixture) => _fixture = fixture; + + public async Task InitializeAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + await DataSeeder.SeedAsync(db); + _client = _fixture.CreateClient(); + } + + public Task DisposeAsync() => Task.CompletedTask; + + /// + /// Full pipeline: upload → entry → verify → approve → live observations exist, zero alerts for backfill. + /// + [Fact] + public async Task Approve_BackfillBatch_CreatesLiveRecords_ZeroOutboxEvents() + { + // Arrange: drive batch through full lifecycle to verified status + var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync( + _fixture, track: "BACKFILL"); + + // Act: approve as clinical approver (approver1) + _client = await AuthHelper.LoginAsync(_fixture, "approver1"); + + var idempotencyKey = Guid.NewGuid().ToString(); + _client.DefaultRequestHeaders.Add("Idempotency-Key", idempotencyKey); + + var approveResponse = await _client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batchId}/approve", + new ApproveRequest(EnableRetroactiveAlerts: false)); + + // Assert: HTTP 200 + approveResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await approveResponse.Content + .ReadFromJsonAsync>(); + + result.Should().NotBeNull(); + result!.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + + var promotion = result.Data!; + promotion.BatchId.Should().Be(batchId); + promotion.Status.Should().Be("PROMOTED"); + promotion.PatientId.Should().NotBeEmpty(); + promotion.Mrn.Should().StartWith("VCR-"); + promotion.EncounterId.Should().NotBeEmpty(); + promotion.ObservationIds.Should().HaveCount(3); + promotion.OutboxEventsWritten.Should().Be(0, + "backfill with enableRetroactiveAlerts=false should write zero outbox events"); + + // Verify live records in database + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Patient exists with correct MRN + var patient = await db.Patients.FirstOrDefaultAsync(p => p.Id == promotion.PatientId); + patient.Should().NotBeNull(); + patient!.Mrn.Should().Be(promotion.Mrn); + patient.FullName.Should().Be("Test Patient"); + patient.DateOfBirth.Should().Be(new DateOnly(1990, 5, 15)); + + // Encounter exists + var encounter = await db.Encounters.FirstOrDefaultAsync(e => e.Id == promotion.EncounterId); + encounter.Should().NotBeNull(); + encounter!.PatientId.Should().Be(promotion.PatientId); + encounter.Department.Should().Be(Department.GeneralMedicine); + + // All 3 observations exist with correct source + var observations = await db.Observations + .Where(o => o.SourceBatchId == batchId) + .ToListAsync(); + observations.Should().HaveCount(3); + observations.Should().AllSatisfy(o => + { + o.Source.Should().Be("digitization_backfill"); + o.PatientId.Should().Be(promotion.PatientId); + o.EncounterId.Should().Be(promotion.EncounterId); + }); + + // Zero outbox events for this batch + var observationIds = observations.Select(o => o.Id).ToList(); + var outboxEvents = await db.OutboxEvents + .Where(e => observationIds.Contains(e.AggregateId)) + .ToListAsync(); + outboxEvents.Should().BeEmpty( + "backfill with enableRetroactiveAlerts=false should not create outbox events"); + + // Batch status is Promoted + var batch = await db.DigitizationBatches.FirstAsync(b => b.Id == batchId); + batch.Status.Should().Be(BatchStatus.Promoted); + batch.PromotedAt.Should().NotBeNull(); + batch.PromotionEncounterId.Should().Be(promotion.EncounterId); + + // DigitizationEvent with type "promoted" exists + var promotedEvent = await db.DigitizationEvents + .FirstOrDefaultAsync(e => e.BatchId == batchId && e.EventType == DigitizationEventType.Promoted); + promotedEvent.Should().NotBeNull(); + + // Clean up header for other tests + _client.DefaultRequestHeaders.Remove("Idempotency-Key"); + } + + /// + /// Backfill batch with enableRetroactiveAlerts=true should write outbox events for all observations. + /// + [Fact] + public async Task Approve_BackfillWithRetroactiveAlerts_CreatesOutboxEvents() + { + // Arrange + var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync( + _fixture, track: "BACKFILL"); + + // Act + _client = await AuthHelper.LoginAsync(_fixture, "approver1"); + var idempotencyKey = Guid.NewGuid().ToString(); + _client.DefaultRequestHeaders.Add("Idempotency-Key", idempotencyKey); + + var approveResponse = await _client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batchId}/approve", + new ApproveRequest(EnableRetroactiveAlerts: true)); + + // Assert + approveResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await approveResponse.Content + .ReadFromJsonAsync>(); + + result!.Data!.OutboxEventsWritten.Should().Be(3, + "backfill with enableRetroactiveAlerts=true should write one outbox event per observation"); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var observationIds = await db.Observations + .Where(o => o.SourceBatchId == batchId) + .Select(o => o.Id) + .ToListAsync(); + + var outboxEvents = await db.OutboxEvents + .Where(e => observationIds.Contains(e.AggregateId)) + .ToListAsync(); + outboxEvents.Should().HaveCount(3); + outboxEvents.Should().AllSatisfy(e => + { + e.EventType.Should().Be("observation.created"); + e.AggregateType.Should().Be("Observation"); + e.ProcessedAt.Should().BeNull(); + }); + + _client.DefaultRequestHeaders.Remove("Idempotency-Key"); + } + + /// + /// Live-capture track always writes outbox events regardless of enableRetroactiveAlerts flag. + /// + [Fact] + public async Task Approve_LiveCaptureBatch_AlwaysCreatesOutboxEvents() + { + // Arrange + var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync( + _fixture, track: "LIVE_CAPTURE"); + + // Act + _client = await AuthHelper.LoginAsync(_fixture, "approver1"); + var idempotencyKey = Guid.NewGuid().ToString(); + _client.DefaultRequestHeaders.Add("Idempotency-Key", idempotencyKey); + + var approveResponse = await _client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batchId}/approve", + new ApproveRequest(EnableRetroactiveAlerts: false)); + + // Assert + approveResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await approveResponse.Content + .ReadFromJsonAsync>(); + + result!.Data!.OutboxEventsWritten.Should().Be(3, + "live_capture track should always write outbox events"); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var observations = await db.Observations + .Where(o => o.SourceBatchId == batchId) + .ToListAsync(); + observations.Should().AllSatisfy(o => + o.Source.Should().Be("live_capture")); + + _client.DefaultRequestHeaders.Remove("Idempotency-Key"); + } + + /// + /// Idempotent promotion: approve twice with same Idempotency-Key produces one set of live rows. + /// + [Fact] + public async Task Approve_SameIdempotencyKey_ReturnsIdenticalResult_NoDuplicateRows() + { + // Arrange + var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync(_fixture); + + _client = await AuthHelper.LoginAsync(_fixture, "approver1"); + var idempotencyKey = Guid.NewGuid().ToString(); + + // Act: first approve + _client.DefaultRequestHeaders.Add("Idempotency-Key", idempotencyKey); + + var firstResponse = await _client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batchId}/approve", + new ApproveRequest(EnableRetroactiveAlerts: false)); + + firstResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var firstResult = await firstResponse.Content + .ReadFromJsonAsync>(); + + // Act: second approve with same key + _client.DefaultRequestHeaders.Remove("Idempotency-Key"); + _client.DefaultRequestHeaders.Add("Idempotency-Key", idempotencyKey); + + var secondResponse = await _client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batchId}/approve", + new ApproveRequest(EnableRetroactiveAlerts: false)); + + secondResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var secondResult = await secondResponse.Content + .ReadFromJsonAsync>(); + + // Assert: identical results + secondResult!.Data!.PatientId.Should().Be(firstResult!.Data!.PatientId); + secondResult.Data.EncounterId.Should().Be(firstResult.Data.EncounterId); + secondResult.Data.ObservationIds.Should().BeEquivalentTo(firstResult.Data.ObservationIds); + secondResult.Data.Mrn.Should().Be(firstResult.Data.Mrn); + + // Assert: only one set of rows in database + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var observations = await db.Observations + .Where(o => o.SourceBatchId == batchId) + .ToListAsync(); + observations.Should().HaveCount(3, + "idempotent replay should not create duplicate observations"); + + _client.DefaultRequestHeaders.Remove("Idempotency-Key"); + } + + /// + /// Separation of duties: entry clerk cannot approve their own batch. + /// + [Fact] + public async Task Approve_ByEntryClerk_Returns409() + { + // Arrange + var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync(_fixture); + + // Act: try to approve as entry1 (who entered the data) + _client = await AuthHelper.LoginAsync(_fixture, "entry1"); + _client.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString()); + + var approveResponse = await _client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batchId}/approve", + new ApproveRequest()); + + // Assert: either 403 (wrong role) or 409 (separation of duties) + // entry1 has DataEntryClerk role, so the [Authorize(Roles = ...)] will reject with 403 + approveResponse.StatusCode.Should().Be(HttpStatusCode.Forbidden); + + _client.DefaultRequestHeaders.Remove("Idempotency-Key"); + } + + /// + /// Missing Idempotency-Key header returns 400. + /// + [Fact] + public async Task Approve_MissingIdempotencyKey_Returns400() + { + // Arrange + var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync(_fixture); + _client = await AuthHelper.LoginAsync(_fixture, "approver1"); + + // Act: approve without Idempotency-Key header + var approveResponse = await _client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batchId}/approve", + new ApproveRequest()); + + // Assert + approveResponse.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var result = await approveResponse.Content.ReadFromJsonAsync>(); + result!.Error!.Code.Should().Be("MISSING_IDEMPOTENCY_KEY"); + } + + /// + /// Batch in wrong status (e.g., uploaded) returns 409. + /// + [Fact] + public async Task Approve_WrongStatus_Returns409() + { + // Arrange: create a batch but don't verify it + _client = await AuthHelper.LoginAsync(_fixture, "intake1"); + + var fileContent = new ByteArrayContent( + System.Text.Encoding.ASCII.GetBytes( + $"%PDF-1.4\n%%EOF\n%test-{Guid.NewGuid()}")); + fileContent.Headers.ContentType = + new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf"); + + var formData = new MultipartFormDataContent + { + { fileContent, "file", "test.pdf" }, + { new StringContent("VITALS_SHEET"), "batchType" } + }; + + var uploadResponse = await _client.PostAsync( + "/api/v1/digitization-batches", formData); + uploadResponse.EnsureSuccessStatusCode(); + + var uploadResult = await uploadResponse.Content.ReadFromJsonAsync(); + var batchId = uploadResult.GetProperty("data").GetProperty("id").GetGuid(); + + // Act: try to approve an uploaded batch + _client = await AuthHelper.LoginAsync(_fixture, "approver1"); + _client.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString()); + + var approveResponse = await _client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batchId}/approve", + new ApproveRequest()); + + // Assert + approveResponse.StatusCode.Should().Be(HttpStatusCode.Conflict); + + _client.DefaultRequestHeaders.Remove("Idempotency-Key"); + } + + /// + /// GET /promotion-result returns correct data after promotion. + /// + [Fact] + public async Task GetPromotionResult_AfterPromotion_ReturnsLiveIds() + { + // Arrange: create and promote a batch + var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync(_fixture); + + _client = await AuthHelper.LoginAsync(_fixture, "approver1"); + var idempotencyKey = Guid.NewGuid().ToString(); + _client.DefaultRequestHeaders.Add("Idempotency-Key", idempotencyKey); + + var approveResponse = await _client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batchId}/approve", + new ApproveRequest()); + approveResponse.EnsureSuccessStatusCode(); + + var approveResult = await approveResponse.Content + .ReadFromJsonAsync>(); + + _client.DefaultRequestHeaders.Remove("Idempotency-Key"); + + // Act: get promotion result + var resultResponse = await _client.GetAsync( + $"/api/v1/digitization-batches/{batchId}/promotion-result"); + + // Assert + resultResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await resultResponse.Content + .ReadFromJsonAsync>(); + + result!.Data!.BatchId.Should().Be(batchId); + result.Data.PatientId.Should().Be(approveResult!.Data!.PatientId); + result.Data.EncounterId.Should().Be(approveResult.Data.EncounterId); + result.Data.ObservationIds.Should().HaveCount(3); + } + + /// + /// GET /promotion-result on non-promoted batch returns 409. + /// + [Fact] + public async Task GetPromotionResult_NotPromoted_Returns409() + { + // Arrange + var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync(_fixture); + _client = await AuthHelper.LoginAsync(_fixture, "approver1"); + + // Act: query promotion result before approving + var resultResponse = await _client.GetAsync( + $"/api/v1/digitization-batches/{batchId}/promotion-result"); + + // Assert + resultResponse.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + /// + /// MRN generation produces unique, sequential values. + /// + [Fact] + public async Task Approve_MultipleBatches_ProducesSequentialMrns() + { + // Arrange & Act: promote two batches with distinct patients so each gets a new MRN + var batchId1 = await BatchPipelineHelper.CreateAndVerifyBatchAsync(_fixture); + var batchId2 = await BatchPipelineHelper.CreateAndVerifyBatchAsync( + _fixture, patientFullName: "Test Patient Two", patientDateOfBirth: "1991-06-20"); + + _client = await AuthHelper.LoginAsync(_fixture, "approver1"); + + _client.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString()); + var result1 = await _client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batchId1}/approve", + new ApproveRequest()); + _client.DefaultRequestHeaders.Remove("Idempotency-Key"); + + _client.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString()); + var result2 = await _client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batchId2}/approve", + new ApproveRequest()); + _client.DefaultRequestHeaders.Remove("Idempotency-Key"); + + var promotion1 = (await result1.Content + .ReadFromJsonAsync>())!.Data!; + var promotion2 = (await result2.Content + .ReadFromJsonAsync>())!.Data!; + + // Assert: MRNs are unique + promotion1.Mrn.Should().NotBe(promotion2.Mrn); + promotion1.Mrn.Should().StartWith("VCR-"); + promotion2.Mrn.Should().StartWith("VCR-"); + + // Assert: MRNs follow sequential pattern + var mrn1Num = int.Parse(promotion1.Mrn.Split('-')[1]); + var mrn2Num = int.Parse(promotion2.Mrn.Split('-')[1]); + (mrn2Num - mrn1Num).Should().BeGreaterThanOrEqualTo(1); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Controllers/ApprovalController.cs b/VigilCareRecordsAPI/Controllers/ApprovalController.cs new file mode 100644 index 0000000..6b30213 --- /dev/null +++ b/VigilCareRecordsAPI/Controllers/ApprovalController.cs @@ -0,0 +1,86 @@ + +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +/// +/// Approval and promotion of verified digitization batches to VigilCareClinical live tables. +/// +[ApiController] +[Route("api/v1/digitization-batches")] +[Produces("application/json")] +[Authorize] +public class ApprovalController : ControllerBase +{ + private readonly IPromotionService _promotion; + private readonly ILogger _logger; + + public ApprovalController(IPromotionService promotion, ILogger logger) + { + _promotion = promotion; + _logger = logger; + } + + /// + /// Approves a verified or awaiting-clinical-approval batch and promotes its draft data + /// into live VigilCareClinical tables (Patient, Encounter, Observations). + /// + /// Requires the Idempotency-Key header for safe retries. If the same key is resubmitted, + /// the original response is returned without re-executing the promotion. + /// + /// Separation of duties: the approver cannot be the entry clerk or verifier of the same batch. + /// + /// The batch ID to approve. + /// Optional request body with alert configuration. + /// Promotion result with live IDs for patient, encounter, and observations. + [HttpPost("{id:guid}/approve")] + [Authorize(Roles = "CLINICAL_APPROVER,ADMINISTRATOR")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task Approve(Guid id, [FromBody] ApproveRequest? request) + { + var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(idempotencyKey)) + return BadRequest(ApiResponse.Fail(400, + "Idempotency-Key header is required for promotion operations.", + "MISSING_IDEMPOTENCY_KEY")); + + if (idempotencyKey.Length > 100) + return BadRequest(ApiResponse.Fail(400, + "Idempotency-Key must be at most 100 characters.", + "INVALID_IDEMPOTENCY_KEY")); + + var approverUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + var enableRetroactiveAlerts = request?.EnableRetroactiveAlerts ?? false; + + _logger.LogInformation( + "Approve request for batch {BatchId} by user {UserId} with idempotency key {Key}", + id, approverUserId, idempotencyKey); + + var result = await _promotion.ApproveAndPromoteAsync( + id, approverUserId, enableRetroactiveAlerts, idempotencyKey); + + return Ok(ApiResponse.Ok(result)); + } + + /// + /// Returns the promotion result for an already-promoted batch, including the live IDs + /// for Patient, Encounter, and Observations that were created during promotion. + /// + /// The batch ID to query. + /// Promotion result with live clinical entity IDs. + [HttpGet("{id:guid}/promotion-result")] + [Authorize] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + public async Task GetPromotionResult(Guid id) + { + var result = await _promotion.GetPromotionResultAsync(id); + return Ok(ApiResponse.Ok(result)); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Data/AppDbContext.cs b/VigilCareRecordsAPI/Data/AppDbContext.cs index c50750d..16e8331 100644 --- a/VigilCareRecordsAPI/Data/AppDbContext.cs +++ b/VigilCareRecordsAPI/Data/AppDbContext.cs @@ -13,6 +13,11 @@ public class AppDbContext : DbContext public DbSet DigitizationEvents => Set(); public DbSet RefreshTokens => Set(); public DbSet AuthAuditEvents => Set(); + public DbSet IdempotencyRecords => Set(); + public DbSet Patients => Set(); + public DbSet Encounters => Set(); + public DbSet Observations => Set(); + public DbSet OutboxEvents => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/VigilCareRecordsAPI/Data/Configurations/Clinical/EncounterConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/Clinical/EncounterConfiguration.cs new file mode 100644 index 0000000..f29d635 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Configurations/Clinical/EncounterConfiguration.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class EncounterConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("encounters", "clinical", t => + { + t.HasCheckConstraint("chk_encounters_department", + "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); + builder.Property(e => e.PatientId).HasColumnName("patient_id").IsRequired(); + builder.Property(e => e.AdmissionDate).HasColumnName("admission_date"); + builder.Property(e => e.Department) + .HasColumnName("department") + .HasMaxLength(100) + .HasConversion( + v => v.HasValue ? v.Value.ToDbString() : null, + v => v == null ? null : DepartmentExtensions.FromDbString(v)); + builder.Property(e => e.RoomBed).HasColumnName("room_bed").HasMaxLength(50); + builder.Property(e => e.AdmissionReason).HasColumnName("admission_reason").HasMaxLength(500); + builder.Property(e => e.DischargeDiagnosis).HasColumnName("discharge_diagnosis").HasMaxLength(500); + builder.Property(e => e.Status).HasColumnName("status").HasMaxLength(20).IsRequired(); + builder.Property(e => e.SourceBatchId).HasColumnName("source_batch_id"); + builder.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + builder.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()"); + + builder.HasOne(e => e.Patient).WithMany().HasForeignKey(e => e.PatientId).OnDelete(DeleteBehavior.Restrict); + builder.HasIndex(e => new { e.PatientId, e.Status }).HasDatabaseName("ix_encounters_patient_status"); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Data/Configurations/Clinical/IdempotencyRecordConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/Clinical/IdempotencyRecordConfiguration.cs new file mode 100644 index 0000000..fffbbfb --- /dev/null +++ b/VigilCareRecordsAPI/Data/Configurations/Clinical/IdempotencyRecordConfiguration.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class IdempotencyRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("idempotency_records"); + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); + builder.Property(r => r.IdempotencyKey).HasColumnName("idempotency_key").HasMaxLength(100).IsRequired(); + builder.Property(r => r.OperationName).HasColumnName("operation_name").HasMaxLength(100).IsRequired(); + builder.Property(r => r.ResourceId).HasColumnName("resource_id").IsRequired(); + builder.Property(r => r.HttpStatusCode).HasColumnName("http_status_code").IsRequired(); + builder.Property(r => r.ResponseBodyJson).HasColumnName("response_body_json").HasColumnType("jsonb").IsRequired(); + builder.Property(r => r.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + builder.Property(r => r.ExpiresAt).HasColumnName("expires_at").IsRequired(); + + builder.HasIndex(r => new { r.IdempotencyKey, r.OperationName }) + .IsUnique() + .HasDatabaseName("ix_idempotency_records_key_operation"); + + builder.HasIndex(r => r.ExpiresAt) + .HasDatabaseName("ix_idempotency_records_expires_at"); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Data/Configurations/Clinical/ObservationConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/Clinical/ObservationConfiguration.cs new file mode 100644 index 0000000..492497f --- /dev/null +++ b/VigilCareRecordsAPI/Data/Configurations/Clinical/ObservationConfiguration.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class ObservationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("observations", "clinical"); + builder.HasKey(o => o.Id); + builder.Property(o => o.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); + builder.Property(o => o.EncounterId).HasColumnName("encounter_id").IsRequired(); + builder.Property(o => o.PatientId).HasColumnName("patient_id").IsRequired(); + builder.Property(o => o.ObservationCode).HasColumnName("observation_code").HasMaxLength(50).IsRequired(); + builder.Property(o => o.Value).HasColumnName("value").HasColumnType("decimal(10,3)").IsRequired(); + builder.Property(o => o.Unit).HasColumnName("unit").HasMaxLength(20).IsRequired(); + builder.Property(o => o.RecordedAt).HasColumnName("recorded_at").IsRequired(); + builder.Property(o => o.Note).HasColumnName("note").HasMaxLength(500); + builder.Property(o => o.Source).HasColumnName("source").HasMaxLength(30).IsRequired(); + builder.Property(o => o.SourceDraftObservationId).HasColumnName("source_draft_observation_id"); + builder.Property(o => o.SourceBatchId).HasColumnName("source_batch_id"); + builder.Property(o => o.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + + builder.HasOne(o => o.Encounter).WithMany().HasForeignKey(o => o.EncounterId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(o => o.Patient).WithMany().HasForeignKey(o => o.PatientId).OnDelete(DeleteBehavior.Restrict); + builder.HasIndex(o => new { o.EncounterId, o.ObservationCode }).HasDatabaseName("ix_observations_encounter_code"); + builder.HasIndex(o => o.SourceBatchId).HasFilter("source_batch_id IS NOT NULL").HasDatabaseName("ix_observations_source_batch"); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Data/Configurations/Clinical/OutboxEventConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/Clinical/OutboxEventConfiguration.cs new file mode 100644 index 0000000..fef6678 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Configurations/Clinical/OutboxEventConfiguration.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class OutboxEventConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("outbox_events", "clinical"); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); + builder.Property(e => e.EventType).HasColumnName("event_type").HasMaxLength(100).IsRequired(); + builder.Property(e => e.AggregateType).HasColumnName("aggregate_type").HasMaxLength(50).IsRequired(); + builder.Property(e => e.AggregateId).HasColumnName("aggregate_id").IsRequired(); + builder.Property(e => e.PayloadJson).HasColumnName("payload_json").HasColumnType("jsonb").IsRequired(); + builder.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + builder.Property(e => e.ProcessedAt).HasColumnName("processed_at"); + builder.Property(e => e.RetryCount).HasColumnName("retry_count").HasDefaultValue(0); + + builder.HasIndex(e => e.ProcessedAt) + .HasFilter("processed_at IS NULL") + .HasDatabaseName("ix_outbox_events_unprocessed"); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Data/Configurations/Clinical/PatientConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/Clinical/PatientConfiguration.cs new file mode 100644 index 0000000..15340e9 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Configurations/Clinical/PatientConfiguration.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class PatientConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("patients", "clinical", t => + { + t.HasCheckConstraint("chk_patients_blood_type", + "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + builder.HasKey(p => p.Id); + builder.Property(p => p.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); + builder.Property(p => p.Mrn).HasColumnName("mrn").HasMaxLength(20).IsRequired(); + builder.Property(p => p.FullName).HasColumnName("full_name").HasMaxLength(200).IsRequired(); + builder.Property(p => p.DateOfBirth).HasColumnName("date_of_birth"); + builder.Property(p => p.Sex).HasColumnName("sex").HasMaxLength(10); + builder.Property(p => p.BloodType) + .HasColumnName("blood_type") + .HasMaxLength(10) + .HasConversion( + v => v.HasValue ? v.Value.ToDbString() : null, + v => v == null ? null : BloodTypeExtensions.FromDbString(v)); + builder.Property(p => p.EmergencyContact).HasColumnName("emergency_contact").HasMaxLength(500); + builder.Property(p => p.AllergiesJson).HasColumnName("allergies_json").HasColumnType("jsonb"); + builder.Property(p => p.NoKnownAllergies).HasColumnName("no_known_allergies").HasDefaultValue(false); + builder.Property(p => p.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + builder.Property(p => p.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()"); + + builder.HasIndex(p => p.Mrn).IsUnique().HasDatabaseName("ix_patients_mrn"); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Data/Configurations/DraftEncounterConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/Draft/DraftEncounterConfiguration.cs similarity index 100% rename from VigilCareRecordsAPI/Data/Configurations/DraftEncounterConfiguration.cs rename to VigilCareRecordsAPI/Data/Configurations/Draft/DraftEncounterConfiguration.cs diff --git a/VigilCareRecordsAPI/Data/Configurations/DraftObservationConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/Draft/DraftObservationConfiguration.cs similarity index 100% rename from VigilCareRecordsAPI/Data/Configurations/DraftObservationConfiguration.cs rename to VigilCareRecordsAPI/Data/Configurations/Draft/DraftObservationConfiguration.cs diff --git a/VigilCareRecordsAPI/Data/Configurations/DraftPatientConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/Draft/DraftPatientConfiguration.cs similarity index 100% rename from VigilCareRecordsAPI/Data/Configurations/DraftPatientConfiguration.cs rename to VigilCareRecordsAPI/Data/Configurations/Draft/DraftPatientConfiguration.cs diff --git a/VigilCareRecordsAPI/Data/Migrations/20260626060803_AddIdempotencyRecords.Designer.cs b/VigilCareRecordsAPI/Data/Migrations/20260626060803_AddIdempotencyRecords.Designer.cs new file mode 100644 index 0000000..207e014 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260626060803_AddIdempotencyRecords.Designer.cs @@ -0,0 +1,768 @@ +// +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 VigilCareRecordsAPI.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260626060803_AddIdempotencyRecords")] + partial class AddIdempotencyRecords + { + /// + 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("AuthAuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "OccurredAt"); + + b.ToTable("auth_audit_events", null, t => + { + t.HasCheckConstraint("chk_auth_audit_events_event_type", "event_type IN ('USER_LOGOUT', 'TOKEN_REFRESHED')"); + }); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ApprovedByUserId") + .HasColumnType("uuid") + .HasColumnName("approved_by_user_id"); + + b.Property("BatchType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("batch_type"); + + b.Property("ClinicianAttestation") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("clinician_attestation"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DocumentRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("document_ref"); + + b.Property("DocumentSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("document_sha256"); + + b.Property("EnableRetroactiveAlerts") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("enable_retroactive_alerts"); + + b.Property("EncounterDraftId") + .HasColumnType("uuid") + .HasColumnName("encounter_draft_id"); + + b.Property("EnteredByUserId") + .HasColumnType("uuid") + .HasColumnName("entered_by_user_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("PromotedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("promoted_at"); + + b.Property("PromotionEncounterId") + .HasColumnType("uuid") + .HasColumnName("promotion_encounter_id"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("status") + .HasDefaultValueSql("'UPLOADED'"); + + b.Property("SupersedesBatchId") + .HasColumnType("uuid") + .HasColumnName("supersedes_batch_id"); + + b.Property("Track") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("track") + .HasDefaultValueSql("'BACKFILL'"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("VerifiedByUserId") + .HasColumnType("uuid") + .HasColumnName("verified_by_user_id"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedByUserId"); + + b.HasIndex("EnteredByUserId"); + + b.HasIndex("Status"); + + b.HasIndex("SupersedesBatchId") + .HasFilter("supersedes_batch_id IS NOT NULL"); + + b.HasIndex("VerifiedByUserId"); + + b.HasIndex("DocumentSha256", "PatientId", "CreatedAt"); + + b.ToTable("digitization_batches", null, t => + { + t.HasCheckConstraint("chk_batches_batch_type", "batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')"); + + t.HasCheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED')"); + + t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')"); + }); + }); + + modelBuilder.Entity("DigitizationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("BatchId", "OccurredAt"); + + b.ToTable("digitization_events", null, t => + { + t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed')"); + }); + }); + + modelBuilder.Entity("DraftEncounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("Status") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_encounters", null, t => + { + t.HasCheckConstraint("chk_draft_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("DraftObservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + 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("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("BatchId", "ObservationCode"); + + b.ToTable("draft_observations", (string)null); + }); + + modelBuilder.Entity("DraftPatient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AllergiesJson") + .HasColumnType("jsonb") + .HasColumnName("allergies_json"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContact") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("emergency_contact"); + + b.Property("FullName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("MedicationsJson") + .HasColumnType("jsonb") + .HasColumnName("medications_json"); + + b.Property("NoActiveMedications") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_active_medications"); + + b.Property("NoKnownAllergies") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_known_allergies"); + + b.Property("Sex") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("sex"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_patients", null, t => + { + t.HasCheckConstraint("chk_draft_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + }); + + modelBuilder.Entity("IdempotencyRecord", 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("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("HttpStatusCode") + .HasColumnType("integer") + .HasColumnName("http_status_code"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + b.Property("OperationName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("operation_name"); + + b.Property("ResourceId") + .HasColumnType("uuid") + .HasColumnName("resource_id"); + + b.Property("ResponseBodyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("response_body_json"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_idempotency_records_expires_at"); + + b.HasIndex("IdempotencyKey", "OperationName") + .IsUnique() + .HasDatabaseName("ix_idempotency_records_key_operation"); + + b.ToTable("idempotency_records", (string)null); + }); + + modelBuilder.Entity("RefreshToken", 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("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid") + .HasColumnName("replaced_by_token_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedByTokenId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("ScannedDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("content_type"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint") + .HasColumnName("file_size_bytes"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("object_key"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("sha256"); + + b.Property("UploadedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.HasIndex("Sha256"); + + b.ToTable("scanned_documents", (string)null); + }); + + modelBuilder.Entity("User", 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("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", null, t => + { + t.HasCheckConstraint("chk_users_role", "role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')"); + }); + }); + + modelBuilder.Entity("AuthAuditEvent", b => + { + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.HasOne("User", "ApprovedByUser") + .WithMany() + .HasForeignKey("ApprovedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("User", "EnteredByUser") + .WithMany() + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("User", "VerifiedByUser") + .WithMany() + .HasForeignKey("VerifiedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApprovedByUser"); + + b.Navigation("EnteredByUser"); + + b.Navigation("VerifiedByUser"); + }); + + modelBuilder.Entity("DigitizationEvent", b => + { + b.HasOne("User", "Actor") + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("DigitizationBatch", "Batch") + .WithMany("Events") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Actor"); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftEncounter", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("DraftEncounter") + .HasForeignKey("DraftEncounter", "BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftObservation", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithMany("DraftObservations") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftPatient", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("DraftPatient") + .HasForeignKey("DraftPatient", "BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("RefreshToken", b => + { + b.HasOne("RefreshToken", "ReplacedByToken") + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplacedByToken"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ScannedDocument", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("Document") + .HasForeignKey("ScannedDocument", "BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.Navigation("Document"); + + b.Navigation("DraftEncounter"); + + b.Navigation("DraftObservations"); + + b.Navigation("DraftPatient"); + + b.Navigation("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/20260626060803_AddIdempotencyRecords.cs b/VigilCareRecordsAPI/Data/Migrations/20260626060803_AddIdempotencyRecords.cs new file mode 100644 index 0000000..9488152 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260626060803_AddIdempotencyRecords.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareRecordsAPI.Data.Migrations +{ + /// + public partial class AddIdempotencyRecords : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "idempotency_records", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + idempotency_key = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + operation_name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + resource_id = table.Column(type: "uuid", nullable: false), + http_status_code = table.Column(type: "integer", nullable: false), + response_body_json = table.Column(type: "jsonb", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"), + expires_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_idempotency_records", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_idempotency_records_expires_at", + table: "idempotency_records", + column: "expires_at"); + + migrationBuilder.CreateIndex( + name: "ix_idempotency_records_key_operation", + table: "idempotency_records", + columns: new[] { "idempotency_key", "operation_name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "idempotency_records"); + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/20260626062059_AddClinicalSchemaAndPromotion.Designer.cs b/VigilCareRecordsAPI/Data/Migrations/20260626062059_AddClinicalSchemaAndPromotion.Designer.cs new file mode 100644 index 0000000..6c41756 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260626062059_AddClinicalSchemaAndPromotion.Designer.cs @@ -0,0 +1,1069 @@ +// +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 VigilCareRecordsAPI.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260626062059_AddClinicalSchemaAndPromotion")] + partial class AddClinicalSchemaAndPromotion + { + /// + 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("AuthAuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "OccurredAt"); + + b.ToTable("auth_audit_events", null, t => + { + t.HasCheckConstraint("chk_auth_audit_events_event_type", "event_type IN ('USER_LOGOUT', 'TOKEN_REFRESHED')"); + }); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ApprovedByUserId") + .HasColumnType("uuid") + .HasColumnName("approved_by_user_id"); + + b.Property("BatchType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("batch_type"); + + b.Property("ClinicianAttestation") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("clinician_attestation"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DocumentRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("document_ref"); + + b.Property("DocumentSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("document_sha256"); + + b.Property("EnableRetroactiveAlerts") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("enable_retroactive_alerts"); + + b.Property("EncounterDraftId") + .HasColumnType("uuid") + .HasColumnName("encounter_draft_id"); + + b.Property("EnteredByUserId") + .HasColumnType("uuid") + .HasColumnName("entered_by_user_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("PromotedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("promoted_at"); + + b.Property("PromotionEncounterId") + .HasColumnType("uuid") + .HasColumnName("promotion_encounter_id"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("status") + .HasDefaultValueSql("'UPLOADED'"); + + b.Property("SupersedesBatchId") + .HasColumnType("uuid") + .HasColumnName("supersedes_batch_id"); + + b.Property("Track") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("track") + .HasDefaultValueSql("'BACKFILL'"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("VerifiedByUserId") + .HasColumnType("uuid") + .HasColumnName("verified_by_user_id"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedByUserId"); + + b.HasIndex("EnteredByUserId"); + + b.HasIndex("Status"); + + b.HasIndex("SupersedesBatchId") + .HasFilter("supersedes_batch_id IS NOT NULL"); + + b.HasIndex("VerifiedByUserId"); + + b.HasIndex("DocumentSha256", "PatientId", "CreatedAt"); + + b.ToTable("digitization_batches", null, t => + { + t.HasCheckConstraint("chk_batches_batch_type", "batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')"); + + t.HasCheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED')"); + + t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')"); + }); + }); + + modelBuilder.Entity("DigitizationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("BatchId", "OccurredAt"); + + b.ToTable("digitization_events", null, t => + { + t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed')"); + }); + }); + + modelBuilder.Entity("DraftEncounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("Status") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_encounters", null, t => + { + t.HasCheckConstraint("chk_draft_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("DraftObservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + 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("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("BatchId", "ObservationCode"); + + b.ToTable("draft_observations", (string)null); + }); + + modelBuilder.Entity("DraftPatient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AllergiesJson") + .HasColumnType("jsonb") + .HasColumnName("allergies_json"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContact") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("emergency_contact"); + + b.Property("FullName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("MedicationsJson") + .HasColumnType("jsonb") + .HasColumnName("medications_json"); + + b.Property("NoActiveMedications") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_active_medications"); + + b.Property("NoKnownAllergies") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_known_allergies"); + + b.Property("Sex") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("sex"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_patients", null, t => + { + t.HasCheckConstraint("chk_draft_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("SourceBatchId") + .HasColumnType("uuid") + .HasColumnName("source_batch_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "Status") + .HasDatabaseName("ix_encounters_patient_status"); + + b.ToTable("encounters", "clinical", t => + { + t.HasCheckConstraint("chk_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("IdempotencyRecord", 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("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("HttpStatusCode") + .HasColumnType("integer") + .HasColumnName("http_status_code"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + b.Property("OperationName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("operation_name"); + + b.Property("ResourceId") + .HasColumnType("uuid") + .HasColumnName("resource_id"); + + b.Property("ResponseBodyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("response_body_json"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_idempotency_records_expires_at"); + + b.HasIndex("IdempotencyKey", "OperationName") + .IsUnique() + .HasDatabaseName("ix_idempotency_records_key_operation"); + + b.ToTable("idempotency_records", (string)null); + }); + + 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("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("source"); + + b.Property("SourceBatchId") + .HasColumnType("uuid") + .HasColumnName("source_batch_id"); + + b.Property("SourceDraftObservationId") + .HasColumnType("uuid") + .HasColumnName("source_draft_observation_id"); + + 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("PatientId"); + + b.HasIndex("SourceBatchId") + .HasDatabaseName("ix_observations_source_batch") + .HasFilter("source_batch_id IS NOT NULL"); + + b.HasIndex("EncounterId", "ObservationCode") + .HasDatabaseName("ix_observations_encounter_code"); + + b.ToTable("observations", "clinical"); + }); + + modelBuilder.Entity("OutboxEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregate_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("event_type"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload_json"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("retry_count"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedAt") + .HasDatabaseName("ix_outbox_events_unprocessed") + .HasFilter("processed_at IS NULL"); + + b.ToTable("outbox_events", "clinical"); + }); + + modelBuilder.Entity("Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AllergiesJson") + .HasColumnType("jsonb") + .HasColumnName("allergies_json"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContact") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("emergency_contact"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("Mrn") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mrn"); + + b.Property("NoKnownAllergies") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_known_allergies"); + + b.Property("Sex") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("sex"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("Mrn") + .IsUnique() + .HasDatabaseName("ix_patients_mrn"); + + b.ToTable("patients", "clinical", t => + { + t.HasCheckConstraint("chk_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + }); + + modelBuilder.Entity("RefreshToken", 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("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid") + .HasColumnName("replaced_by_token_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedByTokenId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("ScannedDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("content_type"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint") + .HasColumnName("file_size_bytes"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("object_key"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("sha256"); + + b.Property("UploadedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.HasIndex("Sha256"); + + b.ToTable("scanned_documents", (string)null); + }); + + modelBuilder.Entity("User", 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("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", null, t => + { + t.HasCheckConstraint("chk_users_role", "role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')"); + }); + }); + + modelBuilder.Entity("AuthAuditEvent", b => + { + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.HasOne("User", "ApprovedByUser") + .WithMany() + .HasForeignKey("ApprovedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("User", "EnteredByUser") + .WithMany() + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("User", "VerifiedByUser") + .WithMany() + .HasForeignKey("VerifiedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApprovedByUser"); + + b.Navigation("EnteredByUser"); + + b.Navigation("VerifiedByUser"); + }); + + modelBuilder.Entity("DigitizationEvent", b => + { + b.HasOne("User", "Actor") + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("DigitizationBatch", "Batch") + .WithMany("Events") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Actor"); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftEncounter", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("DraftEncounter") + .HasForeignKey("DraftEncounter", "BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftObservation", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithMany("DraftObservations") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftPatient", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("DraftPatient") + .HasForeignKey("DraftPatient", "BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("Observation", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("RefreshToken", b => + { + b.HasOne("RefreshToken", "ReplacedByToken") + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplacedByToken"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ScannedDocument", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("Document") + .HasForeignKey("ScannedDocument", "BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.Navigation("Document"); + + b.Navigation("DraftEncounter"); + + b.Navigation("DraftObservations"); + + b.Navigation("DraftPatient"); + + b.Navigation("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/20260626062059_AddClinicalSchemaAndPromotion.cs b/VigilCareRecordsAPI/Data/Migrations/20260626062059_AddClinicalSchemaAndPromotion.cs new file mode 100644 index 0000000..11060a8 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260626062059_AddClinicalSchemaAndPromotion.cs @@ -0,0 +1,186 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareRecordsAPI.Data.Migrations +{ + /// + public partial class AddClinicalSchemaAndPromotion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "clinical"); + + migrationBuilder.CreateTable( + name: "outbox_events", + schema: "clinical", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + event_type = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + aggregate_type = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + aggregate_id = table.Column(type: "uuid", nullable: false), + payload_json = table.Column(type: "jsonb", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"), + processed_at = table.Column(type: "timestamp with time zone", nullable: true), + retry_count = table.Column(type: "integer", nullable: false, defaultValue: 0) + }, + constraints: table => + { + table.PrimaryKey("PK_outbox_events", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "patients", + schema: "clinical", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + mrn = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + full_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + date_of_birth = table.Column(type: "date", nullable: true), + sex = table.Column(type: "character varying(10)", maxLength: 10, nullable: true), + blood_type = table.Column(type: "character varying(10)", maxLength: 10, nullable: true), + emergency_contact = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + allergies_json = table.Column(type: "jsonb", nullable: true), + no_known_allergies = table.Column(type: "boolean", nullable: false, defaultValue: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_patients", x => x.id); + table.CheckConstraint("chk_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + + migrationBuilder.CreateTable( + name: "encounters", + schema: "clinical", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + patient_id = table.Column(type: "uuid", nullable: false), + admission_date = table.Column(type: "timestamp with time zone", nullable: true), + department = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + room_bed = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + admission_reason = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + discharge_diagnosis = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + source_batch_id = table.Column(type: "uuid", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_encounters", x => x.id); + table.CheckConstraint("chk_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + table.ForeignKey( + name: "FK_encounters_patients_patient_id", + column: x => x.patient_id, + principalSchema: "clinical", + principalTable: "patients", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "observations", + schema: "clinical", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + encounter_id = table.Column(type: "uuid", nullable: false), + patient_id = table.Column(type: "uuid", nullable: false), + observation_code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + value = table.Column(type: "numeric(10,3)", nullable: false), + unit = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + recorded_at = table.Column(type: "timestamp with time zone", nullable: false), + note = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + source = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + source_draft_observation_id = table.Column(type: "uuid", nullable: true), + source_batch_id = table.Column(type: "uuid", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_observations", x => x.id); + table.ForeignKey( + name: "FK_observations_encounters_encounter_id", + column: x => x.encounter_id, + principalSchema: "clinical", + principalTable: "encounters", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_observations_patients_patient_id", + column: x => x.patient_id, + principalSchema: "clinical", + principalTable: "patients", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "ix_encounters_patient_status", + schema: "clinical", + table: "encounters", + columns: new[] { "patient_id", "status" }); + + migrationBuilder.CreateIndex( + name: "ix_observations_encounter_code", + schema: "clinical", + table: "observations", + columns: new[] { "encounter_id", "observation_code" }); + + migrationBuilder.CreateIndex( + name: "IX_observations_patient_id", + schema: "clinical", + table: "observations", + column: "patient_id"); + + migrationBuilder.CreateIndex( + name: "ix_observations_source_batch", + schema: "clinical", + table: "observations", + column: "source_batch_id", + filter: "source_batch_id IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "ix_outbox_events_unprocessed", + schema: "clinical", + table: "outbox_events", + column: "processed_at", + filter: "processed_at IS NULL"); + + migrationBuilder.CreateIndex( + name: "ix_patients_mrn", + schema: "clinical", + table: "patients", + column: "mrn", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "observations", + schema: "clinical"); + + migrationBuilder.DropTable( + name: "outbox_events", + schema: "clinical"); + + migrationBuilder.DropTable( + name: "encounters", + schema: "clinical"); + + migrationBuilder.DropTable( + name: "patients", + schema: "clinical"); + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/20260626065328_AddMrnSequence.Designer.cs b/VigilCareRecordsAPI/Data/Migrations/20260626065328_AddMrnSequence.Designer.cs new file mode 100644 index 0000000..a6f915e --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260626065328_AddMrnSequence.Designer.cs @@ -0,0 +1,1069 @@ +// +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 VigilCareRecordsAPI.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260626065328_AddMrnSequence")] + partial class AddMrnSequence + { + /// + 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("AuthAuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "OccurredAt"); + + b.ToTable("auth_audit_events", null, t => + { + t.HasCheckConstraint("chk_auth_audit_events_event_type", "event_type IN ('USER_LOGOUT', 'TOKEN_REFRESHED')"); + }); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ApprovedByUserId") + .HasColumnType("uuid") + .HasColumnName("approved_by_user_id"); + + b.Property("BatchType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("batch_type"); + + b.Property("ClinicianAttestation") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("clinician_attestation"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DocumentRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("document_ref"); + + b.Property("DocumentSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("document_sha256"); + + b.Property("EnableRetroactiveAlerts") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("enable_retroactive_alerts"); + + b.Property("EncounterDraftId") + .HasColumnType("uuid") + .HasColumnName("encounter_draft_id"); + + b.Property("EnteredByUserId") + .HasColumnType("uuid") + .HasColumnName("entered_by_user_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("PromotedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("promoted_at"); + + b.Property("PromotionEncounterId") + .HasColumnType("uuid") + .HasColumnName("promotion_encounter_id"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("status") + .HasDefaultValueSql("'UPLOADED'"); + + b.Property("SupersedesBatchId") + .HasColumnType("uuid") + .HasColumnName("supersedes_batch_id"); + + b.Property("Track") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("track") + .HasDefaultValueSql("'BACKFILL'"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("VerifiedByUserId") + .HasColumnType("uuid") + .HasColumnName("verified_by_user_id"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedByUserId"); + + b.HasIndex("EnteredByUserId"); + + b.HasIndex("Status"); + + b.HasIndex("SupersedesBatchId") + .HasFilter("supersedes_batch_id IS NOT NULL"); + + b.HasIndex("VerifiedByUserId"); + + b.HasIndex("DocumentSha256", "PatientId", "CreatedAt"); + + b.ToTable("digitization_batches", null, t => + { + t.HasCheckConstraint("chk_batches_batch_type", "batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')"); + + t.HasCheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED')"); + + t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')"); + }); + }); + + modelBuilder.Entity("DigitizationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("BatchId", "OccurredAt"); + + b.ToTable("digitization_events", null, t => + { + t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed')"); + }); + }); + + modelBuilder.Entity("DraftEncounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("Status") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_encounters", null, t => + { + t.HasCheckConstraint("chk_draft_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("DraftObservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + 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("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("BatchId", "ObservationCode"); + + b.ToTable("draft_observations", (string)null); + }); + + modelBuilder.Entity("DraftPatient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AllergiesJson") + .HasColumnType("jsonb") + .HasColumnName("allergies_json"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContact") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("emergency_contact"); + + b.Property("FullName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("MedicationsJson") + .HasColumnType("jsonb") + .HasColumnName("medications_json"); + + b.Property("NoActiveMedications") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_active_medications"); + + b.Property("NoKnownAllergies") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_known_allergies"); + + b.Property("Sex") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("sex"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_patients", null, t => + { + t.HasCheckConstraint("chk_draft_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("SourceBatchId") + .HasColumnType("uuid") + .HasColumnName("source_batch_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "Status") + .HasDatabaseName("ix_encounters_patient_status"); + + b.ToTable("encounters", "clinical", t => + { + t.HasCheckConstraint("chk_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("IdempotencyRecord", 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("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("HttpStatusCode") + .HasColumnType("integer") + .HasColumnName("http_status_code"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + b.Property("OperationName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("operation_name"); + + b.Property("ResourceId") + .HasColumnType("uuid") + .HasColumnName("resource_id"); + + b.Property("ResponseBodyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("response_body_json"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_idempotency_records_expires_at"); + + b.HasIndex("IdempotencyKey", "OperationName") + .IsUnique() + .HasDatabaseName("ix_idempotency_records_key_operation"); + + b.ToTable("idempotency_records", (string)null); + }); + + 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("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("source"); + + b.Property("SourceBatchId") + .HasColumnType("uuid") + .HasColumnName("source_batch_id"); + + b.Property("SourceDraftObservationId") + .HasColumnType("uuid") + .HasColumnName("source_draft_observation_id"); + + 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("PatientId"); + + b.HasIndex("SourceBatchId") + .HasDatabaseName("ix_observations_source_batch") + .HasFilter("source_batch_id IS NOT NULL"); + + b.HasIndex("EncounterId", "ObservationCode") + .HasDatabaseName("ix_observations_encounter_code"); + + b.ToTable("observations", "clinical"); + }); + + modelBuilder.Entity("OutboxEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregate_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("event_type"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload_json"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("retry_count"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedAt") + .HasDatabaseName("ix_outbox_events_unprocessed") + .HasFilter("processed_at IS NULL"); + + b.ToTable("outbox_events", "clinical"); + }); + + modelBuilder.Entity("Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AllergiesJson") + .HasColumnType("jsonb") + .HasColumnName("allergies_json"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContact") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("emergency_contact"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("Mrn") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mrn"); + + b.Property("NoKnownAllergies") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_known_allergies"); + + b.Property("Sex") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("sex"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("Mrn") + .IsUnique() + .HasDatabaseName("ix_patients_mrn"); + + b.ToTable("patients", "clinical", t => + { + t.HasCheckConstraint("chk_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + }); + + modelBuilder.Entity("RefreshToken", 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("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid") + .HasColumnName("replaced_by_token_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedByTokenId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("ScannedDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("content_type"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint") + .HasColumnName("file_size_bytes"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("object_key"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("sha256"); + + b.Property("UploadedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.HasIndex("Sha256"); + + b.ToTable("scanned_documents", (string)null); + }); + + modelBuilder.Entity("User", 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("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", null, t => + { + t.HasCheckConstraint("chk_users_role", "role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')"); + }); + }); + + modelBuilder.Entity("AuthAuditEvent", b => + { + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.HasOne("User", "ApprovedByUser") + .WithMany() + .HasForeignKey("ApprovedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("User", "EnteredByUser") + .WithMany() + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("User", "VerifiedByUser") + .WithMany() + .HasForeignKey("VerifiedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApprovedByUser"); + + b.Navigation("EnteredByUser"); + + b.Navigation("VerifiedByUser"); + }); + + modelBuilder.Entity("DigitizationEvent", b => + { + b.HasOne("User", "Actor") + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("DigitizationBatch", "Batch") + .WithMany("Events") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Actor"); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftEncounter", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("DraftEncounter") + .HasForeignKey("DraftEncounter", "BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftObservation", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithMany("DraftObservations") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftPatient", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("DraftPatient") + .HasForeignKey("DraftPatient", "BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("Observation", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("RefreshToken", b => + { + b.HasOne("RefreshToken", "ReplacedByToken") + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplacedByToken"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ScannedDocument", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("Document") + .HasForeignKey("ScannedDocument", "BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.Navigation("Document"); + + b.Navigation("DraftEncounter"); + + b.Navigation("DraftObservations"); + + b.Navigation("DraftPatient"); + + b.Navigation("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/20260626065328_AddMrnSequence.cs b/VigilCareRecordsAPI/Data/Migrations/20260626065328_AddMrnSequence.cs new file mode 100644 index 0000000..d6965ea --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260626065328_AddMrnSequence.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareRecordsAPI.Data.Migrations +{ + /// + public partial class AddMrnSequence : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + "CREATE SEQUENCE IF NOT EXISTS clinical.mrn_sequence START WITH 1 INCREMENT BY 1;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DROP SEQUENCE IF EXISTS clinical.mrn_sequence;"); + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs b/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs index 3e01b44..2a68df1 100644 --- a/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs @@ -423,6 +423,332 @@ namespace VigilCareRecordsAPI.Data.Migrations }); }); + modelBuilder.Entity("Encounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("SourceBatchId") + .HasColumnType("uuid") + .HasColumnName("source_batch_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "Status") + .HasDatabaseName("ix_encounters_patient_status"); + + b.ToTable("encounters", "clinical", t => + { + t.HasCheckConstraint("chk_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("IdempotencyRecord", 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("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("HttpStatusCode") + .HasColumnType("integer") + .HasColumnName("http_status_code"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + b.Property("OperationName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("operation_name"); + + b.Property("ResourceId") + .HasColumnType("uuid") + .HasColumnName("resource_id"); + + b.Property("ResponseBodyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("response_body_json"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_idempotency_records_expires_at"); + + b.HasIndex("IdempotencyKey", "OperationName") + .IsUnique() + .HasDatabaseName("ix_idempotency_records_key_operation"); + + b.ToTable("idempotency_records", (string)null); + }); + + 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("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("source"); + + b.Property("SourceBatchId") + .HasColumnType("uuid") + .HasColumnName("source_batch_id"); + + b.Property("SourceDraftObservationId") + .HasColumnType("uuid") + .HasColumnName("source_draft_observation_id"); + + 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("PatientId"); + + b.HasIndex("SourceBatchId") + .HasDatabaseName("ix_observations_source_batch") + .HasFilter("source_batch_id IS NOT NULL"); + + b.HasIndex("EncounterId", "ObservationCode") + .HasDatabaseName("ix_observations_encounter_code"); + + b.ToTable("observations", "clinical"); + }); + + modelBuilder.Entity("OutboxEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregate_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("event_type"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload_json"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("retry_count"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedAt") + .HasDatabaseName("ix_outbox_events_unprocessed") + .HasFilter("processed_at IS NULL"); + + b.ToTable("outbox_events", "clinical"); + }); + + modelBuilder.Entity("Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AllergiesJson") + .HasColumnType("jsonb") + .HasColumnName("allergies_json"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContact") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("emergency_contact"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("Mrn") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mrn"); + + b.Property("NoKnownAllergies") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_known_allergies"); + + b.Property("Sex") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("sex"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("Mrn") + .IsUnique() + .HasDatabaseName("ix_patients_mrn"); + + b.ToTable("patients", "clinical", t => + { + t.HasCheckConstraint("chk_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + }); + modelBuilder.Entity("RefreshToken", b => { b.Property("Id") @@ -663,6 +989,36 @@ namespace VigilCareRecordsAPI.Data.Migrations b.Navigation("Batch"); }); + modelBuilder.Entity("Encounter", b => + { + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("Observation", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + + b.Navigation("Patient"); + }); + modelBuilder.Entity("RefreshToken", b => { b.HasOne("RefreshToken", "ReplacedByToken") diff --git a/VigilCareRecordsAPI/Domain/Entities/Clinical/Encounter.cs b/VigilCareRecordsAPI/Domain/Entities/Clinical/Encounter.cs new file mode 100644 index 0000000..71e602e --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Entities/Clinical/Encounter.cs @@ -0,0 +1,16 @@ +public class Encounter +{ + public Guid Id { get; set; } + public Guid PatientId { get; set; } + public DateTimeOffset? AdmissionDate { get; set; } + public Department? Department { get; set; } + public string? RoomBed { get; set; } + public string? AdmissionReason { get; set; } + public string? DischargeDiagnosis { get; set; } + public string Status { get; set; } = null!; + public Guid? SourceBatchId { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + + public Patient Patient { get; set; } = null!; +} diff --git a/VigilCareRecordsAPI/Domain/Entities/Clinical/IdempotencyRecord.cs b/VigilCareRecordsAPI/Domain/Entities/Clinical/IdempotencyRecord.cs new file mode 100644 index 0000000..7e8708e --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Entities/Clinical/IdempotencyRecord.cs @@ -0,0 +1,11 @@ +public class IdempotencyRecord +{ + public Guid Id { get; set; } + public string IdempotencyKey { get; set; } = null!; + public string OperationName { get; set; } = null!; + public Guid ResourceId { get; set; } + public int HttpStatusCode { get; set; } + public string ResponseBodyJson { get; set; } = null!; + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Domain/Entities/Clinical/Observation.cs b/VigilCareRecordsAPI/Domain/Entities/Clinical/Observation.cs new file mode 100644 index 0000000..b4e7fff --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Entities/Clinical/Observation.cs @@ -0,0 +1,18 @@ +public class Observation +{ + public Guid Id { get; set; } + public Guid EncounterId { get; set; } + public Guid PatientId { get; set; } + public string ObservationCode { get; set; } = null!; + public decimal Value { get; set; } + public string Unit { get; set; } = null!; + public DateTimeOffset RecordedAt { get; set; } + public string? Note { get; set; } + public string Source { get; set; } = null!; + public Guid? SourceDraftObservationId { get; set; } + public Guid? SourceBatchId { get; set; } + public DateTimeOffset CreatedAt { get; set; } + + public Encounter Encounter { get; set; } = null!; + public Patient Patient { get; set; } = null!; +} diff --git a/VigilCareRecordsAPI/Domain/Entities/Clinical/OutboxEvent.cs b/VigilCareRecordsAPI/Domain/Entities/Clinical/OutboxEvent.cs new file mode 100644 index 0000000..4efd88a --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Entities/Clinical/OutboxEvent.cs @@ -0,0 +1,11 @@ +public class OutboxEvent +{ + public Guid Id { get; set; } + public string EventType { get; set; } = null!; + public string AggregateType { get; set; } = null!; + public Guid AggregateId { get; set; } + public string PayloadJson { get; set; } = null!; + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? ProcessedAt { get; set; } + public int RetryCount { get; set; } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Domain/Entities/Clinical/Patient.cs b/VigilCareRecordsAPI/Domain/Entities/Clinical/Patient.cs new file mode 100644 index 0000000..6863d8a --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Entities/Clinical/Patient.cs @@ -0,0 +1,14 @@ +public class Patient +{ + public Guid Id { get; set; } + public string Mrn { get; set; } = null!; + public string FullName { get; set; } = null!; + public DateOnly? DateOfBirth { get; set; } + public string? Sex { get; set; } + public BloodType? BloodType { get; set; } + public string? EmergencyContact { get; set; } + public string? AllergiesJson { get; set; } + public bool NoKnownAllergies { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } +} diff --git a/VigilCareRecordsAPI/Domain/Entities/DraftEncounter.cs b/VigilCareRecordsAPI/Domain/Entities/Draft/DraftEncounter.cs similarity index 100% rename from VigilCareRecordsAPI/Domain/Entities/DraftEncounter.cs rename to VigilCareRecordsAPI/Domain/Entities/Draft/DraftEncounter.cs diff --git a/VigilCareRecordsAPI/Domain/Entities/DraftObservation.cs b/VigilCareRecordsAPI/Domain/Entities/Draft/DraftObservation.cs similarity index 100% rename from VigilCareRecordsAPI/Domain/Entities/DraftObservation.cs rename to VigilCareRecordsAPI/Domain/Entities/Draft/DraftObservation.cs diff --git a/VigilCareRecordsAPI/Domain/Entities/DraftPatient.cs b/VigilCareRecordsAPI/Domain/Entities/Draft/DraftPatient.cs similarity index 100% rename from VigilCareRecordsAPI/Domain/Entities/DraftPatient.cs rename to VigilCareRecordsAPI/Domain/Entities/Draft/DraftPatient.cs diff --git a/VigilCareRecordsAPI/Middlewares/CorrelationIdMiddleware.cs b/VigilCareRecordsAPI/Middlewares/CorrelationIdMiddleware.cs deleted file mode 100644 index e69de29..0000000 diff --git a/VigilCareRecordsAPI/Middlewares/ExceptionHandlerMiddleware.cs b/VigilCareRecordsAPI/Middlewares/ExceptionHandlerMiddleware.cs deleted file mode 100644 index e69de29..0000000 diff --git a/VigilCareRecordsAPI/Models/Records/Promotion/ApproveRequest.cs b/VigilCareRecordsAPI/Models/Records/Promotion/ApproveRequest.cs new file mode 100644 index 0000000..f07133f --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/Promotion/ApproveRequest.cs @@ -0,0 +1,3 @@ +public record ApproveRequest( + bool? EnableRetroactiveAlerts = false +); \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/Promotion/PromotionResultResponse.cs b/VigilCareRecordsAPI/Models/Records/Promotion/PromotionResultResponse.cs new file mode 100644 index 0000000..aeb9a9c --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/Promotion/PromotionResultResponse.cs @@ -0,0 +1,10 @@ +public record PromotionResultResponse( + Guid BatchId, + string Status, + Guid PatientId, + string Mrn, + Guid EncounterId, + Guid[] ObservationIds, + DateTimeOffset PromotedAt, + int OutboxEventsWritten +); diff --git a/VigilCareRecordsAPI/Program.cs b/VigilCareRecordsAPI/Program.cs index ba1f79b..7aaba40 100644 --- a/VigilCareRecordsAPI/Program.cs +++ b/VigilCareRecordsAPI/Program.cs @@ -62,6 +62,9 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); diff --git a/VigilCareRecordsAPI/Services/IdempotencyService.cs b/VigilCareRecordsAPI/Services/IdempotencyService.cs new file mode 100644 index 0000000..1eb7c46 --- /dev/null +++ b/VigilCareRecordsAPI/Services/IdempotencyService.cs @@ -0,0 +1,50 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; + +public class IdempotencyService : IIdempotencyService +{ + private readonly AppDbContext _db; + private static readonly TimeSpan DefaultTtl = TimeSpan.FromHours(24); + + public IdempotencyService(AppDbContext db) + { + _db = db; + } + + public async Task GetExistingAsync(string idempotencyKey, string operationName) + { + var record = await _db.IdempotencyRecords + .FirstOrDefaultAsync(r => + r.IdempotencyKey == idempotencyKey && + r.OperationName == operationName && + r.ExpiresAt > DateTimeOffset.UtcNow); + + return record; + } + + public async Task SaveAsync(string idempotencyKey, string operationName, Guid resourceId, + int httpStatusCode, object responseBody, TimeSpan? ttl = null) + { + var effectiveTtl = ttl ?? DefaultTtl; + var now = DateTimeOffset.UtcNow; + + var record = new IdempotencyRecord + { + Id = Guid.NewGuid(), + IdempotencyKey = idempotencyKey, + OperationName = operationName, + ResourceId = resourceId, + HttpStatusCode = httpStatusCode, + ResponseBodyJson = JsonSerializer.Serialize(responseBody, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }), + CreatedAt = now, + ExpiresAt = now.Add(effectiveTtl) + }; + + _db.IdempotencyRecords.Add(record); + // SaveChanges is called by the caller (within the same transaction) + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/Interfaces/IIdempotencyService.cs b/VigilCareRecordsAPI/Services/Interfaces/IIdempotencyService.cs new file mode 100644 index 0000000..685a88d --- /dev/null +++ b/VigilCareRecordsAPI/Services/Interfaces/IIdempotencyService.cs @@ -0,0 +1,14 @@ +public interface IIdempotencyService +{ + /// + /// Returns the cached response if the key was already used, or null if this is a new key. + /// + Task GetExistingAsync(string idempotencyKey, string operationName); + + /// + /// Stores the result of an operation for future deduplication. + /// Must be called within the same transaction as the operation. + /// + Task SaveAsync(string idempotencyKey, string operationName, Guid resourceId, + int httpStatusCode, object responseBody, TimeSpan? ttl = null); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/Interfaces/IMrnGenerator.cs b/VigilCareRecordsAPI/Services/Interfaces/IMrnGenerator.cs new file mode 100644 index 0000000..1d58f0c --- /dev/null +++ b/VigilCareRecordsAPI/Services/Interfaces/IMrnGenerator.cs @@ -0,0 +1,8 @@ +public interface IMrnGenerator +{ + /// + /// Generates the next unique MRN in the format "VCR-{6-digit padded number}". + /// Uses a PostgreSQL sequence for atomic increment under concurrent access. + /// + Task GenerateNextMrnAsync(); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/Interfaces/IPromotionService.cs b/VigilCareRecordsAPI/Services/Interfaces/IPromotionService.cs new file mode 100644 index 0000000..110c3d6 --- /dev/null +++ b/VigilCareRecordsAPI/Services/Interfaces/IPromotionService.cs @@ -0,0 +1,23 @@ +public interface IPromotionService +{ + /// + /// Approves a verified or awaiting-clinical-approval batch and promotes its draft data + /// into live VigilCareClinical tables within a single atomic transaction. + /// + /// The batch to promote. + /// The user performing the approval (separation-of-duties enforced). + /// + /// If true, outbox events are written for backfill observations so the alert engine processes them. + /// If false (default), backfill observations are silently inserted with no alert path. + /// Live-capture track always writes outbox events regardless of this flag. + /// + /// Optional key for idempotent promotion. If provided and already used, returns cached result. + /// The promotion result with all created live IDs. + Task ApproveAndPromoteAsync( + Guid batchId, Guid approverUserId, bool enableRetroactiveAlerts, string? idempotencyKey); + + /// + /// Returns the promotion result for an already-promoted batch. + /// + Task GetPromotionResultAsync(Guid batchId); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/MrnGenerator.cs b/VigilCareRecordsAPI/Services/MrnGenerator.cs new file mode 100644 index 0000000..13c078c --- /dev/null +++ b/VigilCareRecordsAPI/Services/MrnGenerator.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; + +public class MrnGenerator : IMrnGenerator +{ + private readonly AppDbContext _db; + private const string MrnPrefix = "VCR"; + private const string SequenceName = "clinical.mrn_sequence"; + + public MrnGenerator(AppDbContext db) + { + _db = db; + } + + public async Task GenerateNextMrnAsync() + { + // Use PostgreSQL sequence for atomic, gap-free numbering + var connection = _db.Database.GetDbConnection(); + var wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + await connection.OpenAsync(); + + try + { + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT nextval('{SequenceName}')"; + + // If we're in a transaction, enlist the command + if (_db.Database.CurrentTransaction is not null) + { + command.Transaction = _db.Database.CurrentTransaction.GetDbTransaction(); + } + + var nextVal = (long)(await command.ExecuteScalarAsync())!; + return $"{MrnPrefix}-{nextVal:D6}"; + } + finally + { + if (!wasOpen) + await connection.CloseAsync(); + } + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/PromotionService.cs b/VigilCareRecordsAPI/Services/PromotionService.cs new file mode 100644 index 0000000..f68909e --- /dev/null +++ b/VigilCareRecordsAPI/Services/PromotionService.cs @@ -0,0 +1,392 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; + +public class PromotionService : IPromotionService +{ + private readonly AppDbContext _db; + private readonly IIdempotencyService _idempotency; + private readonly IMrnGenerator _mrnGenerator; + private readonly ILogger _logger; + + public PromotionService( + AppDbContext db, + IIdempotencyService idempotency, + IMrnGenerator mrnGenerator, + ILogger logger) + { + _db = db; + _idempotency = idempotency; + _mrnGenerator = mrnGenerator; + _logger = logger; + } + + public async Task ApproveAndPromoteAsync( + Guid batchId, Guid approverUserId, bool enableRetroactiveAlerts, string? idempotencyKey) + { + // --- Idempotency check (before transaction) --- + if (!string.IsNullOrWhiteSpace(idempotencyKey)) + { + var existing = await _idempotency.GetExistingAsync(idempotencyKey, "batch_promote"); + if (existing is not null) + { + _logger.LogInformation( + "Idempotent replay for batch {BatchId} with key {Key}", + batchId, idempotencyKey); + + return JsonSerializer.Deserialize( + existing.ResponseBodyJson, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + } + } + + // --- Load batch with all draft data --- + var batch = await _db.DigitizationBatches + .Include(b => b.DraftPatient) + .Include(b => b.DraftEncounter) + .Include(b => b.DraftObservations) + .FirstOrDefaultAsync(b => b.Id == batchId); + + if (batch is null) + throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND"); + + // --- Status validation --- + if (batch.Status != BatchStatus.Verified && batch.Status != BatchStatus.AwaitingClinicalApproval) + throw new ConflictException( + $"Batch must be in 'verified' or 'awaiting_clinical_approval' status to approve. Current status: '{batch.Status.ToDbString()}'.", + "ILLEGAL_STATUS_TRANSITION"); + + // --- Separation of duties: approver cannot be the entry clerk --- + if (batch.EnteredByUserId == approverUserId) + throw new ConflictException( + "Separation of duties: the entry clerk cannot approve their own batch.", + "SEPARATION_OF_DUTIES_VIOLATION"); + + // --- Also cannot be the verifier --- + if (batch.VerifiedByUserId == approverUserId) + throw new ConflictException( + "Separation of duties: the verifier cannot also approve the same batch.", + "SEPARATION_OF_DUTIES_VIOLATION"); + + // --- Validate draft data completeness --- + if (batch.DraftPatient is null) + throw new ValidationException("Batch has no draft patient data.", "MISSING_DRAFT_PATIENT"); + + if (string.IsNullOrWhiteSpace(batch.DraftPatient.FullName)) + throw new ValidationException("Draft patient must have a full name.", "INVALID_DRAFT_PATIENT"); + + // --- Begin atomic transaction --- + await using var transaction = await _db.Database.BeginTransactionAsync(); + + try + { + var now = DateTimeOffset.UtcNow; + + // === Step 1: Create or update Patient === + var patient = await CreateOrUpdatePatientAsync(batch.DraftPatient, now); + + // === Step 2: Create or match Encounter === + var encounter = await CreateOrMatchEncounterAsync(batch, patient.Id, now); + + // === Step 3: Insert each DraftObservation as live Observation === + var (observationIds, outboxCount) = await PromoteObservationsAsync( + batch, patient.Id, encounter.Id, enableRetroactiveAlerts, now); + + // === Step 4: Update batch status to Promoted === + batch.Status = BatchStatus.Promoted; + batch.ApprovedByUserId = approverUserId; + batch.PromotedAt = now; + batch.PromotionEncounterId = encounter.Id; + batch.EnableRetroactiveAlerts = enableRetroactiveAlerts; + batch.UpdatedAt = now; + + // === Step 5: Write DigitizationEvent === + _db.DigitizationEvents.Add(new DigitizationEvent + { + Id = Guid.NewGuid(), + BatchId = batchId, + EventType = DigitizationEventType.Promoted, + ActorUserId = approverUserId, + OccurredAt = now, + MetadataJson = JsonSerializer.Serialize(new + { + patientId = patient.Id, + mrn = patient.Mrn, + encounterId = encounter.Id, + observationCount = observationIds.Length, + outboxEventsWritten = outboxCount, + enableRetroactiveAlerts, + track = batch.Track.ToDbString() + }) + }); + + // === Step 6: Store idempotency record (within same transaction) === + var result = new PromotionResultResponse( + BatchId: batchId, + Status: BatchStatus.Promoted.ToDbString(), + PatientId: patient.Id, + Mrn: patient.Mrn, + EncounterId: encounter.Id, + ObservationIds: observationIds, + PromotedAt: now, + OutboxEventsWritten: outboxCount + ); + + if (!string.IsNullOrWhiteSpace(idempotencyKey)) + { + await _idempotency.SaveAsync( + idempotencyKey, "batch_promote", batchId, + 200, result, TimeSpan.FromHours(24)); + } + + // === Step 7: Commit === + await _db.SaveChangesAsync(); + await transaction.CommitAsync(); + + _logger.LogInformation( + "Batch {BatchId} promoted: Patient {PatientId} (MRN {Mrn}), " + + "Encounter {EncounterId}, {ObsCount} observations, {OutboxCount} outbox events", + batchId, patient.Id, patient.Mrn, encounter.Id, + observationIds.Length, outboxCount); + + return result; + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } + + public async Task GetPromotionResultAsync(Guid batchId) + { + var batch = await _db.DigitizationBatches + .FirstOrDefaultAsync(b => b.Id == batchId); + + if (batch is null) + throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND"); + + if (batch.Status != BatchStatus.Promoted) + throw new ConflictException( + $"Batch is not promoted. Current status: '{batch.Status.ToDbString()}'.", + "BATCH_NOT_PROMOTED"); + + // Retrieve the live patient by looking up the encounter + var encounter = await _db.Encounters + .Include(e => e.Patient) + .FirstOrDefaultAsync(e => e.Id == batch.PromotionEncounterId); + + if (encounter is null) + throw new NotFoundException( + "Promotion encounter not found. Data may be inconsistent.", + "PROMOTION_ENCOUNTER_NOT_FOUND"); + + var observationIds = await _db.Observations + .Where(o => o.SourceBatchId == batchId) + .Select(o => o.Id) + .ToArrayAsync(); + + return new PromotionResultResponse( + BatchId: batchId, + Status: batch.Status.ToDbString(), + PatientId: encounter.PatientId, + Mrn: encounter.Patient.Mrn, + EncounterId: encounter.Id, + ObservationIds: observationIds, + PromotedAt: batch.PromotedAt!.Value, + OutboxEventsWritten: 0 // Historical count not stored; use event metadata + ); + } + + // ────────────────────────────────────────────────── + // Private helpers + // ────────────────────────────────────────────────── + + private async Task CreateOrUpdatePatientAsync(DraftPatient draft, DateTimeOffset now) + { + // Attempt to match existing patient by name + DOB (simple dedup) + Patient? existing = null; + + if (draft.DateOfBirth.HasValue) + { + existing = await _db.Patients + .FirstOrDefaultAsync(p => + p.FullName == draft.FullName && + p.DateOfBirth == draft.DateOfBirth); + } + + if (existing is not null) + { + // Update fields that may have new information + existing.Sex = draft.Sex ?? existing.Sex; + existing.BloodType = draft.BloodType ?? existing.BloodType; + existing.EmergencyContact = draft.EmergencyContact ?? existing.EmergencyContact; + existing.AllergiesJson = draft.AllergiesJson ?? existing.AllergiesJson; + existing.NoKnownAllergies = draft.NoKnownAllergies || existing.NoKnownAllergies; + existing.UpdatedAt = now; + + _logger.LogInformation( + "Matched existing patient {PatientId} (MRN {Mrn}) by name + DOB", + existing.Id, existing.Mrn); + + return existing; + } + + // Create new patient with generated MRN + var mrn = await _mrnGenerator.GenerateNextMrnAsync(); + + var patient = new Patient + { + Id = Guid.NewGuid(), + Mrn = mrn, + FullName = draft.FullName!, + DateOfBirth = draft.DateOfBirth, + Sex = draft.Sex, + BloodType = draft.BloodType, + EmergencyContact = draft.EmergencyContact, + AllergiesJson = draft.AllergiesJson, + NoKnownAllergies = draft.NoKnownAllergies, + CreatedAt = now, + UpdatedAt = now + }; + + _db.Patients.Add(patient); + + _logger.LogInformation( + "Created new patient {PatientId} with MRN {Mrn}", + patient.Id, mrn); + + return patient; + } + + private async Task CreateOrMatchEncounterAsync( + DigitizationBatch batch, Guid patientId, DateTimeOffset now) + { + // If the batch has a draft encounter, try to match an active encounter + // for the same patient in the same department + if (batch.DraftEncounter is not null) + { + var existingEncounter = await _db.Encounters + .FirstOrDefaultAsync(e => + e.PatientId == patientId && + e.Department == batch.DraftEncounter.Department && + e.Status == "active" && + e.SourceBatchId != batch.Id); + + if (existingEncounter is not null) + { + _logger.LogInformation( + "Matched existing active encounter {EncounterId} for patient {PatientId}", + existingEncounter.Id, patientId); + + return existingEncounter; + } + } + + // Determine encounter status from draft + var encounterStatus = batch.DraftEncounter?.Status ?? "active"; + if (batch.DraftEncounter?.DischargeDiagnosis is not null) + { + encounterStatus = "discharged"; + } + + var encounter = new Encounter + { + Id = Guid.NewGuid(), + PatientId = patientId, + AdmissionDate = batch.DraftEncounter?.AdmissionDate, + Department = batch.DraftEncounter?.Department, + RoomBed = batch.DraftEncounter?.RoomBed, + AdmissionReason = batch.DraftEncounter?.AdmissionReason, + DischargeDiagnosis = batch.DraftEncounter?.DischargeDiagnosis, + Status = encounterStatus, + SourceBatchId = batch.Id, + CreatedAt = now, + UpdatedAt = now + }; + + _db.Encounters.Add(encounter); + + _logger.LogInformation( + "Created encounter {EncounterId} for patient {PatientId} with status '{Status}'", + encounter.Id, patientId, encounterStatus); + + return encounter; + } + + private async Task<(Guid[] observationIds, int outboxCount)> PromoteObservationsAsync( + DigitizationBatch batch, Guid patientId, Guid encounterId, + bool enableRetroactiveAlerts, DateTimeOffset now) + { + var observationIds = new List(); + var outboxCount = 0; + + // Determine the source label based on batch track + var source = batch.Track == BatchTrack.LiveCapture + ? "live_capture" + : "digitization_backfill"; + + // Determine whether outbox events should be written for this batch + var shouldAlert = batch.Track == BatchTrack.LiveCapture || enableRetroactiveAlerts; + + foreach (var draft in batch.DraftObservations) + { + var observationId = Guid.NewGuid(); + + var observation = new Observation + { + Id = observationId, + EncounterId = encounterId, + PatientId = patientId, + ObservationCode = draft.ObservationCode, + Value = draft.Value, + Unit = draft.Unit, + RecordedAt = draft.RecordedAt, + Note = draft.Note, + Source = source, + SourceDraftObservationId = draft.Id, + SourceBatchId = batch.Id, + CreatedAt = now + }; + + _db.Observations.Add(observation); + observationIds.Add(observationId); + + // Write outbox event only if alerting is enabled for this batch + if (shouldAlert) + { + _db.OutboxEvents.Add(new OutboxEvent + { + Id = Guid.NewGuid(), + EventType = "observation.created", + AggregateType = "Observation", + AggregateId = observationId, + PayloadJson = JsonSerializer.Serialize(new + { + observationId, + encounterId, + patientId, + observationCode = draft.ObservationCode, + value = draft.Value, + unit = draft.Unit, + recordedAt = draft.RecordedAt, + source, + batchId = batch.Id, + track = batch.Track.ToDbString() + }), + CreatedAt = now, + ProcessedAt = null, + RetryCount = 0 + }); + + outboxCount++; + } + } + + _logger.LogInformation( + "Promoted {Count} observations for batch {BatchId}, " + + "{OutboxCount} outbox events (shouldAlert={ShouldAlert}, track={Track})", + observationIds.Count, batch.Id, outboxCount, shouldAlert, batch.Track.ToDbString()); + + return (observationIds.ToArray(), outboxCount); + } +} \ No newline at end of file diff --git a/scripts/run-vigilcare-records-phase-4-verification.sh b/scripts/run-vigilcare-records-phase-4-verification.sh new file mode 100755 index 0000000..f247024 --- /dev/null +++ b/scripts/run-vigilcare-records-phase-4-verification.sh @@ -0,0 +1,667 @@ +#!/usr/bin/env bash +# Runs Phase 4 verification checks from docs/plans/phase-4-plan.md. +# +# Covers promotion to clinical tables, idempotency, separation of duties, +# promotion-result endpoint, and the outbox alert behavior matrix. +# +# Prerequisites: +# docker compose up -d (PostgreSQL + Redis; psql via docker compose exec) +# dotnet ef database update --project VigilCareRecordsAPI +# dotnet run --project VigilCareRecordsAPI +# Phase 1–3 seed data (intake1, entry1, verifier1, approver1 users) +# +# PostgreSQL checks use docker compose exec when the postgres service is running, +# otherwise host psql against VIGILCARE_PG_HOST:VIGILCARE_PG_PORT. +# Environment overrides (same defaults as Phase 1–3 scripts): +# VIGILCARE_API_URL default: http://localhost:5217 +# VIGILCARE_COMPOSE_FILE default: /docker-compose.yml +# VIGILCARE_PG_HOST default: localhost +# VIGILCARE_PG_PORT default: 5437 +# VIGILCARE_PG_DB default: vigilcare_records +# VIGILCARE_PG_USER default: postgres +# VIGILCARE_PG_PASSWORD default: password +# VIGILCARE_SKIP_DB_CHECKS set to 1 to skip PostgreSQL assertions +# VIGILCARE_RECORDED_AT default: 2024-01-15T09:30:00Z + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +FIXTURE_PDF="$SCRIPT_DIR/fixtures/test-scan.pdf" + +API_URL="${VIGILCARE_API_URL:-http://localhost:5217}" +COMPOSE_FILE="${VIGILCARE_COMPOSE_FILE:-$REPO_ROOT/docker-compose.yml}" +COMPOSE=(docker compose -f "$COMPOSE_FILE") +PG_HOST="${VIGILCARE_PG_HOST:-localhost}" +PG_PORT="${VIGILCARE_PG_PORT:-5437}" +PG_DB="${VIGILCARE_PG_DB:-vigilcare_records}" +PG_USER="${VIGILCARE_PG_USER:-postgres}" +PG_PASSWORD="${VIGILCARE_PG_PASSWORD:-password}" +SKIP_DB_CHECKS="${VIGILCARE_SKIP_DB_CHECKS:-0}" + +RECORDED_AT="${VIGILCARE_RECORDED_AT:-2024-01-15T09:30:00Z}" +RECORDED_AT_BP="${VIGILCARE_RECORDED_AT_BP:-2024-01-15T09:31:00Z}" + +PASS_COUNT=0 +FAIL_COUNT=0 +FAILED_TESTS=() + +log() { + printf '%s\n' "$*" +} + +section() { + log "" + log "== $1 ==" +} + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + log " PASS: $1" +} + +fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) + FAILED_TESTS+=("$1") + log " FAIL: $1" +} + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + log "ERROR: required command not found: $cmd" + exit 1 + fi +} + +compose_service_running() { + local service="$1" + "${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -qx "$service" +} + +psql_available() { + [[ "$SKIP_DB_CHECKS" == "1" ]] && return 1 + compose_service_running postgres && return 0 + command -v psql >/dev/null 2>&1 && return 0 + return 1 +} + +psql_query() { + if [[ "$SKIP_DB_CHECKS" == "1" ]]; then + return 1 + fi + if compose_service_running postgres; then + "${COMPOSE[@]}" exec -T postgres \ + psql -U "$PG_USER" -d "$PG_DB" -Atqc "$1" + elif command -v psql >/dev/null 2>&1; then + PGPASSWORD="$PG_PASSWORD" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -Atqc "$1" + else + return 1 + fi +} + +new_idempotency_key() { + if command -v uuidgen >/dev/null 2>&1; then + uuidgen + else + cat /proc/sys/kernel/random/uuid + fi +} + +http_code() { + curl -sS -o /dev/null -w '%{http_code}' "$@" +} + +json_post() { + local url="$1" + local body="$2" + local token="${3:-}" + if [[ -n "$token" ]]; then + curl -sS -X POST "$url" \ + -H "Authorization: Bearer $token" \ + -H 'Content-Type: application/json' \ + -d "$body" + else + curl -sS -X POST "$url" \ + -H 'Content-Type: application/json' \ + -d "$body" + fi +} + +json_put() { + local url="$1" + local body="$2" + local token="$3" + curl -sS -X PUT "$url" \ + -H "Authorization: Bearer $token" \ + -H 'Content-Type: application/json' \ + -d "$body" +} + +login() { + local username="$1" + local password="${2:-password}" + json_post "$API_URL/api/v1/auth/login" \ + "{\"username\":\"$username\",\"password\":\"$password\"}" +} + +extract_data_field() { + local json="$1" + local field="$2" + jq -er ".data.$field // empty" <<<"$json" +} + +extract_error_code() { + local json="$1" + jq -er '.error.code // .extensions.code // empty' <<<"$json" 2>/dev/null || + jq -er '.title // empty' <<<"$json" 2>/dev/null || true +} + +upload_batch() { + local token="$1" + local batch_type="${2:-VITALS_SHEET}" + local track="${3:-BACKFILL}" + local file_path="${4:-$FIXTURE_PDF}" + + curl -sS -X POST "$API_URL/api/v1/digitization-batches" \ + -H "Authorization: Bearer $token" \ + -F "file=@${file_path};type=application/pdf" \ + -F "batchType=$batch_type" \ + -F "track=$track" +} + +assign_batch() { + local token="$1" + local batch_id="$2" + local entry_clerk_id="$3" + + curl -sS -X PATCH "$API_URL/api/v1/digitization-batches/$batch_id/assign" \ + -H "Authorization: Bearer $token" \ + -H 'Content-Type: application/json' \ + -d "{\"entryClerkUserId\":\"$entry_clerk_id\"}" +} + +verify_batch() { + local token="$1" + local batch_id="$2" + local body="$3" + + json_post "$API_URL/api/v1/digitization-batches/$batch_id/verify" "$body" "$token" +} + +approve_batch() { + local token="$1" + local batch_id="$2" + local idempotency_key="$3" + local body="$4" + if [[ -z "$body" ]]; then + body='{"enableRetroactiveAlerts":false}' + fi + + curl -sS -X POST "$API_URL/api/v1/digitization-batches/$batch_id/approve" \ + -H "Authorization: Bearer $token" \ + -H 'Content-Type: application/json' \ + -H "Idempotency-Key: $idempotency_key" \ + -d "$body" +} + +assert_api_reachable() { + local code + code="$(http_code "$API_URL/swagger/index.html" || true)" + if [[ "$code" != "200" ]]; then + log "ERROR: API not reachable at $API_URL (HTTP $code)." + log "Start infrastructure with: docker compose up -d" + log "Apply migrations with: dotnet ef database update --project VigilCareRecordsAPI" + log "Start API with: dotnet run --project VigilCareRecordsAPI" + exit 1 + fi +} + +# Upload, assign, enter draft data, submit, and verify. +# Leaves batch in AWAITING_CLINICAL_APPROVAL (VITALS_SHEET). Prints batch id to stdout. +create_batch_ready_for_approval() { + local intake_token="$1" + local track="${2:-BACKFILL}" + local patient_name="${3:-Test Patient}" + local patient_dob="${4:-1990-05-15}" + local entry_token verifier_token batch_id entry_id patient_json + local verify_json verify_status submit_code + + patient_json="$(jq -nc \ + --arg name "$patient_name" \ + --arg dob "$patient_dob" \ + '{fullName: $name, dateOfBirth: $dob, sex: "M", bloodType: "A+", noKnownAllergies: true}')" + + local upload_json + upload_json="$(upload_batch "$intake_token" "VITALS_SHEET" "$track")" + if [[ "$(jq -er '.success' <<<"$upload_json")" != "true" ]]; then + log "ERROR: failed to upload VITALS_SHEET batch (track=$track)" + return 1 + fi + + batch_id="$(extract_data_field "$upload_json" id)" + entry_id="$(extract_data_field "$(login entry1)" userId)" + assign_batch "$intake_token" "$batch_id" "$entry_id" >/dev/null + + entry_token="$(extract_data_field "$(login entry1)" token)" + + json_put \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \ + "$patient_json" \ + "$entry_token" >/dev/null + + json_put \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/encounter" \ + '{"admissionDate":"2024-01-15T08:00:00Z","department":"General Medicine","roomBed":"301-A","admissionReason":"Routine checkup","status":"active"}' \ + "$entry_token" >/dev/null + + json_post \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ + "{\"observationCode\":\"HR\",\"value\":88,\"unit\":\"bpm\",\"recordedAt\":\"$RECORDED_AT\",\"note\":\"Resting heart rate\"}" \ + "$entry_token" >/dev/null + + json_post \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ + "{\"observationCode\":\"TEMP\",\"value\":37.2,\"unit\":\"C\",\"recordedAt\":\"$RECORDED_AT\",\"note\":\"Oral temperature\"}" \ + "$entry_token" >/dev/null + + json_post \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ + "{\"observationCode\":\"BP_SYS\",\"value\":120,\"unit\":\"mmHg\",\"recordedAt\":\"$RECORDED_AT_BP\"}" \ + "$entry_token" >/dev/null + + submit_code="$(http_code -X POST \ + "$API_URL/api/v1/digitization-batches/$batch_id/submit-for-verification" \ + -H "Authorization: Bearer $entry_token")" + if [[ "$submit_code" != "200" ]]; then + log "ERROR: submit-for-verification failed (HTTP $submit_code)" + return 1 + fi + + verifier_token="$(extract_data_field "$(login verifier1)" token)" + verify_json="$(verify_batch "$verifier_token" "$batch_id" \ + '{"fieldChecks":[{"fieldName":"patient.fullName","status":"ok","note":null},{"fieldName":"observation.heartRate","status":"ok","note":null}],"passed":true}')" + verify_status="$(extract_data_field "$verify_json" status)" + + if [[ "$(jq -er '.success' <<<"$verify_json")" != "true" || "$verify_status" != "AWAITING_CLINICAL_APPROVAL" ]]; then + log "ERROR: verification failed (status=${verify_status:-})" + return 1 + fi + + printf '%s' "$batch_id" +} + +test_backfill_promotion_creates_live_records() { + section "1. Promotion — BACKFILL batch creates live records, zero outbox events" + + local intake_token batch_id approver_token approve_json + local status mrn outbox_written obs_count + + intake_token="$(extract_data_field "$(login intake1)" token)" + batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return + + approver_token="$(extract_data_field "$(login approver1)" token)" + approve_json="$(approve_batch "$approver_token" "$batch_id" "$(new_idempotency_key)" \ + '{"enableRetroactiveAlerts":false}')" + + if [[ "$(jq -er '.success' <<<"$approve_json")" != "true" ]]; then + fail "approve BACKFILL batch returns 200" + return + fi + + status="$(extract_data_field "$approve_json" status)" + mrn="$(extract_data_field "$approve_json" mrn)" + outbox_written="$(extract_data_field "$approve_json" outboxEventsWritten)" + + if [[ "$status" == "PROMOTED" && "$mrn" == VCR-* && "$outbox_written" == "0" ]]; then + pass "BACKFILL promotion returns PROMOTED, VCR-* MRN, outboxEventsWritten=0" + else + fail "BACKFILL promotion returns PROMOTED, VCR-* MRN, outboxEventsWritten=0 (status=$status mrn=$mrn outbox=$outbox_written)" + return + fi + + if ! psql_available; then + log " SKIP: PostgreSQL live-record checks (postgres not reachable)" + return + fi + + obs_count="$(psql_query "SELECT count(*) FROM clinical.observations WHERE source_batch_id = '$batch_id';")" + if [[ "$obs_count" == "3" ]]; then + pass "clinical.observations has 3 rows for promoted batch" + else + fail "clinical.observations has 3 rows for promoted batch (got: ${obs_count:-})" + fi + + local outbox_count + outbox_count="$(psql_query "SELECT count(*) FROM clinical.outbox_events WHERE payload_json::text LIKE '%$batch_id%';")" + if [[ "$outbox_count" == "0" ]]; then + pass "clinical.outbox_events has 0 rows for BACKFILL without retroactive alerts" + else + fail "clinical.outbox_events has 0 rows for BACKFILL without retroactive alerts (got: ${outbox_count:-})" + fi + + local patient_name batch_status promoted_event + patient_name="$(psql_query "SELECT full_name FROM clinical.patients p JOIN clinical.observations o ON o.patient_id = p.id WHERE o.source_batch_id = '$batch_id' LIMIT 1;")" + if [[ "$patient_name" == "Test Patient" ]]; then + pass "clinical.patients row has correct full_name" + else + fail "clinical.patients row has correct full_name (got: ${patient_name:-})" + fi + + batch_status="$(psql_query "SELECT status FROM digitization_batches WHERE id = '$batch_id';")" + if [[ "$batch_status" == "PROMOTED" ]]; then + pass "digitization_batches status is PROMOTED" + else + fail "digitization_batches status is PROMOTED (got: ${batch_status:-})" + fi + + promoted_event="$(psql_query "SELECT count(*) FROM digitization_events WHERE batch_id = '$batch_id' AND event_type = 'promoted';")" + if [[ "$promoted_event" == "1" ]]; then + pass "digitization_events includes promoted event" + else + fail "digitization_events includes promoted event (count=${promoted_event:-})" + fi +} + +test_idempotency() { + section "2. Idempotency — same Idempotency-Key returns identical result, no duplicate rows" + + local intake_token batch_id approver_token idem_key + local first_json second_json first_patient second_patient obs_count + + intake_token="$(extract_data_field "$(login intake1)" token)" + batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return + + approver_token="$(extract_data_field "$(login approver1)" token)" + idem_key="$(new_idempotency_key)" + + first_json="$(approve_batch "$approver_token" "$batch_id" "$idem_key" '{}')" + second_json="$(approve_batch "$approver_token" "$batch_id" "$idem_key" '{}')" + + first_patient="$(extract_data_field "$first_json" patientId)" + second_patient="$(extract_data_field "$second_json" patientId)" + + if [[ -n "$first_patient" && "$first_patient" == "$second_patient" ]]; then + pass "idempotent replay returns identical patientId" + else + fail "idempotent replay returns identical patientId (first=$first_patient second=$second_patient)" + return + fi + + if ! psql_available; then + log " SKIP: idempotency observation count (postgres not reachable)" + return + fi + + obs_count="$(psql_query "SELECT count(*) FROM clinical.observations WHERE source_batch_id = '$batch_id';")" + if [[ "$obs_count" == "3" ]]; then + pass "only one set of observations exists after idempotent replay (3, not 6)" + else + fail "only one set of observations exists after idempotent replay (got: ${obs_count:-})" + fi +} + +test_separation_of_duties() { + section "3. Separation of duties — entry clerk cannot approve (403)" + + local intake_token batch_id entry_token approve_code + + intake_token="$(extract_data_field "$(login intake1)" token)" + batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return + + entry_token="$(extract_data_field "$(login entry1)" token)" + approve_code="$(http_code -X POST \ + "$API_URL/api/v1/digitization-batches/$batch_id/approve" \ + -H "Authorization: Bearer $entry_token" \ + -H 'Content-Type: application/json' \ + -H "Idempotency-Key: $(new_idempotency_key)" \ + -d '{}')" + + if [[ "$approve_code" == "403" ]]; then + pass "entry clerk approve rejected with 403 (wrong role)" + else + fail "entry clerk approve rejected with 403 (wrong role) (http=$approve_code)" + fi +} + +test_missing_idempotency_key() { + section "4. Missing Idempotency-Key — approve returns 400 MISSING_IDEMPOTENCY_KEY" + + local intake_token batch_id approver_token approve_json error_code approve_code + + intake_token="$(extract_data_field "$(login intake1)" token)" + batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return + + approver_token="$(extract_data_field "$(login approver1)" token)" + approve_code="$(http_code -X POST \ + "$API_URL/api/v1/digitization-batches/$batch_id/approve" \ + -H "Authorization: Bearer $approver_token" \ + -H 'Content-Type: application/json' \ + -d '{}')" + approve_json="$(json_post \ + "$API_URL/api/v1/digitization-batches/$batch_id/approve" \ + '{}' "$approver_token")" + error_code="$(extract_error_code "$approve_json")" + + if [[ "$approve_code" == "400" && "$error_code" == "MISSING_IDEMPOTENCY_KEY" ]]; then + pass "approve without Idempotency-Key returns 400 MISSING_IDEMPOTENCY_KEY" + else + fail "approve without Idempotency-Key returns 400 MISSING_IDEMPOTENCY_KEY (http=$approve_code code=${error_code:-})" + fi +} + +test_approve_wrong_status() { + section "5. Status guard — approve on uploaded batch returns 409" + + local intake_token batch_id approver_token approve_json error_code approve_code + + intake_token="$(extract_data_field "$(login intake1)" token)" + batch_id="$(extract_data_field "$(upload_batch "$intake_token")" id)" + + approver_token="$(extract_data_field "$(login approver1)" token)" + approve_json="$(approve_batch "$approver_token" "$batch_id" "$(new_idempotency_key)" '{}')" + approve_code="$(jq -er '(.statusCode // .status // empty)' <<<"$approve_json")" + error_code="$(extract_error_code "$approve_json")" + + if [[ "$approve_code" == "409" && "$error_code" == "ILLEGAL_STATUS_TRANSITION" ]]; then + pass "approve on UPLOADED batch returns 409 ILLEGAL_STATUS_TRANSITION" + else + fail "approve on UPLOADED batch returns 409 ILLEGAL_STATUS_TRANSITION (http=$approve_code code=${error_code:-})" + fi +} + +test_promotion_result_after_promotion() { + section "6. Promotion result — GET returns live IDs after promotion" + + local intake_token batch_id approver_token approve_json + local result_json result_patient approve_patient obs_len + + intake_token="$(extract_data_field "$(login intake1)" token)" + batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return + + approver_token="$(extract_data_field "$(login approver1)" token)" + approve_json="$(approve_batch "$approver_token" "$batch_id" "$(new_idempotency_key)" '{}')" + approve_patient="$(extract_data_field "$approve_json" patientId)" + + result_json="$(curl -sS "$API_URL/api/v1/digitization-batches/$batch_id/promotion-result" \ + -H "Authorization: Bearer $approver_token")" + result_patient="$(extract_data_field "$result_json" patientId)" + obs_len="$(jq -er '.data.observationIds | length' <<<"$result_json")" + + if [[ "$(jq -er '.success' <<<"$result_json")" == "true" && + "$result_patient" == "$approve_patient" && + "$obs_len" == "3" ]]; then + pass "GET promotion-result returns patientId and 3 observationIds after promotion" + else + fail "GET promotion-result returns patientId and 3 observationIds after promotion" + fi +} + +test_promotion_result_not_promoted() { + section "7. Promotion result — GET on non-promoted batch returns 409" + + local intake_token batch_id approver_token result_code + + intake_token="$(extract_data_field "$(login intake1)" token)" + batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return + + approver_token="$(extract_data_field "$(login approver1)" token)" + result_code="$(http_code "$API_URL/api/v1/digitization-batches/$batch_id/promotion-result" \ + -H "Authorization: Bearer $approver_token")" + + if [[ "$result_code" == "409" ]]; then + pass "GET promotion-result before approve returns 409" + else + fail "GET promotion-result before approve returns 409 (http=$result_code)" + fi +} + +test_backfill_retroactive_alerts() { + section "8. Alert matrix — BACKFILL + enableRetroactiveAlerts=true writes outbox events" + + local intake_token batch_id approver_token approve_json outbox_written + + intake_token="$(extract_data_field "$(login intake1)" token)" + batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return + + approver_token="$(extract_data_field "$(login approver1)" token)" + approve_json="$(approve_batch "$approver_token" "$batch_id" "$(new_idempotency_key)" \ + '{"enableRetroactiveAlerts":true}')" + outbox_written="$(extract_data_field "$approve_json" outboxEventsWritten)" + + if [[ "$outbox_written" == "3" ]]; then + pass "BACKFILL with enableRetroactiveAlerts=true writes 3 outbox events" + else + fail "BACKFILL with enableRetroactiveAlerts=true writes 3 outbox events (got: ${outbox_written:-})" + return + fi + + if ! psql_available; then + log " SKIP: outbox event payload checks (postgres not reachable)" + return + fi + + local outbox_count event_type processed + outbox_count="$(psql_query "SELECT count(*) FROM clinical.outbox_events WHERE payload_json::text LIKE '%$batch_id%';")" + event_type="$(psql_query "SELECT DISTINCT event_type FROM clinical.outbox_events WHERE payload_json::text LIKE '%$batch_id%';")" + processed="$(psql_query "SELECT count(*) FROM clinical.outbox_events WHERE payload_json::text LIKE '%$batch_id%' AND processed_at IS NOT NULL;")" + + if [[ "$outbox_count" == "3" && "$event_type" == "observation.created" && "$processed" == "0" ]]; then + pass "outbox rows are observation.created with processed_at IS NULL" + else + fail "outbox rows are observation.created with processed_at IS NULL (count=$outbox_count type=$event_type processed=$processed)" + fi +} + +test_live_capture_always_alerts() { + section "9. Alert matrix — LIVE_CAPTURE always writes outbox events" + + local intake_token batch_id approver_token approve_json outbox_written source + + intake_token="$(extract_data_field "$(login intake1)" token)" + batch_id="$(create_batch_ready_for_approval "$intake_token" "LIVE_CAPTURE")" || return + + approver_token="$(extract_data_field "$(login approver1)" token)" + approve_json="$(approve_batch "$approver_token" "$batch_id" "$(new_idempotency_key)" \ + '{"enableRetroactiveAlerts":false}')" + outbox_written="$(extract_data_field "$approve_json" outboxEventsWritten)" + + if [[ "$outbox_written" == "3" ]]; then + pass "LIVE_CAPTURE with enableRetroactiveAlerts=false still writes 3 outbox events" + else + fail "LIVE_CAPTURE with enableRetroactiveAlerts=false still writes 3 outbox events (got: ${outbox_written:-})" + return + fi + + if ! psql_available; then + log " SKIP: live_capture source check (postgres not reachable)" + return + fi + + source="$(psql_query "SELECT DISTINCT source FROM clinical.observations WHERE source_batch_id = '$batch_id';")" + if [[ "$source" == "live_capture" ]]; then + pass "LIVE_CAPTURE observations have source=live_capture" + else + fail "LIVE_CAPTURE observations have source=live_capture (got: ${source:-})" + fi +} + +test_sequential_mrns() { + section "10. MRN generation — sequential unique VCR-* values across promotions" + + local intake_token batch_id1 batch_id2 approver_token + local approve1 approve2 mrn1 mrn2 num1 num2 + + intake_token="$(extract_data_field "$(login intake1)" token)" + batch_id1="$(create_batch_ready_for_approval "$intake_token" "BACKFILL" "MRN Test Patient A" "1988-01-10")" || return + batch_id2="$(create_batch_ready_for_approval "$intake_token" "BACKFILL" "MRN Test Patient B" "1992-06-20")" || return + + approver_token="$(extract_data_field "$(login approver1)" token)" + + approve1="$(approve_batch "$approver_token" "$batch_id1" "$(new_idempotency_key)" '{}')" + approve2="$(approve_batch "$approver_token" "$batch_id2" "$(new_idempotency_key)" '{}')" + + mrn1="$(extract_data_field "$approve1" mrn)" + mrn2="$(extract_data_field "$approve2" mrn)" + + if [[ "$mrn1" == VCR-* && "$mrn2" == VCR-* && "$mrn1" != "$mrn2" ]]; then + pass "two promotions produce unique VCR-* MRNs" + else + fail "two promotions produce unique VCR-* MRNs (mrn1=$mrn1 mrn2=$mrn2)" + return + fi + + num1="${mrn1#VCR-}" + num2="${mrn2#VCR-}" + if [[ "$num2" -gt "$num1" ]]; then + pass "second MRN is numerically greater than first (sequential sequence)" + else + fail "second MRN is numerically greater than first (mrn1=$mrn1 mrn2=$mrn2)" + fi +} + +main() { + require_cmd curl + require_cmd jq + require_cmd docker + + if [[ ! -f "$FIXTURE_PDF" ]]; then + log "ERROR: missing fixture PDF at $FIXTURE_PDF" + exit 1 + fi + + log "VigilCare Records — Phase 4 verification" + log "API: $API_URL" + if compose_service_running postgres; then + log "PostgreSQL: docker compose exec (service: postgres)" + elif command -v psql >/dev/null 2>&1; then + log "PostgreSQL: host psql ($PG_HOST:$PG_PORT)" + fi + + assert_api_reachable + + test_backfill_promotion_creates_live_records + test_idempotency + test_separation_of_duties + test_missing_idempotency_key + test_approve_wrong_status + test_promotion_result_after_promotion + test_promotion_result_not_promoted + test_backfill_retroactive_alerts + test_live_capture_always_alerts + test_sequential_mrns + + log "" + log "Results: $PASS_COUNT passed, $FAIL_COUNT failed" + if (( FAIL_COUNT > 0 )); then + log "Failed checks:" + for item in "${FAILED_TESTS[@]}"; do + log " - $item" + done + exit 1 + fi + + log "All Phase 4 verification checks passed." +} + +main "$@"