diff --git a/VigilCareRecords.sln b/VigilCareRecords.sln index f537cdb..48396bf 100644 --- a/VigilCareRecords.sln +++ b/VigilCareRecords.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCareRecordsAPI", "VigilCareRecordsAPI\VigilCareRecordsAPI.csproj", "{3975B2F5-259E-43BF-B1F3-FA08AF8EF90C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCareRecordsAPI.Tests", "VigilCareRecordsAPI.Tests\VigilCareRecordsAPI.Tests.csproj", "{B2ADB8DD-DE24-4E24-A98A-2486F272304C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -18,5 +20,9 @@ Global {3975B2F5-259E-43BF-B1F3-FA08AF8EF90C}.Debug|Any CPU.Build.0 = Debug|Any CPU {3975B2F5-259E-43BF-B1F3-FA08AF8EF90C}.Release|Any CPU.ActiveCfg = Release|Any CPU {3975B2F5-259E-43BF-B1F3-FA08AF8EF90C}.Release|Any CPU.Build.0 = Release|Any CPU + {B2ADB8DD-DE24-4E24-A98A-2486F272304C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2ADB8DD-DE24-4E24-A98A-2486F272304C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2ADB8DD-DE24-4E24-A98A-2486F272304C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2ADB8DD-DE24-4E24-A98A-2486F272304C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/VigilCareRecordsAPI.Tests/DraftEntryTests.cs b/VigilCareRecordsAPI.Tests/DraftEntryTests.cs new file mode 100644 index 0000000..ddcc98e --- /dev/null +++ b/VigilCareRecordsAPI.Tests/DraftEntryTests.cs @@ -0,0 +1,383 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +public class DraftEntryTests : IClassFixture, IAsyncLifetime +{ + private readonly ApiFixture _fixture; + private HttpClient _client = null!; + private Guid _batchId; + + public DraftEntryTests(ApiFixture fixture) + { + _fixture = fixture; + } + + public async Task InitializeAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + + // Re-seed users — truncation removed them + await DataSeeder.SeedAsync(db); + + // Create a batch directly in the database in UPLOADED status. + // We skip the MinIO upload path because integration tests focus on + // draft entry logic, not document storage (covered in Phase 1 tests). + var batch = new DigitizationBatch + { + Id = Guid.NewGuid(), + Status = BatchStatus.Uploaded, + BatchType = BatchType.VitalsSheet, + Track = BatchTrack.Backfill, + DocumentRef = "test/scan.pdf", + DocumentSha256 = "abc123def456", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + db.DigitizationBatches.Add(batch); + + // Seed a ScannedDocument to satisfy the foreign key + db.ScannedDocuments.Add(new ScannedDocument + { + Id = Guid.NewGuid(), + BatchId = batch.Id, + ObjectKey = "test/scan.pdf", + Sha256 = "abc123def456", + ContentType = "application/pdf", + FileSizeBytes = 1024, + UploadedAt = DateTimeOffset.UtcNow + }); + + await db.SaveChangesAsync(); + _batchId = batch.Id; + + // Authenticate as entry clerk + _client = await AuthHelper.LoginAsync(_fixture, "entry1"); + } + + public Task DisposeAsync() => Task.CompletedTask; + + // ─── Test 1: Incomplete vitals batch cannot submit ──────────── + + [Fact] + public async Task IncompleteVitalsBatch_CannotSubmit_Returns422() + { + // Attempt to submit an empty batch — no patient, no encounter, no observations + // First, we need to get it into IN_ENTRY status by saving something + await _client.PutAsJsonAsync( + $"/api/v1/digitization-batches/{_batchId}/draft/patient", + new { fullName = "Test Patient" }); + + // Now attempt to submit — should fail because: + // - Patient missing DOB and sex (but we only need patient for vitals) + // - No encounter context + // - No observations + var submitResp = await _client.PostAsync( + $"/api/v1/digitization-batches/{_batchId}/submit-for-verification", null); + + submitResp.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + + var body = await submitResp.Content.ReadFromJsonAsync(); + var errorCode = body!.RootElement + .GetProperty("error").GetProperty("code").GetString(); + errorCode.Should().Be("BATCH_INCOMPLETE"); + + var errorMsg = body.RootElement + .GetProperty("error").GetProperty("message").GetString(); + errorMsg.Should().Contain("Encounter context is required"); + errorMsg.Should().Contain("At least one observation"); + + // Verify batch is still IN_ENTRY, not PendingVerification + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var batch = await db.DigitizationBatches.FindAsync(_batchId); + batch!.Status.Should().Be(BatchStatus.InEntry); + } + + // ─── Test 2: Plausible observations save successfully ──────── + + [Fact] + public async Task PlausibleObservation_SavesSuccessfully_Returns201() + { + // Heart rate 78 bpm is well within [1-300] plausible range + var resp = await _client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{_batchId}/draft/observations", + new + { + observationCode = "HEART_RATE", + value = 78, + unit = "bpm", + recordedAt = DateTimeOffset.UtcNow, + note = "Resting heart rate from chart" + }); + + resp.StatusCode.Should().Be(HttpStatusCode.Created); + + var body = await resp.Content.ReadFromJsonAsync(); + body!.RootElement.GetProperty("data").GetProperty("observationCode").GetString() + .Should().Be("HEART_RATE"); + body.RootElement.GetProperty("data").GetProperty("value").GetDecimal() + .Should().Be(78); + + // Verify the observation was persisted + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var obsCount = await db.DraftObservations.CountAsync(o => o.BatchId == _batchId); + obsCount.Should().Be(1); + + // Verify batch transitioned to IN_ENTRY + var batch = await db.DigitizationBatches.FindAsync(_batchId); + batch!.Status.Should().Be(BatchStatus.InEntry); + + // Verify entry_started event was logged + var events = await db.DigitizationEvents + .Where(e => e.BatchId == _batchId && e.EventType == DigitizationEventType.EntryStarted) + .ToListAsync(); + events.Should().HaveCount(1); + } + + // ─── Test 3: Implausible observations are rejected ─────────── + + [Fact] + public async Task ImplausibleObservation_Returns422_NoRowWritten() + { + // Heart rate 350 bpm is above the plausibility ceiling of 300 + var resp = await _client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{_batchId}/draft/observations", + new + { + observationCode = "HEART_RATE", + value = 350, + unit = "bpm", + recordedAt = DateTimeOffset.UtcNow + }); + + resp.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + + var body = await resp.Content.ReadFromJsonAsync(); + body!.RootElement.GetProperty("error").GetProperty("code").GetString() + .Should().Be("OBSERVATION_OUT_OF_PLAUSIBLE_RANGE"); + + // Verify no observation was persisted + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var obsCount = await db.DraftObservations.CountAsync(o => o.BatchId == _batchId); + obsCount.Should().Be(0, "implausible observations must not be persisted"); + } + + // ─── Test 4: Full draft lifecycle — entry through submit ───── + + [Fact] + public async Task FullDraftLifecycle_EntryThroughSubmit() + { + // 1. Upsert patient + var patientResp = await _client.PutAsJsonAsync( + $"/api/v1/digitization-batches/{_batchId}/draft/patient", + new + { + fullName = "Chen Wei-Lin", + dateOfBirth = "1985-03-15", + sex = "M", + bloodType = "O+", + noKnownAllergies = true + }); + patientResp.StatusCode.Should().Be(HttpStatusCode.OK); + + // 2. Upsert encounter + var encounterResp = await _client.PutAsJsonAsync( + $"/api/v1/digitization-batches/{_batchId}/draft/encounter", + new + { + admissionDate = DateTimeOffset.UtcNow.AddDays(-2), + department = "ICU", + roomBed = "ICU-3B", + admissionReason = "Chest pain" + }); + encounterResp.StatusCode.Should().Be(HttpStatusCode.OK); + + // 3. Add plausible observations + var obs1Resp = await _client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{_batchId}/draft/observations", + new + { + observationCode = "HEART_RATE", + value = 92, + unit = "bpm", + recordedAt = DateTimeOffset.UtcNow.AddHours(-1) + }); + obs1Resp.StatusCode.Should().Be(HttpStatusCode.Created); + + var obs2Resp = await _client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{_batchId}/draft/observations", + new + { + observationCode = "BP_SYSTOLIC", + value = 138, + unit = "mmHg", + recordedAt = DateTimeOffset.UtcNow.AddHours(-1) + }); + obs2Resp.StatusCode.Should().Be(HttpStatusCode.Created); + + // 4. Verify GET /draft returns all data + var draftResp = await _client.GetAsync( + $"/api/v1/digitization-batches/{_batchId}/draft"); + draftResp.StatusCode.Should().Be(HttpStatusCode.OK); + + var draftBody = await draftResp.Content.ReadFromJsonAsync(); + draftBody!.RootElement.GetProperty("data").GetProperty("patient") + .GetProperty("fullName").GetString().Should().Be("Chen Wei-Lin"); + draftBody.RootElement.GetProperty("data").GetProperty("encounter") + .GetProperty("department").GetString().Should().Be("ICU"); + draftBody.RootElement.GetProperty("data").GetProperty("observations") + .GetArrayLength().Should().Be(2); + + // 5. Submit for verification — should succeed + var submitResp = await _client.PostAsync( + $"/api/v1/digitization-batches/{_batchId}/submit-for-verification", null); + submitResp.StatusCode.Should().Be(HttpStatusCode.OK); + + // 6. Verify batch is now PENDING_VERIFICATION + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var batch = await db.DigitizationBatches.FindAsync(_batchId); + batch!.Status.Should().Be(BatchStatus.PendingVerification); + + // 7. Verify submitted_for_verification event was logged + var submitEvent = await db.DigitizationEvents + .FirstOrDefaultAsync(e => + e.BatchId == _batchId && + e.EventType == DigitizationEventType.SubmittedForVerification); + submitEvent.Should().NotBeNull(); + } + + // ─── Test 5: Observation CRUD — add, update, delete ────────── + + [Fact] + public async Task ObservationCrud_AddUpdateDelete() + { + // Add + var addResp = await _client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{_batchId}/draft/observations", + new + { + observationCode = "TEMP_C", + value = 37.2, + unit = "C", + recordedAt = DateTimeOffset.UtcNow, + note = "Oral temperature" + }); + addResp.StatusCode.Should().Be(HttpStatusCode.Created); + + var addBody = await addResp.Content.ReadFromJsonAsync(); + var obsId = addBody!.RootElement.GetProperty("data").GetProperty("id").GetGuid(); + + // Update — correct the value (clerk misread the chart) + var updateResp = await _client.PutAsJsonAsync( + $"/api/v1/digitization-batches/{_batchId}/draft/observations/{obsId}", + new + { + observationCode = "TEMP_C", + value = 38.1, + unit = "C", + recordedAt = DateTimeOffset.UtcNow, + note = "Corrected — misread decimal" + }); + updateResp.StatusCode.Should().Be(HttpStatusCode.OK); + + var updateBody = await updateResp.Content.ReadFromJsonAsync(); + updateBody!.RootElement.GetProperty("data").GetProperty("value").GetDecimal() + .Should().Be(38.1m); + updateBody.RootElement.GetProperty("data").GetProperty("note").GetString() + .Should().Contain("Corrected"); + + // Delete + var deleteResp = await _client.DeleteAsync( + $"/api/v1/digitization-batches/{_batchId}/draft/observations/{obsId}"); + deleteResp.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Verify observation is gone + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var obsCount = await db.DraftObservations.CountAsync(o => o.BatchId == _batchId); + obsCount.Should().Be(0); + } + + // ─── Test 6: Rejected batch re-entry ───────────────────────── + + [Fact] + public async Task RejectedBatch_CanReEnter_TransitionsToInEntry() + { + // Manually set batch to REJECTED status (simulating a verifier rejection) + using (var scope = _fixture.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var batch = await db.DigitizationBatches.FindAsync(_batchId); + batch!.Status = BatchStatus.Rejected; + batch.RejectionReason = "Missing encounter details"; + await db.SaveChangesAsync(); + } + + // Data entry clerk corrects the entry — should transition from REJECTED to IN_ENTRY + var resp = await _client.PutAsJsonAsync( + $"/api/v1/digitization-batches/{_batchId}/draft/encounter", + new + { + admissionDate = DateTimeOffset.UtcNow.AddDays(-1), + department = "Emergency Department", + roomBed = "ER-7" + }); + + resp.StatusCode.Should().Be(HttpStatusCode.OK); + + // Verify batch is now IN_ENTRY (transitioned from REJECTED) + using var verifyScope = _fixture.Services.CreateScope(); + var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); + var verifyBatch = await verifyDb.DigitizationBatches.FindAsync(_batchId); + verifyBatch!.Status.Should().Be(BatchStatus.InEntry); + + // Verify the entry_started event recorded the previous status + var evt = await verifyDb.DigitizationEvents + .Where(e => e.BatchId == _batchId && e.EventType == DigitizationEventType.EntryStarted) + .OrderByDescending(e => e.OccurredAt) + .FirstOrDefaultAsync(); + evt.Should().NotBeNull(); + evt!.MetadataJson.Should().Contain("REJECTED"); + } + + // ─── Test 7: Data entry blocked on verified batch ──────────── + + [Fact] + public async Task VerifiedBatch_DataEntryBlocked_Returns409() + { + // Manually set batch to VERIFIED status + using (var scope = _fixture.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var batch = await db.DigitizationBatches.FindAsync(_batchId); + batch!.Status = BatchStatus.Verified; + await db.SaveChangesAsync(); + } + + // Attempt to add an observation — should be blocked + var resp = await _client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{_batchId}/draft/observations", + new + { + observationCode = "HEART_RATE", + value = 80, + unit = "bpm", + recordedAt = DateTimeOffset.UtcNow + }); + + resp.StatusCode.Should().Be(HttpStatusCode.Conflict); + + var body = await resp.Content.ReadFromJsonAsync(); + body!.RootElement.GetProperty("error").GetProperty("code").GetString() + .Should().Be("ENTRY_NOT_ALLOWED"); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI.Tests/Fixtures/ApiFixture.cs b/VigilCareRecordsAPI.Tests/Fixtures/ApiFixture.cs new file mode 100644 index 0000000..599b47f --- /dev/null +++ b/VigilCareRecordsAPI.Tests/Fixtures/ApiFixture.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; + +public class ApiFixture : WebApplicationFactory, IAsyncLifetime +{ + // Override configuration to point at a test database — never run tests against + // the development database; a botched rollback could corrupt seed data. + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = + "Host=localhost;Port=5437;Database=vigilcare_records_test;Username=postgres;Password=password", + ["Redis:ConnectionString"] = "localhost:6383,defaultDatabase=1,allowAdmin=true" + }); + }); + } + + public async Task InitializeAsync() + { + // Apply migrations against the test database on first run + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + + // Flush the test Redis database (db=1) to avoid cross-test cache pollution + var redis = scope.ServiceProvider.GetRequiredService(); + await redis.GetDatabase(1).ExecuteAsync("FLUSHDB"); + } + + public new async Task DisposeAsync() + { + await base.DisposeAsync(); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI.Tests/Helpers/AuthHelper.cs b/VigilCareRecordsAPI.Tests/Helpers/AuthHelper.cs new file mode 100644 index 0000000..27434c1 --- /dev/null +++ b/VigilCareRecordsAPI.Tests/Helpers/AuthHelper.cs @@ -0,0 +1,29 @@ +using System.Net.Http.Json; +using System.Text.Json; + +public static class AuthHelper +{ + /// + /// Logs in as the given user and returns an HttpClient with the JWT + /// Authorization header pre-configured. Login also returns a refresh token; + /// integration tests use the access token only. + /// + public static async Task LoginAsync( + ApiFixture fixture, string username = "entry1", string password = "password") + { + var client = fixture.CreateClient(); + + var loginResp = await client.PostAsJsonAsync("/api/v1/auth/login", + new { username, password }); + + loginResp.EnsureSuccessStatusCode(); + + var body = await loginResp.Content.ReadFromJsonAsync(); + var token = body!.RootElement.GetProperty("data").GetProperty("token").GetString()!; + + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + return client; + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs b/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs new file mode 100644 index 0000000..4852745 --- /dev/null +++ b/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; + +public static class DbResetHelper +{ + // Truncate all data between tests — faster than dropping and re-creating + // the database, and preserves the schema (migrations do not re-run). + public static async Task ResetAsync(AppDbContext db) + { + await db.Database.ExecuteSqlRawAsync(@" + TRUNCATE TABLE digitization_events, draft_observations, + draft_encounters, draft_patients, + scanned_documents, digitization_batches, users + RESTART IDENTITY CASCADE; + "); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI.Tests/VigilCareRecordsAPI.Tests.csproj b/VigilCareRecordsAPI.Tests/VigilCareRecordsAPI.Tests.csproj new file mode 100644 index 0000000..4565ae7 --- /dev/null +++ b/VigilCareRecordsAPI.Tests/VigilCareRecordsAPI.Tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/VigilCareRecordsAPI/Controllers/DraftController.cs b/VigilCareRecordsAPI/Controllers/DraftController.cs new file mode 100644 index 0000000..0d6ec30 --- /dev/null +++ b/VigilCareRecordsAPI/Controllers/DraftController.cs @@ -0,0 +1,151 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + + +/// +/// Draft data entry endpoints for digitization batches. A data entry clerk +/// uses these endpoints to transcribe scanned paper charts into structured data. +/// +[ApiController] +[Route("api/v1/digitization-batches/{batchId:guid}/draft")] +[Produces("application/json")] +[Authorize] +public class DraftController : ControllerBase +{ + private readonly IDraftService _draft; + + public DraftController(IDraftService draft) => _draft = draft; + + /// + /// Returns the full draft payload for a batch, including patient demographics, + /// encounter context, and all observation rows entered so far. + /// + /// Digitization batch id. + /// Complete draft data for the batch. + [HttpGet] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task GetDraft(Guid batchId) + { + var result = await _draft.GetDraftAsync(batchId); + return Ok(ApiResponse.Ok(result)); + } + + /// + /// Upserts draft patient demographics for a batch. Creates the patient record + /// on the first call; updates it on subsequent calls. Automatically transitions + /// the batch to IN_ENTRY on first save. + /// + /// Digitization batch id. + /// Patient demographic fields. + /// The upserted patient record. + [HttpPut("patient")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + public async Task UpsertPatient( + Guid batchId, [FromBody] UpsertDraftPatientRequest req) + { + var actorUserId = GetCurrentUserId(); + var result = await _draft.UpsertPatientAsync(batchId, req, actorUserId); + return Ok(ApiResponse.Ok(result)); + } + + /// + /// Upserts draft encounter fields for a batch. Creates the encounter record + /// on the first call; updates it on subsequent calls. + /// + /// Digitization batch id. + /// Encounter context fields. + /// The upserted encounter record. + [HttpPut("encounter")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + public async Task UpsertEncounter( + Guid batchId, [FromBody] UpsertDraftEncounterRequest req) + { + var actorUserId = GetCurrentUserId(); + var result = await _draft.UpsertEncounterAsync(batchId, req, actorUserId); + return Ok(ApiResponse.Ok(result)); + } + + /// + /// Adds a new observation row to the batch draft. Validates the value against + /// plausibility ranges before saving. + /// + /// Digitization batch id. + /// Observation data to add. + /// The created observation record. + [HttpPost("observations")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task AddObservation( + Guid batchId, [FromBody] CreateDraftObservationRequest req) + { + var actorUserId = GetCurrentUserId(); + var result = await _draft.AddObservationAsync(batchId, req, actorUserId); + return StatusCode(201, ApiResponse.Created(result)); + } + + /// + /// Updates an existing observation row in the batch draft. Re-validates the + /// new value against plausibility ranges. + /// + /// Digitization batch id. + /// Observation id to update. + /// Updated observation data. + /// The updated observation record. + [HttpPut("observations/{obsId:guid}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task UpdateObservation( + Guid batchId, Guid obsId, [FromBody] UpdateDraftObservationRequest req) + { + var actorUserId = GetCurrentUserId(); + var result = await _draft.UpdateObservationAsync(batchId, obsId, req, actorUserId); + return Ok(ApiResponse.Ok(result)); + } + + /// + /// Removes an observation row from the batch draft. + /// + /// Digitization batch id. + /// Observation id to delete. + [HttpDelete("observations/{obsId:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + public async Task DeleteObservation(Guid batchId, Guid obsId) + { + var actorUserId = GetCurrentUserId(); + await _draft.DeleteObservationAsync(batchId, obsId, actorUserId); + return NoContent(); + } + + /// + /// Validates completeness per batch type and transitions the batch from + /// IN_ENTRY to PENDING_VERIFICATION. Returns 422 if required fields are missing. + /// + /// Digitization batch id. + /// The updated batch record. + [HttpPost("~/api/v1/digitization-batches/{batchId:guid}/submit-for-verification")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task SubmitForVerification(Guid batchId) + { + var actorUserId = GetCurrentUserId(); + var batch = await _draft.SubmitForVerificationAsync(batchId, actorUserId); + return Ok(ApiResponse.Ok(BatchDetailResponse.FromEntity(batch))); + } + + private Guid GetCurrentUserId() => + Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/Batch/DraftPayloadResponse.cs b/VigilCareRecordsAPI/Models/Records/Batch/DraftPayloadResponse.cs new file mode 100644 index 0000000..9bb10e1 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/Batch/DraftPayloadResponse.cs @@ -0,0 +1,8 @@ +public record DraftPayloadResponse( + Guid BatchId, + string Status, + string BatchType, + DraftPatientDto? Patient, + DraftEncounterDto? Encounter, + List Observations +); diff --git a/VigilCareRecordsAPI/Models/Records/DraftEncounter/DraftEncounterDto.cs b/VigilCareRecordsAPI/Models/Records/DraftEncounter/DraftEncounterDto.cs new file mode 100644 index 0000000..2887674 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/DraftEncounter/DraftEncounterDto.cs @@ -0,0 +1,10 @@ +public record DraftEncounterDto( + Guid Id, + DateTimeOffset? AdmissionDate, + string? Department, + string? RoomBed, + string? AdmissionReason, + string? DischargeDiagnosis, + string? Status, + DateTimeOffset UpdatedAt +); diff --git a/VigilCareRecordsAPI/Models/Records/DraftEncounter/UpsertDraftEncounterRequest.cs b/VigilCareRecordsAPI/Models/Records/DraftEncounter/UpsertDraftEncounterRequest.cs new file mode 100644 index 0000000..a2d3da3 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/DraftEncounter/UpsertDraftEncounterRequest.cs @@ -0,0 +1,8 @@ +public record UpsertDraftEncounterRequest( + DateTimeOffset? AdmissionDate, + string? Department, + string? RoomBed, + string? AdmissionReason, + string? DischargeDiagnosis, + string? Status +); \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/DraftObservation/CreateDraftObservationRequest.cs b/VigilCareRecordsAPI/Models/Records/DraftObservation/CreateDraftObservationRequest.cs new file mode 100644 index 0000000..18a0434 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/DraftObservation/CreateDraftObservationRequest.cs @@ -0,0 +1,7 @@ +public record CreateDraftObservationRequest( + string ObservationCode, + decimal Value, + string Unit, + DateTimeOffset RecordedAt, + string? Note +); \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/DraftObservation/DraftObservationDto.cs b/VigilCareRecordsAPI/Models/Records/DraftObservation/DraftObservationDto.cs new file mode 100644 index 0000000..ac9f8a2 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/DraftObservation/DraftObservationDto.cs @@ -0,0 +1,9 @@ +public record DraftObservationDto( + Guid Id, + string ObservationCode, + decimal Value, + string Unit, + DateTimeOffset RecordedAt, + string? Note, + DateTimeOffset CreatedAt +); \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/DraftObservation/UpdateDraftObservationRequest.cs b/VigilCareRecordsAPI/Models/Records/DraftObservation/UpdateDraftObservationRequest.cs new file mode 100644 index 0000000..23a4bff --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/DraftObservation/UpdateDraftObservationRequest.cs @@ -0,0 +1,7 @@ +public record UpdateDraftObservationRequest( + string ObservationCode, + decimal Value, + string Unit, + DateTimeOffset RecordedAt, + string? Note +); \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/DraftPatient/DraftPatientDto.cs b/VigilCareRecordsAPI/Models/Records/DraftPatient/DraftPatientDto.cs new file mode 100644 index 0000000..901abd4 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/DraftPatient/DraftPatientDto.cs @@ -0,0 +1,13 @@ +public record DraftPatientDto( + Guid Id, + string? FullName, + DateOnly? DateOfBirth, + string? Sex, + string? BloodType, + string? EmergencyContact, + List? Allergies, + bool NoKnownAllergies, + List? Medications, + bool NoActiveMedications, + DateTimeOffset UpdatedAt +); diff --git a/VigilCareRecordsAPI/Models/Records/DraftPatient/UpsertDraftPatientRequest.cs b/VigilCareRecordsAPI/Models/Records/DraftPatient/UpsertDraftPatientRequest.cs new file mode 100644 index 0000000..b171ae8 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/DraftPatient/UpsertDraftPatientRequest.cs @@ -0,0 +1,11 @@ +public record UpsertDraftPatientRequest( + string? FullName, + DateOnly? DateOfBirth, + string? Sex, + string? BloodType, + string? EmergencyContact, + List? Allergies, + bool NoKnownAllergies, + List? Medications, + bool NoActiveMedications +); \ No newline at end of file diff --git a/VigilCareRecordsAPI/Program.cs b/VigilCareRecordsAPI/Program.cs index 609a5e6..afafa7f 100644 --- a/VigilCareRecordsAPI/Program.cs +++ b/VigilCareRecordsAPI/Program.cs @@ -22,8 +22,8 @@ try options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); // Redis - builder.Services.AddSingleton( - ConnectionMultiplexer.Connect(builder.Configuration["Redis:ConnectionString"]!)); + builder.Services.AddSingleton(sp => + ConnectionMultiplexer.Connect(sp.GetRequiredService()["Redis:ConnectionString"]!)); // MinIO var minioOptions = builder.Configuration.GetSection(MinioOptions.Section).Get()!; @@ -54,6 +54,7 @@ try // Services builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddControllers(); diff --git a/VigilCareRecordsAPI/Services/DraftService.cs b/VigilCareRecordsAPI/Services/DraftService.cs new file mode 100644 index 0000000..910d1d6 --- /dev/null +++ b/VigilCareRecordsAPI/Services/DraftService.cs @@ -0,0 +1,584 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; + +public class DraftService : IDraftService +{ + private readonly AppDbContext _db; + private readonly ILogger _logger; + + // Statuses that allow data entry to begin or continue + private static readonly HashSet _entryAllowedStatuses = new() + { + BatchStatus.Uploaded, + BatchStatus.InEntry, + BatchStatus.Rejected + }; + + public DraftService(AppDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task GetDraftAsync(Guid batchId) + { + var batch = await _db.DigitizationBatches + .AsNoTracking() + .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"); + + return new DraftPayloadResponse( + batch.Id, + batch.Status.ToDbString(), + batch.BatchType.ToDbString(), + batch.DraftPatient is not null ? MapPatient(batch.DraftPatient) : null, + batch.DraftEncounter is not null ? MapEncounter(batch.DraftEncounter) : null, + batch.DraftObservations.Select(MapObservation).OrderBy(o => o.RecordedAt).ToList() + ); + } + + public async Task UpsertPatientAsync( + Guid batchId, UpsertDraftPatientRequest req, Guid actorUserId) + { + var batch = await LoadBatchForEntryAsync(batchId, actorUserId); + TransitionToInEntryIfNeeded(batch, actorUserId); + + BloodType? parsedBloodType = null; + if (req.BloodType is not null) + { + if (!BloodTypeExtensions.TryFromDbString(req.BloodType, out var parsed)) + throw new ValidationException( + $"Invalid blood type '{req.BloodType}'. Allowed values: A+, A-, B+, B-, AB+, AB-, O+, O-.", + "INVALID_BLOOD_TYPE"); + parsedBloodType = parsed; + } + + var patient = await _db.DraftPatients.FirstOrDefaultAsync(p => p.BatchId == batchId); + + if (patient is null) + { + patient = new DraftPatient + { + Id = Guid.NewGuid(), + BatchId = batchId, + FullName = req.FullName, + DateOfBirth = req.DateOfBirth, + Sex = req.Sex, + BloodType = parsedBloodType, + EmergencyContact = req.EmergencyContact, + AllergiesJson = req.Allergies is not null + ? JsonSerializer.Serialize(req.Allergies) + : null, + NoKnownAllergies = req.NoKnownAllergies, + MedicationsJson = req.Medications is not null + ? JsonSerializer.Serialize(req.Medications) + : null, + NoActiveMedications = req.NoActiveMedications, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + _db.DraftPatients.Add(patient); + } + else + { + patient.FullName = req.FullName; + patient.DateOfBirth = req.DateOfBirth; + patient.Sex = req.Sex; + patient.BloodType = parsedBloodType; + patient.EmergencyContact = req.EmergencyContact; + patient.AllergiesJson = req.Allergies is not null + ? JsonSerializer.Serialize(req.Allergies) + : null; + patient.NoKnownAllergies = req.NoKnownAllergies; + patient.MedicationsJson = req.Medications is not null + ? JsonSerializer.Serialize(req.Medications) + : null; + patient.NoActiveMedications = req.NoActiveMedications; + patient.UpdatedAt = DateTimeOffset.UtcNow; + } + + await _db.SaveChangesAsync(); + + _logger.LogInformation( + "Draft patient upserted for batch {BatchId} by user {UserId}", + batchId, actorUserId); + + return MapPatient(patient); + } + + public async Task UpsertEncounterAsync( + Guid batchId, UpsertDraftEncounterRequest req, Guid actorUserId) + { + var batch = await LoadBatchForEntryAsync(batchId, actorUserId); + TransitionToInEntryIfNeeded(batch, actorUserId); + + Department? parsedDepartment = null; + if (req.Department is not null) + { + if (!DepartmentExtensions.TryFromDbString(req.Department, out var parsed)) + throw new ValidationException( + $"Invalid department '{req.Department}'. Must be a recognized hospital department.", + "INVALID_DEPARTMENT"); + parsedDepartment = parsed; + } + + var encounter = await _db.DraftEncounters.FirstOrDefaultAsync(e => e.BatchId == batchId); + + if (encounter is null) + { + encounter = new DraftEncounter + { + Id = Guid.NewGuid(), + BatchId = batchId, + AdmissionDate = req.AdmissionDate, + Department = parsedDepartment, + RoomBed = req.RoomBed, + AdmissionReason = req.AdmissionReason, + DischargeDiagnosis = req.DischargeDiagnosis, + Status = req.Status, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + _db.DraftEncounters.Add(encounter); + } + else + { + encounter.AdmissionDate = req.AdmissionDate; + encounter.Department = parsedDepartment; + encounter.RoomBed = req.RoomBed; + encounter.AdmissionReason = req.AdmissionReason; + encounter.DischargeDiagnosis = req.DischargeDiagnosis; + encounter.Status = req.Status; + encounter.UpdatedAt = DateTimeOffset.UtcNow; + } + + await _db.SaveChangesAsync(); + + _logger.LogInformation( + "Draft encounter upserted for batch {BatchId} by user {UserId}", + batchId, actorUserId); + + return MapEncounter(encounter); + } + + public async Task AddObservationAsync( + Guid batchId, CreateDraftObservationRequest req, Guid actorUserId) + { + var batch = await LoadBatchForEntryAsync(batchId, actorUserId); + TransitionToInEntryIfNeeded(batch, actorUserId); + + // Plausibility check — reject impossible values before persisting + if (!PlausibilityValidator.IsPlausible(req.ObservationCode, req.Value, out var reason)) + throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE"); + + var observation = new DraftObservation + { + Id = Guid.NewGuid(), + BatchId = batchId, + ObservationCode = req.ObservationCode, + Value = req.Value, + Unit = req.Unit, + RecordedAt = req.RecordedAt, + Note = req.Note, + CreatedAt = DateTimeOffset.UtcNow + }; + + _db.DraftObservations.Add(observation); + await _db.SaveChangesAsync(); + + _logger.LogInformation( + "Draft observation {ObservationId} added to batch {BatchId} by user {UserId}", + observation.Id, batchId, actorUserId); + + return MapObservation(observation); + } + + public async Task UpdateObservationAsync( + Guid batchId, Guid observationId, UpdateDraftObservationRequest req, Guid actorUserId) + { + var batch = await LoadBatchForEntryAsync(batchId, actorUserId); + + var observation = await _db.DraftObservations + .FirstOrDefaultAsync(o => o.Id == observationId && o.BatchId == batchId); + + if (observation is null) + throw new NotFoundException( + "Observation not found in this batch.", "OBSERVATION_NOT_FOUND"); + + // Re-validate plausibility on the new value + if (!PlausibilityValidator.IsPlausible(req.ObservationCode, req.Value, out var reason)) + throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE"); + + observation.ObservationCode = req.ObservationCode; + observation.Value = req.Value; + observation.Unit = req.Unit; + observation.RecordedAt = req.RecordedAt; + observation.Note = req.Note; + + await _db.SaveChangesAsync(); + + _logger.LogInformation( + "Draft observation {ObservationId} updated in batch {BatchId} by user {UserId}", + observationId, batchId, actorUserId); + + return MapObservation(observation); + } + + public async Task DeleteObservationAsync( + Guid batchId, Guid observationId, Guid actorUserId) + { + var batch = await LoadBatchForEntryAsync(batchId, actorUserId); + + var observation = await _db.DraftObservations + .FirstOrDefaultAsync(o => o.Id == observationId && o.BatchId == batchId); + + if (observation is null) + throw new NotFoundException( + "Observation not found in this batch.", "OBSERVATION_NOT_FOUND"); + + _db.DraftObservations.Remove(observation); + await _db.SaveChangesAsync(); + + _logger.LogInformation( + "Draft observation {ObservationId} deleted from batch {BatchId} by user {UserId}", + observationId, batchId, actorUserId); + } + + public async Task SubmitForVerificationAsync( + Guid batchId, Guid actorUserId) + { + 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"); + + if (batch.Status != BatchStatus.InEntry) + throw new ConflictException( + $"Only batches in 'in_entry' status can be submitted for verification. " + + $"Current status: '{batch.Status.ToDbString()}'.", + "ILLEGAL_STATUS_TRANSITION"); + + // Batch-type-specific completeness validation + ValidateCompleteness(batch); + + // Transition to PendingVerification + batch.Status = BatchStatus.PendingVerification; + batch.UpdatedAt = DateTimeOffset.UtcNow; + + _db.DigitizationEvents.Add(new DigitizationEvent + { + Id = Guid.NewGuid(), + BatchId = batchId, + EventType = DigitizationEventType.SubmittedForVerification, + ActorUserId = actorUserId, + OccurredAt = DateTimeOffset.UtcNow, + MetadataJson = JsonSerializer.Serialize(new + { + batchType = batch.BatchType.ToDbString(), + observationCount = batch.DraftObservations.Count + }) + }); + + await _db.SaveChangesAsync(); + + _logger.LogInformation( + "Batch {BatchId} submitted for verification by user {UserId}. " + + "Type={BatchType}, ObservationCount={ObsCount}", + batchId, actorUserId, + batch.BatchType.ToDbString(), + batch.DraftObservations.Count); + + return batch; + } + + // ─── Private helpers ───────────────────────────────────────── + + /// + /// Loads the batch and validates that its current status allows data entry. + /// Enforces assignment: only the assigned entry clerk (enteredByUserId) or an + /// administrator may save draft fields. Throws BATCH_NOT_ASSIGNED otherwise. + /// + private async Task LoadBatchForEntryAsync(Guid batchId, Guid actorUserId) + { + var batch = await _db.DigitizationBatches.FindAsync(batchId); + + if (batch is null) + throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND"); + + if (!_entryAllowedStatuses.Contains(batch.Status)) + throw new ConflictException( + $"Data entry is not allowed for batches in '{batch.Status.ToDbString()}' status. " + + $"Allowed statuses: UPLOADED, IN_ENTRY, REJECTED.", + "ENTRY_NOT_ALLOWED"); + + if (batch.EnteredByUserId.HasValue && batch.EnteredByUserId.Value != actorUserId) + { + var actor = await _db.Users.AsNoTracking() + .FirstOrDefaultAsync(u => u.Id == actorUserId); + if (actor?.Role != UserRole.Administrator) + { + throw new ConflictException( + "This batch is assigned to another entry clerk.", + "BATCH_NOT_ASSIGNED"); + } + } + + return batch; + } + + /// + /// Transitions the batch from Uploaded or Rejected to InEntry on first save. + /// If the batch is already InEntry, this is a no-op. + /// + private void TransitionToInEntryIfNeeded(DigitizationBatch batch, Guid actorUserId) + { + if (batch.Status == BatchStatus.InEntry) return; + + // batch.Status is Uploaded or Rejected (validated by LoadBatchForEntryAsync) + var previousStatus = batch.Status.ToDbString(); + batch.Status = BatchStatus.InEntry; + batch.UpdatedAt = DateTimeOffset.UtcNow; + + _db.DigitizationEvents.Add(new DigitizationEvent + { + Id = Guid.NewGuid(), + BatchId = batch.Id, + EventType = DigitizationEventType.EntryStarted, + ActorUserId = actorUserId, + OccurredAt = DateTimeOffset.UtcNow, + MetadataJson = JsonSerializer.Serialize(new + { + previousStatus, + newStatus = "IN_ENTRY" + }) + }); + + _logger.LogInformation( + "Batch {BatchId} transitioned from {PreviousStatus} to IN_ENTRY", + batch.Id, previousStatus); + } + + /// + /// Validates batch-type-specific completeness rules. Throws ValidationException + /// with a descriptive message listing all missing fields. + /// + private static void ValidateCompleteness(DigitizationBatch batch) + { + var errors = new List(); + + switch (batch.BatchType) + { + case BatchType.PatientRegistration: + ValidatePatientRegistration(batch, errors); + break; + + case BatchType.VitalsSheet: + ValidateVitalsSheet(batch, errors); + break; + + case BatchType.LabResults: + ValidateLabResults(batch, errors); + break; + + case BatchType.AllergyUpdate: + ValidateAllergyUpdate(batch, errors); + break; + + case BatchType.EncounterSummary: + ValidateEncounterSummary(batch, errors); + break; + + case BatchType.MedicationList: + ValidateMedicationList(batch, errors); + break; + + case BatchType.Mixed: + ValidateMixed(batch, errors); + break; + } + + if (errors.Count > 0) + { + var message = $"Batch is incomplete for type '{batch.BatchType.ToDbString()}'. " + + $"Missing: {string.Join("; ", errors)}."; + throw new ValidationException(message, "BATCH_INCOMPLETE"); + } + } + + private static void ValidatePatientRegistration( + DigitizationBatch batch, List errors) + { + // Required: Full name, date of birth, sex + if (batch.DraftPatient is null) + { + errors.Add("Patient demographics are required"); + return; + } + + if (string.IsNullOrWhiteSpace(batch.DraftPatient.FullName)) + errors.Add("Patient full name is required"); + + if (batch.DraftPatient.DateOfBirth is null) + errors.Add("Patient date of birth is required"); + + if (string.IsNullOrWhiteSpace(batch.DraftPatient.Sex)) + errors.Add("Patient sex is required"); + } + + private static void ValidateVitalsSheet( + DigitizationBatch batch, List errors) + { + // Required: Linked patient, encounter context, >= 1 observation with recordedAt + if (batch.DraftPatient is null) + errors.Add("Linked patient is required for vitals sheets"); + + if (batch.DraftEncounter is null) + errors.Add("Encounter context is required for vitals sheets"); + + if (batch.DraftObservations.Count == 0) + errors.Add("At least one observation with a recorded timestamp is required"); + else if (batch.DraftObservations.Any(o => o.RecordedAt == default)) + errors.Add("All observations must have a recorded timestamp"); + } + + private static void ValidateLabResults( + DigitizationBatch batch, List errors) + { + // Required: Linked patient, encounter, >= 1 lab observation code, recordedAt + if (batch.DraftPatient is null) + errors.Add("Linked patient is required for lab results"); + + if (batch.DraftEncounter is null) + errors.Add("Encounter context is required for lab results"); + + if (batch.DraftObservations.Count == 0) + errors.Add("At least one lab observation is required"); + else if (batch.DraftObservations.Any(o => o.RecordedAt == default)) + errors.Add("All observations must have a recorded timestamp"); + } + + private static void ValidateAllergyUpdate( + DigitizationBatch batch, List errors) + { + // Required: Linked patient, allergies list (may be empty with noKnownAllergies: true) + if (batch.DraftPatient is null) + { + errors.Add("Linked patient is required for allergy updates"); + return; + } + + var hasAllergies = !string.IsNullOrWhiteSpace(batch.DraftPatient.AllergiesJson); + var hasNoKnownAllergiesFlag = batch.DraftPatient.NoKnownAllergies; + + // Either the allergies list must be present OR noKnownAllergies must be true + if (!hasAllergies && !hasNoKnownAllergiesFlag) + errors.Add("Allergies list is required (set noKnownAllergies to true if none)"); + } + + private static void ValidateEncounterSummary( + DigitizationBatch batch, List errors) + { + if (batch.DraftPatient is null) + errors.Add("Linked patient is required for encounter summaries"); + + if (batch.DraftEncounter is null) + { + errors.Add("Encounter context is required for encounter summaries"); + return; + } + + if (batch.DraftEncounter.AdmissionDate is null) + errors.Add("Admission date is required for encounter summaries"); + + if (batch.DraftEncounter.Department is null) + errors.Add("Department is required for encounter summaries"); + + if (string.IsNullOrWhiteSpace(batch.DraftEncounter.AdmissionReason)) + errors.Add("Admission reason is required for encounter summaries"); + } + + private static void ValidateMedicationList( + DigitizationBatch batch, List errors) + { + if (batch.DraftPatient is null) + { + errors.Add("Linked patient is required for medication lists"); + return; + } + + var hasMedications = !string.IsNullOrWhiteSpace(batch.DraftPatient.MedicationsJson); + if (!hasMedications && !batch.DraftPatient.NoActiveMedications) + errors.Add("Medications list is required (set noActiveMedications to true if none)"); + } + + private static void ValidateMixed( + DigitizationBatch batch, List errors) + { + if (batch.DraftPatient is null) + errors.Add("Linked patient is required for mixed batches"); + + if (batch.DraftEncounter is null) + errors.Add("Encounter context is required for mixed batches"); + + var hasObservations = batch.DraftObservations.Count > 0 + && batch.DraftObservations.All(o => o.RecordedAt != default); + + var hasEncounterSummary = batch.DraftEncounter is not null + && batch.DraftEncounter.AdmissionDate is not null + && batch.DraftEncounter.Department is not null + && !string.IsNullOrWhiteSpace(batch.DraftEncounter.AdmissionReason); + + if (!hasObservations && !hasEncounterSummary) + errors.Add("Mixed batch requires at least one observation with recordedAt, or a complete encounter summary (admission date, department, admission reason)"); + } + + // ─── Mapping helpers ───────────────────────────────────────── + + private static DraftPatientDto MapPatient(DraftPatient p) => new( + p.Id, + p.FullName, + p.DateOfBirth, + p.Sex, + p.BloodType?.ToDbString(), + p.EmergencyContact, + !string.IsNullOrWhiteSpace(p.AllergiesJson) + ? JsonSerializer.Deserialize>(p.AllergiesJson) + : null, + p.NoKnownAllergies, + !string.IsNullOrWhiteSpace(p.MedicationsJson) + ? JsonSerializer.Deserialize>(p.MedicationsJson) + : null, + p.NoActiveMedications, + p.UpdatedAt + ); + + private static DraftEncounterDto MapEncounter(DraftEncounter e) => new( + e.Id, + e.AdmissionDate, + e.Department?.ToDbString(), + e.RoomBed, + e.AdmissionReason, + e.DischargeDiagnosis, + e.Status, + e.UpdatedAt + ); + + private static DraftObservationDto MapObservation(DraftObservation o) => new( + o.Id, + o.ObservationCode, + o.Value, + o.Unit, + o.RecordedAt, + o.Note, + o.CreatedAt + ); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/Interfaces/IDraftService.cs b/VigilCareRecordsAPI/Services/Interfaces/IDraftService.cs new file mode 100644 index 0000000..9fa17b8 --- /dev/null +++ b/VigilCareRecordsAPI/Services/Interfaces/IDraftService.cs @@ -0,0 +1,44 @@ +public interface IDraftService +{ + /// + /// Returns the full draft payload for a batch, including patient, encounter, + /// and all observation rows entered so far. + /// + Task GetDraftAsync(Guid batchId); + + /// + /// Upserts draft patient demographics for a batch. Creates the DraftPatient + /// row on the first call; updates it on subsequent calls. Transitions batch + /// from Uploaded/Rejected to InEntry on first save. + /// + Task UpsertPatientAsync(Guid batchId, UpsertDraftPatientRequest req, Guid actorUserId); + + /// + /// Upserts draft encounter fields for a batch. Creates the DraftEncounter + /// row on the first call; updates it on subsequent calls. + /// + Task UpsertEncounterAsync(Guid batchId, UpsertDraftEncounterRequest req, Guid actorUserId); + + /// + /// Adds a new observation row to the batch draft. Validates plausibility + /// before saving — implausible values throw ValidationException. + /// + Task AddObservationAsync(Guid batchId, CreateDraftObservationRequest req, Guid actorUserId); + + /// + /// Edits an existing observation row. Re-validates plausibility on the new value. + /// + Task UpdateObservationAsync(Guid batchId, Guid observationId, UpdateDraftObservationRequest req, Guid actorUserId); + + /// + /// Removes an observation row from the batch draft. + /// + Task DeleteObservationAsync(Guid batchId, Guid observationId, Guid actorUserId); + + /// + /// Validates completeness per batch type and transitions the batch to + /// PendingVerification. Throws ValidationException if required fields + /// are missing. + /// + Task SubmitForVerificationAsync(Guid batchId, Guid actorUserId); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/PlausibilityValidator.cs b/VigilCareRecordsAPI/Services/PlausibilityValidator.cs new file mode 100644 index 0000000..0e4974a --- /dev/null +++ b/VigilCareRecordsAPI/Services/PlausibilityValidator.cs @@ -0,0 +1,53 @@ +public static class PlausibilityValidator +{ + // Plausible ranges define the outer boundary of physically possible values. + // These are NOT clinical alert thresholds — they catch device malfunctions, + // transcription errors, and misread handwriting from paper charts. + // A heart rate of 300 is clinically extreme but not impossible during VT; + // 400 is physically impossible and indicates a data entry mistake. + private static readonly Dictionary _ranges = new() + { + ["HEART_RATE"] = (1, 300), // beats per minute; ceiling allows extreme tachycardia (e.g. VT) + ["TEMP_C"] = (15, 50), // core body temperature in °C + ["POTASSIUM_MEQ_L"] = (0.1m, 12), // serum potassium mEq/L; catches decimal misplacement (5.2 vs 52) + ["SPO2"] = (50, 100), // peripheral oxygen saturation % + ["RESP_RATE"] = (1, 80), // respirations per minute + ["WBC_K_UL"] = (0.1m, 500), // white blood cell count ×10³/µL + ["GLUCOSE_MG_DL"] = (10, 1000), // blood glucose mg/dL + ["LACTATE_MMOL_L"] = (0.1m, 30), // blood lactate mmol/L + ["BP_SYSTOLIC"] = (40, 300), // systolic blood pressure mmHg + ["BP_DIASTOLIC"] = (20, 200), // diastolic blood pressure mmHg + }; + + /// + /// Returns true if the value falls within the plausible range for the given code. + /// Unknown observation codes pass plausibility — the code is validated elsewhere. + /// + public static bool IsPlausible(string observationCode, decimal value, out string? reason) + { + if (!_ranges.TryGetValue(observationCode, out var range)) + { + // Unknown codes pass plausibility — the observation code itself + // is validated at the business rule layer, not here. + reason = null; + return true; + } + + if (value < range.Min || value > range.Max) + { + reason = $"Value {value} is outside the plausible range " + + $"[{range.Min}–{range.Max}] for {observationCode}."; + return false; + } + + reason = null; + return true; + } + + /// + /// Returns all known observation codes and their plausible ranges. + /// Used by the frontend to display valid input boundaries. + /// + public static IReadOnlyDictionary GetAllRanges() => + _ranges; +} \ No newline at end of file diff --git a/scripts/run-vigilcare-records-phase-2-verification.sh b/scripts/run-vigilcare-records-phase-2-verification.sh new file mode 100755 index 0000000..17aa01d --- /dev/null +++ b/scripts/run-vigilcare-records-phase-2-verification.sh @@ -0,0 +1,618 @@ +#!/usr/bin/env bash +# Runs Phase 2 verification checks from docs/plans/phase-2-plan.md. +# +# Prerequisites: +# docker compose up -d (PostgreSQL + Redis) +# dotnet run --project VigilCareRecordsAPI +# Phase 1 seed data (entry1, entry2, intake1 users) +# +# Environment overrides (same defaults as Phase 1 script): +# VIGILCARE_API_URL default: http://localhost:5217 +# VIGILCARE_COMPOSE_FILE default: /docker-compose.yml +# VIGILCARE_PG_HOST default: localhost +# VIGILCARE_PG_PORT default: 5437 +# VIGILCARE_PG_DB default: vigilcare_records +# VIGILCARE_PG_USER default: postgres +# VIGILCARE_PG_PASSWORD default: password +# VIGILCARE_SKIP_DB_CHECKS set to 1 to skip PostgreSQL assertions + +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:-2025-01-01T10:00: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 + command -v psql >/dev/null 2>&1 && return 0 + compose_service_running postgres +} + +psql_query() { + if 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" + elif compose_service_running postgres; then + "${COMPOSE[@]}" exec -T postgres \ + psql -U "$PG_USER" -d "$PG_DB" -Atqc "$1" + else + return 1 + 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 // empty' <<<"$json" +} + +extract_error_message() { + local json="$1" + jq -er '.error.message // empty' <<<"$json" +} + +upload_batch() { + local token="$1" + local file_path="${2:-$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=VITALS_SHEET" +} + +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\"}" +} + +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 "Start API with: dotnet run --project VigilCareRecordsAPI" + exit 1 + fi +} + +# Upload a VITALS_SHEET batch and assign it to entry1. +# Prints the batch id to stdout. +create_assigned_vitals_batch() { + local intake_token="$1" + local upload_json batch_id entry_json entry_id + + upload_json="$(upload_batch "$intake_token")" + if [[ "$(jq -er '.success' <<<"$upload_json")" != "true" ]]; then + log "ERROR: failed to upload batch for test setup" + return 1 + fi + + batch_id="$(extract_data_field "$upload_json" id)" + entry_json="$(login entry1)" + entry_id="$(extract_data_field "$entry_json" userId)" + assign_batch "$intake_token" "$batch_id" "$entry_id" >/dev/null + + printf '%s' "$batch_id" +} + +test_draft_patient_upsert() { + section "1. Draft CRUD — patient upsert creates and updates" + + local intake_json intake_token entry_json entry_token batch_id + local first_json second_json blood_type + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + batch_id="$(create_assigned_vitals_batch "$intake_token")" || return + + entry_json="$(login entry1)" + entry_token="$(extract_data_field "$entry_json" token)" + + first_json="$(json_put \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \ + '{"fullName":"Chen Wei-Lin","dateOfBirth":"1985-03-15","sex":"M","noKnownAllergies":true,"noActiveMedications":true}' \ + "$entry_token")" + if [[ "$(jq -er '.success' <<<"$first_json")" == "true" ]]; then + pass "first patient PUT creates draft patient (200)" + else + fail "first patient PUT creates draft patient (200)" + return + fi + + second_json="$(json_put \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \ + '{"fullName":"Chen Wei-Lin","dateOfBirth":"1985-03-15","sex":"M","bloodType":"O+","noKnownAllergies":true,"noActiveMedications":true}' \ + "$entry_token")" + blood_type="$(extract_data_field "$second_json" bloodType)" + if [[ "$(jq -er '.success' <<<"$second_json")" == "true" && "$blood_type" == "O+" ]]; then + pass "second patient PUT updates draft patient (bloodType added)" + else + fail "second patient PUT updates draft patient (bloodType added) (got: ${blood_type:-})" + fi +} + +test_status_transition_to_in_entry() { + section "2. Status transitions — first draft save moves to IN_ENTRY" + + local intake_json intake_token entry_json entry_token batch_id status_before status_after + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + batch_id="$(create_assigned_vitals_batch "$intake_token")" || return + + entry_json="$(login entry1)" + entry_token="$(extract_data_field "$entry_json" token)" + + status_before="$(curl -sS "$API_URL/api/v1/digitization-batches/$batch_id" \ + -H "Authorization: Bearer $entry_token" | jq -er '.data.status')" + if [[ "$status_before" == "UPLOADED" ]]; then + pass "batch status is UPLOADED before first draft save" + else + fail "batch status is UPLOADED before first draft save (got: $status_before)" + fi + + json_put \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \ + '{"fullName":"Test Patient","noKnownAllergies":true,"noActiveMedications":true}' \ + "$entry_token" >/dev/null + + status_after="$(curl -sS "$API_URL/api/v1/digitization-batches/$batch_id" \ + -H "Authorization: Bearer $entry_token" | jq -er '.data.status')" + if [[ "$status_after" == "IN_ENTRY" ]]; then + pass "batch status transitions to IN_ENTRY after first draft save" + else + fail "batch status transitions to IN_ENTRY after first draft save (got: $status_after)" + fi +} + +test_plausibility_validation() { + section "3. Plausibility validation — implausible values rejected" + + local intake_json intake_token entry_json entry_token batch_id + local bad_json bad_code good_code + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + batch_id="$(create_assigned_vitals_batch "$intake_token")" || return + + entry_json="$(login entry1)" + entry_token="$(extract_data_field "$entry_json" token)" + + bad_json="$(json_post \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ + "{\"observationCode\":\"HEART_RATE\",\"value\":350,\"unit\":\"bpm\",\"recordedAt\":\"$RECORDED_AT\"}" \ + "$entry_token")" + bad_code="$(extract_error_code "$bad_json")" + if [[ "$bad_code" == "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE" ]]; then + pass "heart rate 350 bpm returns OBSERVATION_OUT_OF_PLAUSIBLE_RANGE" + else + fail "heart rate 350 bpm returns OBSERVATION_OUT_OF_PLAUSIBLE_RANGE (got: ${bad_code:-})" + fi + + good_code="$(http_code -X POST \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ + -H "Authorization: Bearer $entry_token" \ + -H 'Content-Type: application/json' \ + -d "{\"observationCode\":\"HEART_RATE\",\"value\":78,\"unit\":\"bpm\",\"recordedAt\":\"$RECORDED_AT\"}")" + if [[ "$good_code" == "201" ]]; then + pass "heart rate 78 bpm saves successfully (201)" + else + fail "heart rate 78 bpm saves successfully (201) (got: $good_code)" + fi +} + +test_incomplete_submit_rejected() { + section "4. Submit-for-verification — incomplete batch rejected" + + local intake_json intake_token entry_json entry_token batch_id + local submit_body submit_code error_code error_msg + local submit_tmp + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + batch_id="$(create_assigned_vitals_batch "$intake_token")" || return + + entry_json="$(login entry1)" + entry_token="$(extract_data_field "$entry_json" token)" + + json_put \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \ + '{"fullName":"Test Patient","noKnownAllergies":true,"noActiveMedications":true}' \ + "$entry_token" >/dev/null + + submit_tmp="$(mktemp)" + submit_code="$(curl -sS -o "$submit_tmp" -w '%{http_code}' -X POST \ + "$API_URL/api/v1/digitization-batches/$batch_id/submit-for-verification" \ + -H "Authorization: Bearer $entry_token")" + submit_body="$(cat "$submit_tmp")" + rm -f "$submit_tmp" + error_code="$(extract_error_code "$submit_body")" + error_msg="$(extract_error_message "$submit_body")" + + if [[ "$submit_code" == "422" && "$error_code" == "BATCH_INCOMPLETE" ]]; then + pass "incomplete vitals batch submit returns 422 BATCH_INCOMPLETE" + else + fail "incomplete vitals batch submit returns 422 BATCH_INCOMPLETE (http=$submit_code code=${error_code:-})" + return + fi + + if [[ "$error_msg" == *"Encounter context is required"* && + "$error_msg" == *"At least one observation"* ]]; then + pass "BATCH_INCOMPLETE message lists missing encounter and observations" + else + fail "BATCH_INCOMPLETE message lists missing encounter and observations" + fi +} + +test_get_draft_payload() { + section "5. Full draft payload — GET returns patient, encounter, observations" + + local intake_json intake_token entry_json entry_token batch_id draft_json + local patient_name department obs_count + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + batch_id="$(create_assigned_vitals_batch "$intake_token")" || return + + entry_json="$(login entry1)" + entry_token="$(extract_data_field "$entry_json" token)" + + json_put \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \ + '{"fullName":"Chen Wei-Lin","dateOfBirth":"1985-03-15","sex":"M","noKnownAllergies":true,"noActiveMedications":true}' \ + "$entry_token" >/dev/null + + json_put \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/encounter" \ + '{"admissionDate":"2025-01-01T08:00:00Z","department":"ICU","roomBed":"ICU-3B","admissionReason":"Chest pain"}' \ + "$entry_token" >/dev/null + + json_post \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ + "{\"observationCode\":\"HEART_RATE\",\"value\":92,\"unit\":\"bpm\",\"recordedAt\":\"$RECORDED_AT\"}" \ + "$entry_token" >/dev/null + + draft_json="$(curl -sS "$API_URL/api/v1/digitization-batches/$batch_id/draft" \ + -H "Authorization: Bearer $entry_token")" + patient_name="$(jq -er '.data.patient.fullName // empty' <<<"$draft_json")" + department="$(jq -er '.data.encounter.department // empty' <<<"$draft_json")" + obs_count="$(jq -er '.data.observations | length' <<<"$draft_json")" + + if [[ "$patient_name" == "Chen Wei-Lin" && "$department" == "ICU" && "$obs_count" -ge 1 ]]; then + pass "GET /draft returns patient, encounter, and observations" + else + fail "GET /draft returns patient, encounter, and observations (patient=$patient_name dept=$department obs=$obs_count)" + fi +} + +test_complete_submit_succeeds() { + section "6. Full lifecycle — complete vitals batch submits to PENDING_VERIFICATION" + + local intake_json intake_token entry_json entry_token batch_id submit_json status + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + batch_id="$(create_assigned_vitals_batch "$intake_token")" || return + + entry_json="$(login entry1)" + entry_token="$(extract_data_field "$entry_json" token)" + + json_put \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \ + '{"fullName":"Chen Wei-Lin","dateOfBirth":"1985-03-15","sex":"M","noKnownAllergies":true,"noActiveMedications":true}' \ + "$entry_token" >/dev/null + + json_put \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/encounter" \ + '{"admissionDate":"2025-01-01T08:00:00Z","department":"ICU","admissionReason":"Chest pain"}' \ + "$entry_token" >/dev/null + + json_post \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ + "{\"observationCode\":\"HEART_RATE\",\"value\":92,\"unit\":\"bpm\",\"recordedAt\":\"$RECORDED_AT\"}" \ + "$entry_token" >/dev/null + + submit_json="$(curl -sS -X POST \ + "$API_URL/api/v1/digitization-batches/$batch_id/submit-for-verification" \ + -H "Authorization: Bearer $entry_token")" + status="$(extract_data_field "$submit_json" status)" + + if [[ "$(jq -er '.success' <<<"$submit_json")" == "true" && "$status" == "PENDING_VERIFICATION" ]]; then + pass "complete vitals batch submits successfully (PENDING_VERIFICATION)" + else + fail "complete vitals batch submits successfully (PENDING_VERIFICATION) (status=${status:-})" + fi +} + +test_observation_crud() { + section "7. Observation CRUD — add, update, delete" + + local intake_json intake_token entry_json entry_token batch_id + local add_json obs_id update_json update_value delete_code + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + batch_id="$(create_assigned_vitals_batch "$intake_token")" || return + + entry_json="$(login entry1)" + entry_token="$(extract_data_field "$entry_json" token)" + + add_json="$(json_post \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ + "{\"observationCode\":\"TEMP_C\",\"value\":37.2,\"unit\":\"C\",\"recordedAt\":\"$RECORDED_AT\",\"note\":\"Oral temperature\"}" \ + "$entry_token")" + obs_id="$(extract_data_field "$add_json" id)" + if [[ "$(jq -er '.success' <<<"$add_json")" == "true" && -n "$obs_id" ]]; then + pass "POST observation returns 201 with id" + else + fail "POST observation returns 201 with id" + return + fi + + update_json="$(json_put \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations/$obs_id" \ + "{\"observationCode\":\"TEMP_C\",\"value\":38.1,\"unit\":\"C\",\"recordedAt\":\"$RECORDED_AT\",\"note\":\"Corrected — misread decimal\"}" \ + "$entry_token")" + update_value="$(extract_data_field "$update_json" value)" + if [[ "$(jq -er '.success' <<<"$update_json")" == "true" && "$update_value" == "38.1" ]]; then + pass "PUT observation updates value (200)" + else + fail "PUT observation updates value (200) (got: ${update_value:-})" + fi + + delete_code="$(http_code -X DELETE \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations/$obs_id" \ + -H "Authorization: Bearer $entry_token")" + if [[ "$delete_code" == "204" ]]; then + pass "DELETE observation returns 204" + else + fail "DELETE observation returns 204 (got: $delete_code)" + fi +} + +test_rejected_batch_reentry() { + section "8. Rejected batch — draft save transitions back to IN_ENTRY" + + if ! psql_available; then + if [[ "$SKIP_DB_CHECKS" == "1" ]]; then + log " SKIP: rejected batch re-entry (VIGILCARE_SKIP_DB_CHECKS=1)" + else + log " SKIP: rejected batch re-entry (postgres not reachable)" + fi + return + fi + + local intake_json intake_token entry_json entry_token batch_id status event_meta + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + batch_id="$(create_assigned_vitals_batch "$intake_token")" || return + + entry_json="$(login entry1)" + entry_token="$(extract_data_field "$entry_json" token)" + + psql_query "UPDATE digitization_batches SET status = 'REJECTED', rejection_reason = 'Missing encounter details' WHERE id = '$batch_id';" >/dev/null + + json_put \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/encounter" \ + '{"admissionDate":"2025-01-01T08:00:00Z","department":"Emergency Department","roomBed":"ER-7"}' \ + "$entry_token" >/dev/null + + status="$(curl -sS "$API_URL/api/v1/digitization-batches/$batch_id" \ + -H "Authorization: Bearer $entry_token" | jq -er '.data.status')" + if [[ "$status" == "IN_ENTRY" ]]; then + pass "REJECTED batch transitions to IN_ENTRY on draft save" + else + fail "REJECTED batch transitions to IN_ENTRY on draft save (got: $status)" + fi + + event_meta="$(psql_query "SELECT metadata_json FROM digitization_events WHERE batch_id = '$batch_id' AND event_type = 'entry_started' ORDER BY occurred_at DESC LIMIT 1;")" + if [[ "$event_meta" == *"REJECTED"* ]]; then + pass "entry_started event metadata records previous REJECTED status" + else + fail "entry_started event metadata records previous REJECTED status" + fi +} + +test_verified_batch_blocks_entry() { + section "9. Verified batch — data entry blocked with ENTRY_NOT_ALLOWED" + + if ! psql_available; then + if [[ "$SKIP_DB_CHECKS" == "1" ]]; then + log " SKIP: verified batch entry guard (VIGILCARE_SKIP_DB_CHECKS=1)" + else + log " SKIP: verified batch entry guard (postgres not reachable)" + fi + return + fi + + local intake_json intake_token entry_json entry_token batch_id resp_json error_code + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + batch_id="$(create_assigned_vitals_batch "$intake_token")" || return + + entry_json="$(login entry1)" + entry_token="$(extract_data_field "$entry_json" token)" + + psql_query "UPDATE digitization_batches SET status = 'VERIFIED' WHERE id = '$batch_id';" >/dev/null + + resp_json="$(json_post \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ + "{\"observationCode\":\"HEART_RATE\",\"value\":80,\"unit\":\"bpm\",\"recordedAt\":\"$RECORDED_AT\"}" \ + "$entry_token")" + error_code="$(extract_error_code "$resp_json")" + + if [[ "$error_code" == "ENTRY_NOT_ALLOWED" ]]; then + pass "verified batch blocks observation add (ENTRY_NOT_ALLOWED)" + else + fail "verified batch blocks observation add (ENTRY_NOT_ALLOWED) (got: ${error_code:-})" + fi +} + +test_batch_not_assigned() { + section "10. Assignment guard — non-assigned clerk receives BATCH_NOT_ASSIGNED" + + local intake_json intake_token entry1_json entry2_json entry2_token batch_id entry1_id resp_json error_code + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + batch_id="$(create_assigned_vitals_batch "$intake_token")" || return + + entry1_json="$(login entry1)" + entry1_id="$(extract_data_field "$entry1_json" userId)" + entry2_json="$(login entry2)" + entry2_token="$(extract_data_field "$entry2_json" token)" + + assign_batch "$intake_token" "$batch_id" "$entry1_id" >/dev/null + + resp_json="$(json_put \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \ + '{"fullName":"Blocked Clerk","noKnownAllergies":true,"noActiveMedications":true}' \ + "$entry2_token")" + error_code="$(extract_error_code "$resp_json")" + + if [[ "$error_code" == "BATCH_NOT_ASSIGNED" ]]; then + pass "entry2 cannot save draft on batch assigned to entry1 (BATCH_NOT_ASSIGNED)" + else + fail "entry2 cannot save draft on batch assigned to entry1 (BATCH_NOT_ASSIGNED) (got: ${error_code:-})" + fi +} + +main() { + require_cmd curl + require_cmd jq + + if [[ ! -f "$FIXTURE_PDF" ]]; then + log "ERROR: missing fixture PDF at $FIXTURE_PDF" + exit 1 + fi + + log "VigilCare Records — Phase 2 verification" + log "API: $API_URL" + + assert_api_reachable + + test_draft_patient_upsert + test_status_transition_to_in_entry + test_plausibility_validation + test_incomplete_submit_rejected + test_get_draft_payload + test_complete_submit_succeeds + test_observation_crud + test_rejected_batch_reentry + test_verified_batch_blocks_entry + test_batch_not_assigned + + 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 2 verification checks passed." +} + +main "$@"