feature: Draft Data Entry
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<ApiFixture>, 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<AppDbContext>();
|
||||
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<JsonDocument>();
|
||||
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<AppDbContext>();
|
||||
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<JsonDocument>();
|
||||
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<AppDbContext>();
|
||||
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<JsonDocument>();
|
||||
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<AppDbContext>();
|
||||
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<JsonDocument>();
|
||||
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<AppDbContext>();
|
||||
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<JsonDocument>();
|
||||
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<JsonDocument>();
|
||||
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<AppDbContext>();
|
||||
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<AppDbContext>();
|
||||
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<AppDbContext>();
|
||||
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<AppDbContext>();
|
||||
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<JsonDocument>();
|
||||
body!.RootElement.GetProperty("error").GetProperty("code").GetString()
|
||||
.Should().Be("ENTRY_NOT_ALLOWED");
|
||||
}
|
||||
}
|
||||
@@ -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<Program>, 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<string, string?>
|
||||
{
|
||||
["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<AppDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
// Flush the test Redis database (db=1) to avoid cross-test cache pollution
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
await redis.GetDatabase(1).ExecuteAsync("FLUSHDB");
|
||||
}
|
||||
|
||||
public new async Task DisposeAsync()
|
||||
{
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
public static class AuthHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static async Task<HttpClient> 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<JsonDocument>();
|
||||
var token = body!.RootElement.GetProperty("data").GetProperty("token").GetString()!;
|
||||
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.10.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="3.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VigilCareRecordsAPI\VigilCareRecordsAPI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Draft data entry endpoints for digitization batches. A data entry clerk
|
||||
/// uses these endpoints to transcribe scanned paper charts into structured data.
|
||||
/// </summary>
|
||||
[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;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full draft payload for a batch, including patient demographics,
|
||||
/// encounter context, and all observation rows entered so far.
|
||||
/// </summary>
|
||||
/// <param name="batchId">Digitization batch id.</param>
|
||||
/// <returns>Complete draft data for the batch.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<DraftPayloadResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetDraft(Guid batchId)
|
||||
{
|
||||
var result = await _draft.GetDraftAsync(batchId);
|
||||
return Ok(ApiResponse<DraftPayloadResponse>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="batchId">Digitization batch id.</param>
|
||||
/// <param name="req">Patient demographic fields.</param>
|
||||
/// <returns>The upserted patient record.</returns>
|
||||
[HttpPut("patient")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DraftPatientDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> UpsertPatient(
|
||||
Guid batchId, [FromBody] UpsertDraftPatientRequest req)
|
||||
{
|
||||
var actorUserId = GetCurrentUserId();
|
||||
var result = await _draft.UpsertPatientAsync(batchId, req, actorUserId);
|
||||
return Ok(ApiResponse<DraftPatientDto>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upserts draft encounter fields for a batch. Creates the encounter record
|
||||
/// on the first call; updates it on subsequent calls.
|
||||
/// </summary>
|
||||
/// <param name="batchId">Digitization batch id.</param>
|
||||
/// <param name="req">Encounter context fields.</param>
|
||||
/// <returns>The upserted encounter record.</returns>
|
||||
[HttpPut("encounter")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DraftEncounterDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> UpsertEncounter(
|
||||
Guid batchId, [FromBody] UpsertDraftEncounterRequest req)
|
||||
{
|
||||
var actorUserId = GetCurrentUserId();
|
||||
var result = await _draft.UpsertEncounterAsync(batchId, req, actorUserId);
|
||||
return Ok(ApiResponse<DraftEncounterDto>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new observation row to the batch draft. Validates the value against
|
||||
/// plausibility ranges before saving.
|
||||
/// </summary>
|
||||
/// <param name="batchId">Digitization batch id.</param>
|
||||
/// <param name="req">Observation data to add.</param>
|
||||
/// <returns>The created observation record.</returns>
|
||||
[HttpPost("observations")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DraftObservationDto>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> AddObservation(
|
||||
Guid batchId, [FromBody] CreateDraftObservationRequest req)
|
||||
{
|
||||
var actorUserId = GetCurrentUserId();
|
||||
var result = await _draft.AddObservationAsync(batchId, req, actorUserId);
|
||||
return StatusCode(201, ApiResponse<DraftObservationDto>.Created(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing observation row in the batch draft. Re-validates the
|
||||
/// new value against plausibility ranges.
|
||||
/// </summary>
|
||||
/// <param name="batchId">Digitization batch id.</param>
|
||||
/// <param name="obsId">Observation id to update.</param>
|
||||
/// <param name="req">Updated observation data.</param>
|
||||
/// <returns>The updated observation record.</returns>
|
||||
[HttpPut("observations/{obsId:guid}")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DraftObservationDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> UpdateObservation(
|
||||
Guid batchId, Guid obsId, [FromBody] UpdateDraftObservationRequest req)
|
||||
{
|
||||
var actorUserId = GetCurrentUserId();
|
||||
var result = await _draft.UpdateObservationAsync(batchId, obsId, req, actorUserId);
|
||||
return Ok(ApiResponse<DraftObservationDto>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an observation row from the batch draft.
|
||||
/// </summary>
|
||||
/// <param name="batchId">Digitization batch id.</param>
|
||||
/// <param name="obsId">Observation id to delete.</param>
|
||||
[HttpDelete("observations/{obsId:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> DeleteObservation(Guid batchId, Guid obsId)
|
||||
{
|
||||
var actorUserId = GetCurrentUserId();
|
||||
await _draft.DeleteObservationAsync(batchId, obsId, actorUserId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates completeness per batch type and transitions the batch from
|
||||
/// IN_ENTRY to PENDING_VERIFICATION. Returns 422 if required fields are missing.
|
||||
/// </summary>
|
||||
/// <param name="batchId">Digitization batch id.</param>
|
||||
/// <returns>The updated batch record.</returns>
|
||||
[HttpPost("~/api/v1/digitization-batches/{batchId:guid}/submit-for-verification")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BatchDetailResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> SubmitForVerification(Guid batchId)
|
||||
{
|
||||
var actorUserId = GetCurrentUserId();
|
||||
var batch = await _draft.SubmitForVerificationAsync(batchId, actorUserId);
|
||||
return Ok(ApiResponse<BatchDetailResponse>.Ok(BatchDetailResponse.FromEntity(batch)));
|
||||
}
|
||||
|
||||
private Guid GetCurrentUserId() =>
|
||||
Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
public record DraftPayloadResponse(
|
||||
Guid BatchId,
|
||||
string Status,
|
||||
string BatchType,
|
||||
DraftPatientDto? Patient,
|
||||
DraftEncounterDto? Encounter,
|
||||
List<DraftObservationDto> Observations
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
public record DraftEncounterDto(
|
||||
Guid Id,
|
||||
DateTimeOffset? AdmissionDate,
|
||||
string? Department,
|
||||
string? RoomBed,
|
||||
string? AdmissionReason,
|
||||
string? DischargeDiagnosis,
|
||||
string? Status,
|
||||
DateTimeOffset UpdatedAt
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
public record UpsertDraftEncounterRequest(
|
||||
DateTimeOffset? AdmissionDate,
|
||||
string? Department,
|
||||
string? RoomBed,
|
||||
string? AdmissionReason,
|
||||
string? DischargeDiagnosis,
|
||||
string? Status
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
public record CreateDraftObservationRequest(
|
||||
string ObservationCode,
|
||||
decimal Value,
|
||||
string Unit,
|
||||
DateTimeOffset RecordedAt,
|
||||
string? Note
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
public record DraftObservationDto(
|
||||
Guid Id,
|
||||
string ObservationCode,
|
||||
decimal Value,
|
||||
string Unit,
|
||||
DateTimeOffset RecordedAt,
|
||||
string? Note,
|
||||
DateTimeOffset CreatedAt
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
public record UpdateDraftObservationRequest(
|
||||
string ObservationCode,
|
||||
decimal Value,
|
||||
string Unit,
|
||||
DateTimeOffset RecordedAt,
|
||||
string? Note
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
public record DraftPatientDto(
|
||||
Guid Id,
|
||||
string? FullName,
|
||||
DateOnly? DateOfBirth,
|
||||
string? Sex,
|
||||
string? BloodType,
|
||||
string? EmergencyContact,
|
||||
List<string>? Allergies,
|
||||
bool NoKnownAllergies,
|
||||
List<string>? Medications,
|
||||
bool NoActiveMedications,
|
||||
DateTimeOffset UpdatedAt
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
public record UpsertDraftPatientRequest(
|
||||
string? FullName,
|
||||
DateOnly? DateOfBirth,
|
||||
string? Sex,
|
||||
string? BloodType,
|
||||
string? EmergencyContact,
|
||||
List<string>? Allergies,
|
||||
bool NoKnownAllergies,
|
||||
List<string>? Medications,
|
||||
bool NoActiveMedications
|
||||
);
|
||||
@@ -22,8 +22,8 @@ try
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
|
||||
// Redis
|
||||
builder.Services.AddSingleton<IConnectionMultiplexer>(
|
||||
ConnectionMultiplexer.Connect(builder.Configuration["Redis:ConnectionString"]!));
|
||||
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
|
||||
ConnectionMultiplexer.Connect(sp.GetRequiredService<IConfiguration>()["Redis:ConnectionString"]!));
|
||||
|
||||
// MinIO
|
||||
var minioOptions = builder.Configuration.GetSection(MinioOptions.Section).Get<MinioOptions>()!;
|
||||
@@ -54,6 +54,7 @@ try
|
||||
// Services
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IBatchService, BatchService>();
|
||||
builder.Services.AddScoped<IDraftService, DraftService>();
|
||||
builder.Services.AddScoped<IDocumentStorageService, DocumentStorageService>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class DraftService : IDraftService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ILogger<DraftService> _logger;
|
||||
|
||||
// Statuses that allow data entry to begin or continue
|
||||
private static readonly HashSet<BatchStatus> _entryAllowedStatuses = new()
|
||||
{
|
||||
BatchStatus.Uploaded,
|
||||
BatchStatus.InEntry,
|
||||
BatchStatus.Rejected
|
||||
};
|
||||
|
||||
public DraftService(AppDbContext db, ILogger<DraftService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<DraftPayloadResponse> 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<DraftPatientDto> 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<DraftEncounterDto> 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<DraftObservationDto> 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<DraftObservationDto> 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<DigitizationBatch> 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 ─────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<DigitizationBatch> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transitions the batch from Uploaded or Rejected to InEntry on first save.
|
||||
/// If the batch is already InEntry, this is a no-op.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates batch-type-specific completeness rules. Throws ValidationException
|
||||
/// with a descriptive message listing all missing fields.
|
||||
/// </summary>
|
||||
private static void ValidateCompleteness(DigitizationBatch batch)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<List<string>>(p.AllergiesJson)
|
||||
: null,
|
||||
p.NoKnownAllergies,
|
||||
!string.IsNullOrWhiteSpace(p.MedicationsJson)
|
||||
? JsonSerializer.Deserialize<List<string>>(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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
public interface IDraftService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the full draft payload for a batch, including patient, encounter,
|
||||
/// and all observation rows entered so far.
|
||||
/// </summary>
|
||||
Task<DraftPayloadResponse> GetDraftAsync(Guid batchId);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<DraftPatientDto> UpsertPatientAsync(Guid batchId, UpsertDraftPatientRequest req, Guid actorUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Upserts draft encounter fields for a batch. Creates the DraftEncounter
|
||||
/// row on the first call; updates it on subsequent calls.
|
||||
/// </summary>
|
||||
Task<DraftEncounterDto> UpsertEncounterAsync(Guid batchId, UpsertDraftEncounterRequest req, Guid actorUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new observation row to the batch draft. Validates plausibility
|
||||
/// before saving — implausible values throw ValidationException.
|
||||
/// </summary>
|
||||
Task<DraftObservationDto> AddObservationAsync(Guid batchId, CreateDraftObservationRequest req, Guid actorUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Edits an existing observation row. Re-validates plausibility on the new value.
|
||||
/// </summary>
|
||||
Task<DraftObservationDto> UpdateObservationAsync(Guid batchId, Guid observationId, UpdateDraftObservationRequest req, Guid actorUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an observation row from the batch draft.
|
||||
/// </summary>
|
||||
Task DeleteObservationAsync(Guid batchId, Guid observationId, Guid actorUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Validates completeness per batch type and transitions the batch to
|
||||
/// PendingVerification. Throws ValidationException if required fields
|
||||
/// are missing.
|
||||
/// </summary>
|
||||
Task<DigitizationBatch> SubmitForVerificationAsync(Guid batchId, Guid actorUserId);
|
||||
}
|
||||
@@ -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<string, (decimal Min, decimal Max)> _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
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the value falls within the plausible range for the given code.
|
||||
/// Unknown observation codes pass plausibility — the code is validated elsewhere.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all known observation codes and their plausible ranges.
|
||||
/// Used by the frontend to display valid input boundaries.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, (decimal Min, decimal Max)> GetAllRanges() =>
|
||||
_ranges;
|
||||
}
|
||||
+618
@@ -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: <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
|
||||
|
||||
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:-<none>})"
|
||||
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:-<none>})"
|
||||
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:-<none>})"
|
||||
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:-<none>})"
|
||||
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:-<none>})"
|
||||
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:-<none>})"
|
||||
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:-<none>})"
|
||||
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 "$@"
|
||||
Reference in New Issue
Block a user