feature: Approval and Promotion to VigilCareClinical

This commit is contained in:
voltsrage
2026-06-26 15:05:02 +08:00
parent 470df683dd
commit 706318e5d2
40 changed files with 5639 additions and 3 deletions
@@ -0,0 +1,144 @@
using System.Net.Http.Json;
using System.Text.Json;
public static class BatchPipelineHelper
{
/// <summary>
/// Drives a batch from upload through to verified status, returning the batch ID.
/// Uses intake1 for upload, entry1 for data entry, verifier1 for verification.
/// </summary>
public static async Task<Guid> 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<JsonElement>();
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<FieldCheck>
{
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<Guid> GetUserIdAsync(ApiFixture fixture, string username)
{
var client = await AuthHelper.LoginAsync(fixture, username);
var meResponse = await client.GetFromJsonAsync<JsonElement>("/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);
}
}
@@ -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;
");
}
+448
View File
@@ -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<AppDbContext>();
await DbResetHelper.ResetAsync(db);
await DataSeeder.SeedAsync(db);
_client = _fixture.CreateClient();
}
public Task DisposeAsync() => Task.CompletedTask;
/// <summary>
/// Full pipeline: upload → entry → verify → approve → live observations exist, zero alerts for backfill.
/// </summary>
[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<ApiResponse<PromotionResultResponse>>();
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<AppDbContext>();
// 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");
}
/// <summary>
/// Backfill batch with enableRetroactiveAlerts=true should write outbox events for all observations.
/// </summary>
[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<ApiResponse<PromotionResultResponse>>();
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<AppDbContext>();
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");
}
/// <summary>
/// Live-capture track always writes outbox events regardless of enableRetroactiveAlerts flag.
/// </summary>
[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<ApiResponse<PromotionResultResponse>>();
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<AppDbContext>();
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");
}
/// <summary>
/// Idempotent promotion: approve twice with same Idempotency-Key produces one set of live rows.
/// </summary>
[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<ApiResponse<PromotionResultResponse>>();
// 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<ApiResponse<PromotionResultResponse>>();
// 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<AppDbContext>();
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");
}
/// <summary>
/// Separation of duties: entry clerk cannot approve their own batch.
/// </summary>
[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");
}
/// <summary>
/// Missing Idempotency-Key header returns 400.
/// </summary>
[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<ApiResponse<object>>();
result!.Error!.Code.Should().Be("MISSING_IDEMPOTENCY_KEY");
}
/// <summary>
/// Batch in wrong status (e.g., uploaded) returns 409.
/// </summary>
[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<JsonElement>();
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");
}
/// <summary>
/// GET /promotion-result returns correct data after promotion.
/// </summary>
[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<ApiResponse<PromotionResultResponse>>();
_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<ApiResponse<PromotionResultResponse>>();
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);
}
/// <summary>
/// GET /promotion-result on non-promoted batch returns 409.
/// </summary>
[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);
}
/// <summary>
/// MRN generation produces unique, sequential values.
/// </summary>
[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<ApiResponse<PromotionResultResponse>>())!.Data!;
var promotion2 = (await result2.Content
.ReadFromJsonAsync<ApiResponse<PromotionResultResponse>>())!.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);
}
}
@@ -0,0 +1,86 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
/// <summary>
/// Approval and promotion of verified digitization batches to VigilCareClinical live tables.
/// </summary>
[ApiController]
[Route("api/v1/digitization-batches")]
[Produces("application/json")]
[Authorize]
public class ApprovalController : ControllerBase
{
private readonly IPromotionService _promotion;
private readonly ILogger<ApprovalController> _logger;
public ApprovalController(IPromotionService promotion, ILogger<ApprovalController> logger)
{
_promotion = promotion;
_logger = logger;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="id">The batch ID to approve.</param>
/// <param name="request">Optional request body with alert configuration.</param>
/// <returns>Promotion result with live IDs for patient, encounter, and observations.</returns>
[HttpPost("{id:guid}/approve")]
[Authorize(Roles = "CLINICAL_APPROVER,ADMINISTRATOR")]
[ProducesResponseType(typeof(ApiResponse<PromotionResultResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Approve(Guid id, [FromBody] ApproveRequest? request)
{
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(idempotencyKey))
return BadRequest(ApiResponse<object>.Fail(400,
"Idempotency-Key header is required for promotion operations.",
"MISSING_IDEMPOTENCY_KEY"));
if (idempotencyKey.Length > 100)
return BadRequest(ApiResponse<object>.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<PromotionResultResponse>.Ok(result));
}
/// <summary>
/// Returns the promotion result for an already-promoted batch, including the live IDs
/// for Patient, Encounter, and Observations that were created during promotion.
/// </summary>
/// <param name="id">The batch ID to query.</param>
/// <returns>Promotion result with live clinical entity IDs.</returns>
[HttpGet("{id:guid}/promotion-result")]
[Authorize]
[ProducesResponseType(typeof(ApiResponse<PromotionResultResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
public async Task<IActionResult> GetPromotionResult(Guid id)
{
var result = await _promotion.GetPromotionResultAsync(id);
return Ok(ApiResponse<PromotionResultResponse>.Ok(result));
}
}
+5
View File
@@ -13,6 +13,11 @@ public class AppDbContext : DbContext
public DbSet<DigitizationEvent> DigitizationEvents => Set<DigitizationEvent>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<AuthAuditEvent> AuthAuditEvents => Set<AuthAuditEvent>();
public DbSet<IdempotencyRecord> IdempotencyRecords => Set<IdempotencyRecord>();
public DbSet<Patient> Patients => Set<Patient>();
public DbSet<Encounter> Encounters => Set<Encounter>();
public DbSet<Observation> Observations => Set<Observation>();
public DbSet<OutboxEvent> OutboxEvents => Set<OutboxEvent>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class EncounterConfiguration : IEntityTypeConfiguration<Encounter>
{
public void Configure(EntityTypeBuilder<Encounter> 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");
}
}
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class IdempotencyRecordConfiguration : IEntityTypeConfiguration<IdempotencyRecord>
{
public void Configure(EntityTypeBuilder<IdempotencyRecord> 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");
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ObservationConfiguration : IEntityTypeConfiguration<Observation>
{
public void Configure(EntityTypeBuilder<Observation> 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");
}
}
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class OutboxEventConfiguration : IEntityTypeConfiguration<OutboxEvent>
{
public void Configure(EntityTypeBuilder<OutboxEvent> 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");
}
}
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class PatientConfiguration : IEntityTypeConfiguration<Patient>
{
public void Configure(EntityTypeBuilder<Patient> 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");
}
}
@@ -0,0 +1,768 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace VigilCareRecordsAPI.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260626060803_AddIdempotencyRecords")]
partial class AddIdempotencyRecords
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AuthAuditEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("event_type");
b.Property<string>("MetadataJson")
.HasColumnType("jsonb")
.HasColumnName("metadata_json");
b.Property<DateTimeOffset>("OccurredAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("occurred_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("uuid")
.HasColumnName("approved_by_user_id");
b.Property<string>("BatchType")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("batch_type");
b.Property<bool>("ClinicianAttestation")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("clinician_attestation");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("DocumentRef")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("document_ref");
b.Property<string>("DocumentSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("document_sha256");
b.Property<bool>("EnableRetroactiveAlerts")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("enable_retroactive_alerts");
b.Property<Guid?>("EncounterDraftId")
.HasColumnType("uuid")
.HasColumnName("encounter_draft_id");
b.Property<Guid?>("EnteredByUserId")
.HasColumnType("uuid")
.HasColumnName("entered_by_user_id");
b.Property<Guid?>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("PromotedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("promoted_at");
b.Property<Guid?>("PromotionEncounterId")
.HasColumnType("uuid")
.HasColumnName("promotion_encounter_id");
b.Property<string>("RejectionReason")
.HasColumnType("text")
.HasColumnName("rejection_reason");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("status")
.HasDefaultValueSql("'UPLOADED'");
b.Property<Guid?>("SupersedesBatchId")
.HasColumnType("uuid")
.HasColumnName("supersedes_batch_id");
b.Property<string>("Track")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("track")
.HasDefaultValueSql("'BACKFILL'");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("ActorUserId")
.HasColumnType("uuid")
.HasColumnName("actor_user_id");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("event_type");
b.Property<string>("MetadataJson")
.HasColumnType("jsonb")
.HasColumnName("metadata_json");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("AdmissionDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("admission_date");
b.Property<string>("AdmissionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("admission_reason");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Department")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("department");
b.Property<string>("DischargeDiagnosis")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("discharge_diagnosis");
b.Property<string>("RoomBed")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("room_bed");
b.Property<string>("Status")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("note");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal>("Value")
.HasColumnType("decimal(10,3)")
.HasColumnName("value");
b.HasKey("Id");
b.HasIndex("BatchId", "ObservationCode");
b.ToTable("draft_observations", (string)null);
});
modelBuilder.Entity("DraftPatient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("AllergiesJson")
.HasColumnType("jsonb")
.HasColumnName("allergies_json");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("BloodType")
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("blood_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateOnly?>("DateOfBirth")
.HasColumnType("date")
.HasColumnName("date_of_birth");
b.Property<string>("EmergencyContact")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("emergency_contact");
b.Property<string>("FullName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("full_name");
b.Property<string>("MedicationsJson")
.HasColumnType("jsonb")
.HasColumnName("medications_json");
b.Property<bool>("NoActiveMedications")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("no_active_medications");
b.Property<bool>("NoKnownAllergies")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("no_known_allergies");
b.Property<string>("Sex")
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("sex");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<int>("HttpStatusCode")
.HasColumnType("integer")
.HasColumnName("http_status_code");
b.Property<string>("IdempotencyKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("idempotency_key");
b.Property<string>("OperationName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("operation_name");
b.Property<Guid>("ResourceId")
.HasColumnType("uuid")
.HasColumnName("resource_id");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<Guid?>("ReplacedByTokenId")
.HasColumnType("uuid")
.HasColumnName("replaced_by_token_id");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("token_hash");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("content_type");
b.Property<long>("FileSizeBytes")
.HasColumnType("bigint")
.HasColumnName("file_size_bytes");
b.Property<string>("ObjectKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("object_key");
b.Property<string>("Sha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("sha256");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("full_name");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("is_active");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("password_hash");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("role");
b.Property<string>("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
}
}
}
@@ -0,0 +1,51 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareRecordsAPI.Data.Migrations
{
/// <inheritdoc />
public partial class AddIdempotencyRecords : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "idempotency_records",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
idempotency_key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
operation_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
resource_id = table.Column<Guid>(type: "uuid", nullable: false),
http_status_code = table.Column<int>(type: "integer", nullable: false),
response_body_json = table.Column<string>(type: "jsonb", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
expires_at = table.Column<DateTimeOffset>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "idempotency_records");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,186 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareRecordsAPI.Data.Migrations
{
/// <inheritdoc />
public partial class AddClinicalSchemaAndPromotion : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "clinical");
migrationBuilder.CreateTable(
name: "outbox_events",
schema: "clinical",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
event_type = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
aggregate_type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
aggregate_id = table.Column<Guid>(type: "uuid", nullable: false),
payload_json = table.Column<string>(type: "jsonb", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
processed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
retry_count = table.Column<int>(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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
mrn = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
full_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
date_of_birth = table.Column<DateOnly>(type: "date", nullable: true),
sex = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: true),
blood_type = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: true),
emergency_contact = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
allergies_json = table.Column<string>(type: "jsonb", nullable: true),
no_known_allergies = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
updated_at = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
admission_date = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
department = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
room_bed = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
admission_reason = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
discharge_diagnosis = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
source_batch_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
updated_at = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
value = table.Column<decimal>(type: "numeric(10,3)", nullable: false),
unit = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
recorded_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
note = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
source = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
source_draft_observation_id = table.Column<Guid>(type: "uuid", nullable: true),
source_batch_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<DateTimeOffset>(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);
}
/// <inheritdoc />
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");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareRecordsAPI.Data.Migrations
{
/// <inheritdoc />
public partial class AddMrnSequence : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"CREATE SEQUENCE IF NOT EXISTS clinical.mrn_sequence START WITH 1 INCREMENT BY 1;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DROP SEQUENCE IF EXISTS clinical.mrn_sequence;");
}
}
}
@@ -423,6 +423,332 @@ namespace VigilCareRecordsAPI.Data.Migrations
});
});
modelBuilder.Entity("Encounter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("AdmissionDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("admission_date");
b.Property<string>("AdmissionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("admission_reason");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Department")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("department");
b.Property<string>("DischargeDiagnosis")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("discharge_diagnosis");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<string>("RoomBed")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("room_bed");
b.Property<Guid?>("SourceBatchId")
.HasColumnType("uuid")
.HasColumnName("source_batch_id");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<int>("HttpStatusCode")
.HasColumnType("integer")
.HasColumnName("http_status_code");
b.Property<string>("IdempotencyKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("idempotency_key");
b.Property<string>("OperationName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("operation_name");
b.Property<Guid>("ResourceId")
.HasColumnType("uuid")
.HasColumnName("resource_id");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("note");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("source");
b.Property<Guid?>("SourceBatchId")
.HasColumnType("uuid")
.HasColumnName("source_batch_id");
b.Property<Guid?>("SourceDraftObservationId")
.HasColumnType("uuid")
.HasColumnName("source_draft_observation_id");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal>("Value")
.HasColumnType("decimal(10,3)")
.HasColumnName("value");
b.HasKey("Id");
b.HasIndex("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("AggregateId")
.HasColumnType("uuid")
.HasColumnName("aggregate_id");
b.Property<string>("AggregateType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("aggregate_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("event_type");
b.Property<string>("PayloadJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("payload_json");
b.Property<DateTimeOffset?>("ProcessedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_at");
b.Property<int>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("AllergiesJson")
.HasColumnType("jsonb")
.HasColumnName("allergies_json");
b.Property<string>("BloodType")
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("blood_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateOnly?>("DateOfBirth")
.HasColumnType("date")
.HasColumnName("date_of_birth");
b.Property<string>("EmergencyContact")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("emergency_contact");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("full_name");
b.Property<string>("Mrn")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("mrn");
b.Property<bool>("NoKnownAllergies")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("no_known_allergies");
b.Property<string>("Sex")
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("sex");
b.Property<DateTimeOffset>("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<Guid>("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")
@@ -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!;
}
@@ -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; }
}
@@ -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!;
}
@@ -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; }
}
@@ -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; }
}
@@ -0,0 +1,3 @@
public record ApproveRequest(
bool? EnableRetroactiveAlerts = false
);
@@ -0,0 +1,10 @@
public record PromotionResultResponse(
Guid BatchId,
string Status,
Guid PatientId,
string Mrn,
Guid EncounterId,
Guid[] ObservationIds,
DateTimeOffset PromotedAt,
int OutboxEventsWritten
);
+3
View File
@@ -62,6 +62,9 @@ try
builder.Services.AddScoped<IDocumentStorageService, DocumentStorageService>();
builder.Services.AddScoped<IVerificationService, VerificationService>();
builder.Services.AddScoped<IWorkQueueService, WorkQueueService>();
builder.Services.AddScoped<IPromotionService, PromotionService>();
builder.Services.AddScoped<IIdempotencyService, IdempotencyService>();
builder.Services.AddScoped<IMrnGenerator, MrnGenerator>();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
@@ -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<IdempotencyRecord?> 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)
}
}
@@ -0,0 +1,14 @@
public interface IIdempotencyService
{
/// <summary>
/// Returns the cached response if the key was already used, or null if this is a new key.
/// </summary>
Task<IdempotencyRecord?> GetExistingAsync(string idempotencyKey, string operationName);
/// <summary>
/// Stores the result of an operation for future deduplication.
/// Must be called within the same transaction as the operation.
/// </summary>
Task SaveAsync(string idempotencyKey, string operationName, Guid resourceId,
int httpStatusCode, object responseBody, TimeSpan? ttl = null);
}
@@ -0,0 +1,8 @@
public interface IMrnGenerator
{
/// <summary>
/// Generates the next unique MRN in the format "VCR-{6-digit padded number}".
/// Uses a PostgreSQL sequence for atomic increment under concurrent access.
/// </summary>
Task<string> GenerateNextMrnAsync();
}
@@ -0,0 +1,23 @@
public interface IPromotionService
{
/// <summary>
/// Approves a verified or awaiting-clinical-approval batch and promotes its draft data
/// into live VigilCareClinical tables within a single atomic transaction.
/// </summary>
/// <param name="batchId">The batch to promote.</param>
/// <param name="approverUserId">The user performing the approval (separation-of-duties enforced).</param>
/// <param name="enableRetroactiveAlerts">
/// 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.
/// </param>
/// <param name="idempotencyKey">Optional key for idempotent promotion. If provided and already used, returns cached result.</param>
/// <returns>The promotion result with all created live IDs.</returns>
Task<PromotionResultResponse> ApproveAndPromoteAsync(
Guid batchId, Guid approverUserId, bool enableRetroactiveAlerts, string? idempotencyKey);
/// <summary>
/// Returns the promotion result for an already-promoted batch.
/// </summary>
Task<PromotionResultResponse> GetPromotionResultAsync(Guid batchId);
}
@@ -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<string> 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();
}
}
}
@@ -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<PromotionService> _logger;
public PromotionService(
AppDbContext db,
IIdempotencyService idempotency,
IMrnGenerator mrnGenerator,
ILogger<PromotionService> logger)
{
_db = db;
_idempotency = idempotency;
_mrnGenerator = mrnGenerator;
_logger = logger;
}
public async Task<PromotionResultResponse> 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<PromotionResultResponse>(
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<PromotionResultResponse> 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<Patient> 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<Encounter> 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<Guid>();
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);
}
}
+667
View File
@@ -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 13 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 13 scripts):
# VIGILCARE_API_URL default: http://localhost:5217
# VIGILCARE_COMPOSE_FILE default: <repo>/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:-<none>})"
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:-<none>})"
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:-<none>})"
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:-<none>})"
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:-<none>})"
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:-<none>})"
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:-<none>})"
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:-<none>})"
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:-<none>})"
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:-<none>})"
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:-<none>})"
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:-<none>})"
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 "$@"