diff --git a/README.md b/README.md
index 99a429f..abb8990 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# VigilCare Records API
-A clinical records intake backend built with ASP.NET Core 8, PostgreSQL, MinIO, and Redis. The domain models the scan-to-approved lifecycle at the center of any paper chart digitization system: scanned document upload, human data entry, dual-human verification with separation of duties, site-configurable clinical approval routing, and batch status machine enforcement.
+A clinical records intake backend built with ASP.NET Core 8, PostgreSQL, MinIO, and Redis. The domain models the scan-to-approved lifecycle at the center of any paper chart digitization system: scanned document upload, human data entry, dual-human verification with separation of duties, site-configurable clinical approval routing, atomic promotion to live clinical tables, and governed correction via supersession.
-**Implementation status:** Three planned phases are complete through Phase 3 — from schema, authentication, and batch CRUD through draft data entry and verification/rejection with separation of duties and work queues. See [Implemented Phases](#implemented-phases) for the full breakdown.
+**Implementation status:** Five planned phases are complete through Phase 5 — from schema, authentication, and batch CRUD through draft data entry, verification/rejection with separation of duties, approval with atomic promotion to VigilCareClinical live tables, and correction batches that supersede erroneous promoted observations without silent edits. See [Implemented Phases](#implemented-phases) for the full breakdown.
## Domain Model — How It Maps to a Real Clinical System
@@ -45,12 +45,16 @@ Append-only audit log entry for every state transition, field-level correction,
## Features
-- **Document Upload and Batch Creation** — multipart upload (PDF, JPEG, PNG; max 25 MB) with MinIO storage, SHA-256 integrity hash, presigned GET URLs (15-minute expiry); duplicate detection per patient within 24 hours by SHA-256 (`409 DUPLICATE_DOCUMENT`); cross-patient duplicate scans allowed; batch created in `UPLOADED` status with `DigitizationEvent` audit trail
+- **Document Upload and Batch Creation** — multipart upload (PDF, JPEG, PNG; max 25 MB) with MinIO storage, SHA-256 integrity hash, presigned GET URLs (15-minute expiry); duplicate detection per patient within 24 hours by SHA-256 (`409 DUPLICATE_DOCUMENT`); cross-patient duplicate scans allowed; optional `supersedesBatchId` creates a correction batch linked to a promoted batch; batch created in `UPLOADED` status with `DigitizationEvent` audit trail
- **Batch Assignment** — `PATCH /digitization-batches/:id/assign` assigns an entry clerk with a Redis lock (`SET batch:assign:{id} NX EX 3600`) to prevent double-assignment; `409 BATCH_ALREADY_ASSIGNED` on conflict
- **Draft Data Entry** — full CRUD on draft payload: upsert patient demographics, upsert encounter context, add/edit/delete observation rows; plausibility validation on observation values at draft save time (catches decimal errors before verification); automatic `UPLOADED → IN_ENTRY` transition on first save; draft save requires the acting user to match `enteredByUserId` or hold `Administrator` role (`409 BATCH_NOT_ASSIGNED`)
- **Submit for Verification** — validates completeness per batch type (e.g. vitals sheet requires linked patient, encounter context, and at least one observation with `recordedAt`); transitions `IN_ENTRY → PENDING_VERIFICATION`; returns `422` with missing fields if incomplete
- **Verification and Rejection** — verifier reviews entry against the scan with field-level checks (`fieldName`, `status: ok|warning|error`, optional `note`); verify pass transitions to `VERIFIED` or `AWAITING_CLINICAL_APPROVAL` based on site configuration for the batch type; verify fail transitions to `REJECTED` with mandatory reason; **separation of duties** enforced: entry clerk cannot verify their own batch (`409 SEPARATION_OF_DUTIES_VIOLATION`)
- **Clinical Approval Routing** — site-configurable per batch type (`SiteConfig.ClinicalApprovalRequired`); high-stakes batch types (encounter summary, vitals sheet, lab results, medication list, mixed) route to `AWAITING_CLINICAL_APPROVAL` after verification for physician sign-off; low-stakes types (patient registration, allergy update) go directly to `VERIFIED`
+- **Approval and Promotion** — `POST /digitization-batches/:id/approve` atomically promotes draft data to live VigilCareClinical tables (`patients`, `encounters`, `observations`) in a single PostgreSQL transaction; generates MRN via PostgreSQL sequence (`VCR-000001`); patient deduplication by name + DOB; encounter matching by patient + department + active status; writes `observation.created` outbox events for downstream consumers; separation of duties enforced (approver cannot be entry clerk or verifier); `Idempotency-Key` header required for safe retries with 24-hour TTL; retroactive alert policy (`enableRetroactiveAlerts`) controls whether backfill observations emit outbox events
+- **Promotion Result Query** — `GET /digitization-batches/:id/promotion-result` returns live entity IDs (patient, MRN, encounter, observations) created during promotion
+- **Corrections and Supersession** — approved live observations are never silently edited; a correction uploads a new batch with `supersedesBatchId`, goes through the full entry → verify → approve cycle, and on promotion marks the original batch's `live_observations` rows as `is_superseded` (append-only — never deleted); `422 SUPERSEDED_BATCH_NOT_PROMOTED`, `409 BATCH_ALREADY_SUPERSEDED`, and `404 SUPERSEDED_BATCH_NOT_FOUND` guard invalid supersession targets; correction batches inherit patient context and reuse the original promotion encounter
+- **Patient Digitization History** — `GET /patients/:id/digitization-history` returns all batches for a patient with correction chain metadata (`isCorrection`, `hasBeenSuperseded`, `supersededByBatchId`), live vs superseded observation counts, summary totals, and per-batch audit trails; `404 PATIENT_HISTORY_NOT_FOUND` when no batches exist for the patient
- **Work Queues** — `GET /work-queue/verification` (batches in `PENDING_VERIFICATION`, sorted by submission time ASC); `GET /work-queue/entry` (batches in `UPLOADED`, `IN_ENTRY`, or `REJECTED`); `GET /work-queue/clinical-approval` (batches in `AWAITING_CLINICAL_APPROVAL`); role-restricted access
- **Batch Status Machine** — explicit transition matrix enforced in the service layer; illegal transitions return `409`; `PROMOTED` is terminal — corrections require a new batch with `supersedesBatchId`
- **JWT Authentication** — `POST /auth/login` returns access token (15 min) and refresh token (7 days); `POST /auth/refresh` rotates tokens; `POST /auth/logout` revokes server-side; `GET /auth/me` returns authenticated user profile; BCrypt password hashing
@@ -76,25 +80,29 @@ HTTP request
├── BatchService (batch CRUD, status machine, Redis assignment lock, duplicate detection)
├── DraftService (draft patient/encounter/observation CRUD, plausibility validation, submit-for-verification)
├── VerificationService (verify/reject with separation of duties, site-config approval routing)
+ ├── PromotionService (approve + atomic promote to live tables, patient dedup, encounter matching, outbox events, supersession on correction promotion)
+ ├── DigitizationHistoryService (patient digitization history with correction chain and audit trails)
+ ├── IdempotencyService (Idempotency-Key storage + replay for safe promotion retries)
+ ├── MrnGenerator (PostgreSQL sequence-backed MRN generation: VCR-000001)
├── WorkQueueService (verification, entry, clinical approval queues)
├── DocumentStorageService (MinIO upload, SHA-256, presigned URLs)
├── PlausibilityValidator (per-code numeric range guard)
- ├── PostgreSQL (EF Core — writes, keyed reads)
+ ├── PostgreSQL (EF Core — writes, keyed reads, clinical schema for live tables)
├── Redis (batch assignment locks)
└── MinIO (scanned document storage)
```
-**Relationship to VigilCareClinical:** VigilCare Records is the precursor that creates the fuel — patient identity, encounters, and observations — in a governed, auditable way. Draft observations are invisible to VigilCareClinical's alert engine, Elasticsearch ward projection, and scoring consumers. Only batches that pass approval are promoted to live tables. Promotion (Phase 4) will write directly to VigilCareClinical's `patients`, `encounters`, `observations`, and `outbox_events` tables in a single transaction.
+**Relationship to VigilCareClinical:** VigilCare Records is the precursor that creates the fuel — patient identity, encounters, and observations — in a governed, auditable way. Draft observations are invisible to VigilCareClinical's alert engine, Elasticsearch ward projection, and scoring consumers. Only batches that pass approval are promoted to live tables. Promotion writes directly to VigilCareClinical's `patients`, `encounters`, `observations`, and `outbox_events` tables in a single atomic transaction with idempotency protection.
```
┌─────────────────────────────────────────────────────────────────────┐
│ VigilCare Records (this project) │
│ │
-│ Scan → Entry → Verify → Approve │
+│ Scan → Entry → Verify → Approve → Promote │
│ ↓ │
│ Draft tables (never alert) │
-│ ↓ on approval (Phase 4) │
-│ Promotion service ──────────────────────────────────────────────┐ │
+│ ↓ on approval │
+│ PromotionService (atomic txn + idempotency) ────────────────────┐ │
└──────────────────────────────────────────────────────────────────│──┘
│
┌──────────────────────────────────────────────────────────────────▼──┐
@@ -129,6 +137,7 @@ VigilCareRecordsAPI/
├── Program.cs # Service registration, middleware, seed on startup
├── appsettings.json # Connection strings, Redis, MinIO, Seq, JWT, site config
├── Controllers/
+│ ├── ApprovalController.cs # Batch approval and promotion to live clinical tables
│ ├── AuthController.cs # JWT login, token refresh, logout, authenticated user profile
│ ├── DigitizationBatchesController.cs # Batch CRUD, document upload, assignment
│ ├── DraftController.cs # Draft data entry: patient, encounter, observations, submit
@@ -136,10 +145,17 @@ VigilCareRecordsAPI/
│ └── WorkQueueController.cs # Work queues: verification, entry, clinical approval
├── Domain/
│ ├── Entities/
+│ │ ├── Clinical/
+│ │ │ ├── Patient.cs # Live patient record with MRN (promoted from draft)
+│ │ │ ├── Encounter.cs # Live encounter (promoted from draft)
+│ │ │ ├── Observation.cs # Live observation with source traceability (batchId, draftObsId)
+│ │ │ ├── OutboxEvent.cs # Transactional outbox for downstream consumers (Kafka)
+│ │ │ └── IdempotencyRecord.cs # Idempotency-Key storage for safe promotion retries
+│ │ ├── Draft/
+│ │ │ ├── DraftPatient.cs # Structured patient demographics from paper chart
+│ │ │ ├── DraftEncounter.cs # Encounter context: admission, department, room/bed
+│ │ │ └── DraftObservation.cs # Single measurement: code, value, unit, recordedAt
│ │ ├── DigitizationBatch.cs # Central workflow entity with status machine
-│ │ ├── DraftPatient.cs # Structured patient demographics from paper chart
-│ │ ├── DraftEncounter.cs # Encounter context: admission, department, room/bed
-│ │ ├── DraftObservation.cs # Single measurement: code, value, unit, recordedAt
│ │ ├── ScannedDocument.cs # MinIO object reference with SHA-256 integrity hash
│ │ ├── DigitizationEvent.cs # Append-only audit trail for every status transition
│ │ ├── User.cs # Username, BCrypt hash, full name, role, active flag
@@ -154,11 +170,14 @@ VigilCareRecordsAPI/
│ ├── BloodType.cs # A+, A-, B+, B-, AB+, AB-, O+, O-
│ └── Department.cs # Clinical departments
├── Services/
-│ ├── Interfaces/ # IAuthService, IBatchService, IDraftService, IVerificationService, IWorkQueueService, IDocumentStorageService
+│ ├── Interfaces/ # IAuthService, IBatchService, IDraftService, IVerificationService, IWorkQueueService, IDocumentStorageService, IPromotionService, IIdempotencyService, IMrnGenerator
│ ├── AuthService.cs # Login (BCrypt verify), JWT generation, refresh rotation, logout, audit logging
│ ├── BatchService.cs # Batch CRUD, status machine transitions, Redis assignment lock, duplicate detection
│ ├── DraftService.cs # Draft CRUD with plausibility validation, batch-type completeness on submit
│ ├── VerificationService.cs # Verify/reject with separation of duties, site-config clinical approval routing
+│ ├── PromotionService.cs # Atomic approve + promote: draft → live tables in single transaction
+│ ├── IdempotencyService.cs # Idempotency-Key record storage + replay (24h TTL)
+│ ├── MrnGenerator.cs # PostgreSQL sequence-backed MRN generation (VCR-000001)
│ ├── WorkQueueService.cs # Verification, entry, and clinical approval work queues
│ ├── DocumentStorageService.cs # MinIO upload with SHA-256, presigned URL generation
│ └── PlausibilityValidator.cs # Per-code numeric range guard (reused from VigilCareClinical)
@@ -169,6 +188,7 @@ VigilCareRecordsAPI/
├── Models/Records/
│ ├── Auth/ # LoginRequest, LoginResponse, RefreshRequest, LogoutRequest, TokenResponse, UserProfileResponse
│ ├── Batch/ # CreateBatchForm, CreateBatchRequest, AssignBatchRequest, BatchDetailResponse, BatchListResponse, DraftPayloadResponse, VerifyBatchRequest, RejectBatchRequest, FieldCheck
+│ ├── Promotion/ # ApproveRequest, PromotionResultResponse
│ ├── DraftPatient/ # DraftPatientDto, UpsertDraftPatientRequest
│ ├── DraftEncounter/ # DraftEncounterDto, UpsertDraftEncounterRequest
│ ├── DraftObservation/ # DraftObservationDto, CreateDraftObservationRequest, UpdateDraftObservationRequest
@@ -176,10 +196,13 @@ VigilCareRecordsAPI/
│ └── Common/ # PagedResult
├── Data/
│ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints
-│ ├── Configurations/ # IEntityTypeConfiguration per entity (snake_case mapping)
+│ ├── Configurations/
+│ │ ├── Clinical/ # PatientConfiguration, EncounterConfiguration, ObservationConfiguration, OutboxEventConfiguration, IdempotencyRecordConfiguration
+│ │ ├── Draft/ # DraftPatientConfiguration, DraftEncounterConfiguration, DraftObservationConfiguration
+│ │ └── ... # DigitizationBatch, DigitizationEvent, ScannedDocument, User, RefreshToken, AuthAuditEvent configs
│ ├── Seed/
│ │ └── DataSeeder.cs # Seeds 12 demo users (two per role) on startup
-│ └── Migrations/ # InitialCreate, InitialSchema, AddRefreshTokensAndAuthAudit
+│ └── Migrations/ # InitialCreate, InitialSchema, AddRefreshTokensAndAuthAudit, AddIdempotencyRecords, AddClinicalSchemaAndPromotion, AddMrnSequence
├── Common/
│ ├── ApiResponse.cs # { success, statusCode, data, error } envelope
│ └── Exceptions/
@@ -199,18 +222,21 @@ tests/
└── VigilCareRecordsAPI.Tests/
├── DraftEntryTests.cs # Draft CRUD, plausibility validation, submit-for-verification completeness
├── VerificationTests.cs # Verification, rejection, separation of duties, clinical approval routing
+ ├── PromotionTests.cs # Approval, atomic promotion, idempotency, separation of duties, retroactive alerts, patient dedup
├── Fixtures/
│ ├── ApiFixture.cs # WebApplicationFactory with PostgreSQL, Redis, MinIO containers
│ └── DatabaseCollection.cs # Shared test collection
└── Helpers/
├── AuthHelper.cs # JWT token generation for test users
├── BatchSeedHelper.cs # Creates seeded batches at various lifecycle stages
+ ├── BatchPipelineHelper.cs # End-to-end batch pipeline: upload → entry → verify → ready for approval
└── DbResetHelper.cs # Database cleanup between tests
scripts/
├── run-vigilcare-records-verification.sh # Phase 1 — schema, auth, roles, batch CRUD, MinIO, status machine
├── run-vigilcare-records-phase-2-verification.sh # Phase 2 — draft entry, plausibility, submit-for-verification
-└── run-vigilcare-records-phase-3-verification.sh # Phase 3 — verification, rejection, separation of duties, work queues
+├── run-vigilcare-records-phase-3-verification.sh # Phase 3 — verification, rejection, separation of duties, work queues
+└── run-vigilcare-records-phase-4-verification.sh # Phase 4 — approval, atomic promotion, idempotency, patient dedup, outbox events
docs/
├── plans/ # Phase 1–9 implementation and verification guides
@@ -360,6 +386,7 @@ Integration tests use `WebApplicationFactory` with PostgreSQL, Redis, and MinIO
|---|---|---|
| `DraftEntryTests` | 2 | Draft CRUD, plausibility validation, submit-for-verification completeness checks |
| `VerificationTests` | 3 | Verification, rejection, separation of duties enforcement, clinical approval routing |
+| `PromotionTests` | 4 | Approval, atomic promotion to live tables, idempotency replay, separation of duties, retroactive alert policy, patient deduplication, MRN generation |
### Verification Scripts
@@ -369,6 +396,7 @@ With the API running (`dotnet run`) and Docker Compose up:
./scripts/run-vigilcare-records-verification.sh # Phase 1 — schema, auth, batch CRUD, MinIO, status machine
./scripts/run-vigilcare-records-phase-2-verification.sh # Phase 2 — draft entry, plausibility, submit
./scripts/run-vigilcare-records-phase-3-verification.sh # Phase 3 — verification, rejection, separation of duties
+./scripts/run-vigilcare-records-phase-4-verification.sh # Phase 4 — approval, promotion, idempotency, patient dedup
```
---
@@ -534,6 +562,54 @@ Error response:
|---|---|---|---|
| `reason` | string | yes | Rejection reason (minimum 10 characters) |
+### Approval and Promotion
+
+| Method | Path | Auth | Description |
+|---|---|---|---|
+| POST | `/digitization-batches/{id}/approve` | Clinical Approver, Administrator | Approve and atomically promote draft data to live clinical tables |
+| GET | `/digitization-batches/{id}/promotion-result` | Any authenticated | Retrieve live entity IDs created during promotion |
+
+**POST `/approve` headers:**
+
+| Header | Required | Description |
+|---|---|---|
+| `Idempotency-Key` | yes | Unique key (max 100 chars) for safe retries; replays return the original response within 24 hours |
+
+**POST `/approve` body:**
+
+| Field | Type | Required | Description |
+|---|---|---|---|
+| `enableRetroactiveAlerts` | bool | no | Default `false`. When `true`, backfill observations emit outbox events for downstream alerting. Live capture batches always emit outbox events regardless of this flag. |
+
+**Promotion response (`PromotionResultResponse`):**
+
+| Field | Type | Description |
+|---|---|---|
+| `batchId` | Guid | The promoted batch |
+| `status` | string | `promoted` |
+| `patientId` | Guid | Live patient ID (created or matched) |
+| `mrn` | string | Medical Record Number (e.g. `VCR-000001`) |
+| `encounterId` | Guid | Live encounter ID (created or matched) |
+| `observationIds` | Guid[] | Live observation IDs created |
+| `promotedAt` | DateTimeOffset | Promotion timestamp |
+| `outboxEventsWritten` | int | Number of outbox events emitted for downstream consumers |
+
+**Status codes:**
+
+| Code | Meaning |
+|---|---|
+| 200 | Batch approved and promoted (or idempotent replay) |
+| 400 | Missing or invalid `Idempotency-Key` header |
+| 404 | Batch not found |
+| 409 | Illegal status transition or separation of duties violation |
+| 422 | Missing draft patient data |
+
+**Separation of duties:** The approver cannot be the entry clerk (`enteredByUserId`) or the verifier (`verifiedByUserId`) of the same batch. Both checks return `409 SEPARATION_OF_DUTIES_VIOLATION`.
+
+**Patient deduplication:** On promotion, the service matches existing patients by `fullName` + `dateOfBirth`. If a match is found, the existing patient record is updated with any new fields from the draft. Otherwise, a new patient is created with a sequence-generated MRN (`VCR-NNNNNN`).
+
+**Encounter matching:** Active encounters for the same patient and department are reused. Otherwise, a new encounter is created. Encounters with a discharge diagnosis are created with `discharged` status.
+
### Work Queues
| Method | Path | Auth | Description |
@@ -641,6 +717,81 @@ occurredAt DateTimeOffset
metadataJson string? optional JSON (field checks, rejection reason, assigned-to)
```
+### Patient (Clinical — Live)
+
+```
+id Guid PK
+mrn string required, unique — generated from PostgreSQL sequence (VCR-000001)
+fullName string required
+dateOfBirth DateOnly?
+sex string?
+bloodType BloodType?
+emergencyContact string?
+allergiesJson string?
+noKnownAllergies bool
+createdAt DateTimeOffset
+updatedAt DateTimeOffset
+```
+
+### Encounter (Clinical — Live)
+
+```
+id Guid PK
+patientId Guid FK → Patient
+admissionDate DateTimeOffset?
+department Department?
+roomBed string?
+admissionReason string?
+dischargeDiagnosis string?
+status string active | discharged
+sourceBatchId Guid? FK → DigitizationBatch (traceability)
+createdAt DateTimeOffset
+updatedAt DateTimeOffset
+```
+
+### Observation (Clinical — Live)
+
+```
+id Guid PK
+encounterId Guid FK → Encounter
+patientId Guid FK → Patient
+observationCode string required (e.g. HEART_RATE, TEMP_C)
+value decimal required
+unit string required
+recordedAt DateTimeOffset required
+note string?
+source string digitization_backfill | live_capture
+sourceDraftObservationId Guid? FK → DraftObservation (traceability)
+sourceBatchId Guid? FK → DigitizationBatch (traceability)
+createdAt DateTimeOffset
+```
+
+### OutboxEvent
+
+```
+id Guid PK
+eventType string e.g. observation.created
+aggregateType string e.g. Observation
+aggregateId Guid FK → the created entity
+payloadJson string full event payload for downstream consumers
+createdAt DateTimeOffset
+processedAt DateTimeOffset? set when consumed
+retryCount int default 0
+```
+
+### IdempotencyRecord
+
+```
+id Guid PK
+idempotencyKey string required, unique (from Idempotency-Key header)
+operationName string e.g. batch_promote
+resourceId Guid the batch ID
+httpStatusCode int original response code
+responseBodyJson string serialized original response
+createdAt DateTimeOffset
+expiresAt DateTimeOffset 24-hour TTL
+```
+
### User
```
@@ -703,14 +854,14 @@ Response shape:
## Implemented Phases
-Three phases from the project roadmap are implemented and verified. Integration tests (`dotnet test`) and per-phase verification scripts cover Phases 1–3.
+Four phases from the project roadmap are implemented and verified. Integration tests (`dotnet test`) and per-phase verification scripts cover Phases 1–4.
| Phase | Feature | Status |
|---|---|---|
| 1 | Schema, EF Core migrations, JWT authentication with refresh tokens, six user roles, batch CRUD, MinIO upload with SHA-256 and presigned URLs, batch status machine with transition matrix, duplicate document detection, Redis batch assignment locks, twelve seeded demo users, auth audit events | Done |
| 2 | Draft data entry API (patient demographics, encounter context, observations), plausibility validation on observation values, batch-type completeness validation on submit-for-verification, automatic `UPLOADED → IN_ENTRY` transition on first save, assignment guard (`BATCH_NOT_ASSIGNED`), `DraftEntryTests` integration tests | Done |
| 3 | Verification with field-level checks (`ok`, `warning`, `error` per field), rejection with mandatory reason (min 10 chars), separation of duties enforcement (`SEPARATION_OF_DUTIES_VIOLATION`), site-configurable clinical approval routing per batch type, work queue endpoints (verification, entry, clinical approval) with role-based access, `VerificationTests` integration tests | Done |
-| 4 | Approval and promotion to VigilCareClinical live tables, outbox integration, idempotent promotion, retroactive alert policy | Planned |
+| 4 | Approval with three-person separation of duties (entry ≠ verifier ≠ approver), atomic promotion to live `patients`/`encounters`/`observations` tables in a single PostgreSQL transaction, PostgreSQL sequence-backed MRN generation (`VCR-NNNNNN`), patient dedup by name + DOB, encounter matching by patient + department + active status, transactional outbox (`observation.created` events), retroactive alert policy per batch, `Idempotency-Key` header with 24h TTL for safe retries, `PromotionTests` integration tests, Phase 4 verification script | Done |
| 5 | Corrections and supersession — new batch replaces old, superseded observations soft-flagged | Planned |
| 6 | Track B live capture with clinician attestation, synchronous alert evaluation | Planned |
| 7 | Digitization workstation UI (Vue 3 side-by-side scan viewer + entry form) | Planned |
diff --git a/VigilCareRecordsAPI.Tests/CorrectionSupersessionTests.cs b/VigilCareRecordsAPI.Tests/CorrectionSupersessionTests.cs
new file mode 100644
index 0000000..4fb9e33
--- /dev/null
+++ b/VigilCareRecordsAPI.Tests/CorrectionSupersessionTests.cs
@@ -0,0 +1,178 @@
+using System.Net;
+using System.Net.Http.Json;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+///
+/// Integration tests for correction supersession via the full HTTP pipeline.
+///
+[Collection("Database")]
+public class CorrectionSupersessionTests : IAsyncLifetime
+{
+ private readonly ApiFixture _fixture;
+
+ public CorrectionSupersessionTests(ApiFixture fixture) => _fixture = fixture;
+
+ public async Task InitializeAsync()
+ {
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await DbResetHelper.ResetAsync(db);
+ await DataSeeder.SeedAsync(db);
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ [Fact]
+ public async Task WrongPotassium_CorrectionBatch_SupersedesOriginal()
+ {
+ // Act 1: promote original batch with wrong potassium (3.5)
+ var (originalBatchId, patientId) =
+ await CorrectionPipelineHelper.PromoteLabBatchAsync(_fixture, potassiumValue: 3.5m);
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var liveObsAfterOriginal = await db.LiveObservations
+ .Where(o => o.SourceBatchId == originalBatchId)
+ .ToListAsync();
+
+ liveObsAfterOriginal.Should().HaveCount(2);
+ liveObsAfterOriginal.First(o => o.ObservationCode == "K").Value.Should().Be(3.5m);
+
+ // Act 2: promote correction batch with correct potassium (5.3)
+ var correctionBatchId = await CorrectionPipelineHelper.PromoteCorrectionBatchAsync(
+ _fixture, originalBatchId, patientId, potassiumValue: 5.3m);
+
+ db.ChangeTracker.Clear();
+
+ // Assert: original observations superseded, correction observations live
+ var originalObs = await db.LiveObservations
+ .Where(o => o.SourceBatchId == originalBatchId)
+ .ToListAsync();
+ originalObs.Should().AllSatisfy(o =>
+ {
+ o.IsSuperseded.Should().BeTrue();
+ o.SupersededByBatchId.Should().Be(correctionBatchId);
+ });
+ originalObs.First(o => o.ObservationCode == "K").Value.Should().Be(3.5m,
+ "original erroneous value is preserved for audit");
+
+ var correctionObs = await db.LiveObservations
+ .Where(o => o.SourceBatchId == correctionBatchId && !o.IsSuperseded)
+ .ToListAsync();
+ correctionObs.Should().HaveCount(2);
+ correctionObs.First(o => o.ObservationCode == "K").Value.Should().Be(5.3m);
+
+ var allObs = await db.LiveObservations.Where(o => o.PatientId == patientId).ToListAsync();
+ allObs.Should().HaveCount(4, "2 original (superseded) + 2 correction (active)");
+
+ var originalEvents = await db.DigitizationEvents
+ .Where(e => e.BatchId == originalBatchId)
+ .ToListAsync();
+ originalEvents.Should().Contain(e => e.EventType == DigitizationEventType.Superseded);
+ }
+
+ [Fact]
+ public async Task CannotSupersede_NonPromotedBatch()
+ {
+ // Arrange: batch still in PendingVerification
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var entry1Id = await BatchSeedHelper.UserIdAsync(db, "entry1");
+ var pendingBatch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync(db, entry1Id);
+
+ // Act: attempt correction upload against non-promoted batch
+ var client = await AuthHelper.LoginAsync(_fixture, "intake1");
+ var fileContent = new ByteArrayContent([0x25, 0x50, 0x44, 0x46]);
+ fileContent.Headers.ContentType =
+ new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
+ var formData = new MultipartFormDataContent
+ {
+ { fileContent, "file", "test.pdf" },
+ { new StringContent("LAB_RESULTS"), "batchType" },
+ { new StringContent("BACKFILL"), "track" },
+ { new StringContent(pendingBatch.Id.ToString()), "supersedesBatchId" }
+ };
+
+ var response = await client.PostAsync("/api/v1/digitization-batches", formData);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
+ var body = await response.Content.ReadAsStringAsync();
+ body.Should().Contain("Only promoted batches can be superseded");
+ }
+
+ [Fact]
+ public async Task CannotSupersede_AlreadySupersededBatch()
+ {
+ // Arrange: promote original + one correction (supersedes original)
+ var (originalBatchId, patientId) =
+ await CorrectionPipelineHelper.PromoteLabBatchAsync(_fixture, potassiumValue: 100m);
+ await CorrectionPipelineHelper.PromoteCorrectionBatchAsync(
+ _fixture, originalBatchId, patientId, potassiumValue: 102m);
+
+ // Act: attempt second correction against the already-superseded original
+ var client = await AuthHelper.LoginAsync(_fixture, "intake1");
+ var fileContent = new ByteArrayContent([0x25, 0x50, 0x44, 0x46]);
+ fileContent.Headers.ContentType =
+ new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
+ var formData = new MultipartFormDataContent
+ {
+ { fileContent, "file", "test2.pdf" },
+ { new StringContent("LAB_RESULTS"), "batchType" },
+ { new StringContent("BACKFILL"), "track" },
+ { new StringContent(patientId.ToString()), "patientId" },
+ { new StringContent(originalBatchId.ToString()), "supersedesBatchId" }
+ };
+
+ var response = await client.PostAsync("/api/v1/digitization-batches", formData);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Conflict);
+ var body = await response.Content.ReadAsStringAsync();
+ body.Should().Contain("already been superseded");
+ }
+
+ [Fact]
+ public async Task PatientDigitizationHistory_ShowsFullCorrectionChain()
+ {
+ // Arrange: promote original + correction
+ var (originalBatchId, patientId) =
+ await CorrectionPipelineHelper.PromoteLabBatchAsync(_fixture, potassiumValue: 3.5m);
+ var correctionBatchId = await CorrectionPipelineHelper.PromoteCorrectionBatchAsync(
+ _fixture, originalBatchId, patientId, potassiumValue: 5.3m);
+
+ // Act
+ var client = await AuthHelper.LoginAsync(_fixture, "admin1");
+ var response = await client.GetAsync(
+ $"/api/v1/patients/{patientId}/digitization-history");
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var result = await response.Content
+ .ReadFromJsonAsync>();
+
+ result!.Data!.TotalBatches.Should().Be(2);
+ result.Data.SupersededBatches.Should().Be(1);
+
+ var originalEntry = result.Data.Entries.First(e => e.BatchId == originalBatchId);
+ originalEntry.HasBeenSuperseded.Should().BeTrue();
+ originalEntry.SupersededByBatchId.Should().Be(correctionBatchId);
+
+ var correctionEntry = result.Data.Entries.First(e => e.BatchId == correctionBatchId);
+ correctionEntry.IsCorrection.Should().BeTrue();
+ correctionEntry.SupersedesBatchId.Should().Be(originalBatchId);
+ }
+
+ [Fact]
+ public async Task PatientHistory_ReturnsNotFound_ForUnknownPatient()
+ {
+ var client = await AuthHelper.LoginAsync(_fixture, "admin1");
+ var response = await client.GetAsync(
+ $"/api/v1/patients/{Guid.NewGuid()}/digitization-history");
+
+ response.StatusCode.Should().Be(HttpStatusCode.NotFound);
+ }
+}
\ No newline at end of file
diff --git a/VigilCareRecordsAPI.Tests/Helpers/CorrectionPipelineHelper.cs b/VigilCareRecordsAPI.Tests/Helpers/CorrectionPipelineHelper.cs
new file mode 100644
index 0000000..bb0d181
--- /dev/null
+++ b/VigilCareRecordsAPI.Tests/Helpers/CorrectionPipelineHelper.cs
@@ -0,0 +1,148 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+///
+/// Drives lab-result batches with potassium observations through the HTTP pipeline.
+/// Uses DataSeeder usernames (intake1, entry1, verifier1, approver1).
+///
+public static class CorrectionPipelineHelper
+{
+ ///
+ /// Creates a LAB_RESULTS batch with K and Na draft observations, drives it through
+ /// verify → approve, and returns the promoted batch ID and live patient ID.
+ ///
+ public static async Task<(Guid BatchId, Guid PatientId)> PromoteLabBatchAsync(
+ ApiFixture fixture,
+ decimal potassiumValue,
+ decimal sodiumValue = 140m)
+ {
+ var batchId = await CreateLabBatchThroughVerificationAsync(fixture, potassiumValue, sodiumValue);
+
+ var client = await AuthHelper.LoginAsync(fixture, "approver1");
+ client.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString());
+
+ var approveResponse = await client.PostAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/approve",
+ new { enableRetroactiveAlerts = false });
+ approveResponse.EnsureSuccessStatusCode();
+
+ using var scope = fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var batch = await db.DigitizationBatches.AsNoTracking().FirstAsync(b => b.Id == batchId);
+
+ return (batchId, batch.PatientId!.Value);
+ }
+
+ ///
+ /// Uploads a correction batch linked to a promoted batch, drives entry → verify → approve.
+ ///
+ public static async Task PromoteCorrectionBatchAsync(
+ ApiFixture fixture,
+ Guid supersedesBatchId,
+ Guid patientId,
+ decimal potassiumValue,
+ decimal sodiumValue = 140m)
+ {
+ var client = await AuthHelper.LoginAsync(fixture, "intake1");
+
+ var fileContent = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes("%PDF-correction"));
+ fileContent.Headers.ContentType =
+ new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
+
+ var formData = new MultipartFormDataContent
+ {
+ { fileContent, "file", $"correction-{Guid.NewGuid()}.pdf" },
+ { new StringContent("LAB_RESULTS"), "batchType" },
+ { new StringContent("BACKFILL"), "track" },
+ { new StringContent(patientId.ToString()), "patientId" },
+ { new StringContent(supersedesBatchId.ToString()), "supersedesBatchId" }
+ };
+
+ var uploadResponse = await client.PostAsync("/api/v1/digitization-batches", formData);
+ uploadResponse.EnsureSuccessStatusCode();
+
+ var uploadBody = await uploadResponse.Content.ReadFromJsonAsync();
+ var correctionBatchId = uploadBody.GetProperty("data").GetProperty("id").GetGuid();
+
+ // Data entry — correction re-enters all observations
+ client = await AuthHelper.LoginAsync(fixture, "entry2");
+ await client.PostAsJsonAsync(
+ $"/api/v1/digitization-batches/{correctionBatchId}/draft/observations",
+ new { observationCode = "K", value = potassiumValue, unit = "mmol/L",
+ recordedAt = DateTimeOffset.UtcNow });
+ await client.PostAsJsonAsync(
+ $"/api/v1/digitization-batches/{correctionBatchId}/draft/observations",
+ new { observationCode = "Na", value = sodiumValue, unit = "mmol/L",
+ recordedAt = DateTimeOffset.UtcNow });
+ var submitResponse = await client.PostAsync(
+ $"/api/v1/digitization-batches/{correctionBatchId}/submit-for-verification", null);
+ submitResponse.EnsureSuccessStatusCode();
+
+ // Verify and approve
+ client = await AuthHelper.LoginAsync(fixture, "verifier2");
+ var verifyResponse = await client.PostAsJsonAsync(
+ $"/api/v1/digitization-batches/{correctionBatchId}/verify",
+ new { fieldChecks = new[] { new { fieldName = "observation.K", status = "ok", note = (string?)null } }, passed = true });
+ verifyResponse.EnsureSuccessStatusCode();
+
+ client = await AuthHelper.LoginAsync(fixture, "approver2");
+ client.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString());
+ var approveResponse = await client.PostAsJsonAsync(
+ $"/api/v1/digitization-batches/{correctionBatchId}/approve",
+ new { enableRetroactiveAlerts = false });
+ approveResponse.EnsureSuccessStatusCode();
+
+ return correctionBatchId;
+ }
+
+ private static async Task CreateLabBatchThroughVerificationAsync(
+ ApiFixture fixture, decimal potassiumValue, decimal sodiumValue)
+ {
+ var client = await AuthHelper.LoginAsync(fixture, "intake1");
+
+ var fileContent = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes("%PDF-lab"));
+ fileContent.Headers.ContentType =
+ new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
+
+ var formData = new MultipartFormDataContent
+ {
+ { fileContent, "file", $"lab-{Guid.NewGuid()}.pdf" },
+ { new StringContent("LAB_RESULTS"), "batchType" },
+ { new StringContent("BACKFILL"), "track" }
+ };
+
+ var uploadResponse = await client.PostAsync("/api/v1/digitization-batches", formData);
+ uploadResponse.EnsureSuccessStatusCode();
+
+ var uploadBody = await uploadResponse.Content.ReadFromJsonAsync();
+ var batchId = uploadBody.GetProperty("data").GetProperty("id").GetGuid();
+
+ client = await AuthHelper.LoginAsync(fixture, "entry1");
+ await client.PutAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/draft/patient",
+ new { fullName = "Correction Test Patient", dateOfBirth = "1980-01-15", sex = "Female" });
+ await client.PutAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/draft/encounter",
+ new { admissionDate = "2024-06-01T08:00:00Z", department = Department.InternalMedicine.ToDbString(),
+ roomBed = "4A-12", admissionReason = "Electrolyte panel", status = "active" });
+ await client.PostAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/draft/observations",
+ new { observationCode = "K", value = potassiumValue, unit = "mmol/L",
+ recordedAt = DateTimeOffset.UtcNow });
+ await client.PostAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/draft/observations",
+ new { observationCode = "Na", value = sodiumValue, unit = "mmol/L",
+ recordedAt = DateTimeOffset.UtcNow });
+ await client.PostAsync(
+ $"/api/v1/digitization-batches/{batchId}/submit-for-verification", null);
+
+ client = await AuthHelper.LoginAsync(fixture, "verifier1");
+ await client.PostAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/verify",
+ new { fieldChecks = new[] { new { fieldName = "observation.K", status = "ok", note = (string?)null } }, passed = true });
+
+ return batchId;
+ }
+}
\ No newline at end of file
diff --git a/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs b/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs
index 7dffd83..4cc6319 100644
--- a/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs
+++ b/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs
@@ -7,7 +7,8 @@ public static class DbResetHelper
public static async Task ResetAsync(AppDbContext db)
{
await db.Database.ExecuteSqlRawAsync(@"
- TRUNCATE TABLE clinical.outbox_events, clinical.observations,
+ TRUNCATE TABLE live_observations, live_encounters,
+ clinical.outbox_events, clinical.observations,
clinical.encounters, clinical.patients,
idempotency_records,
digitization_events, draft_observations,
diff --git a/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs b/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs
index 517b08b..344e856 100644
--- a/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs
+++ b/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs
@@ -14,28 +14,36 @@ public class DigitizationBatchesController : ControllerBase
{
private readonly IBatchService _batches;
private readonly IDocumentStorageService _storage;
+ private readonly IPromotionService _promotion;
private static readonly HashSet _allowedMimeTypes = new()
{
"application/pdf", "image/jpeg", "image/png"
};
- public DigitizationBatchesController(IBatchService batches, IDocumentStorageService storage)
+ public DigitizationBatchesController(
+ IBatchService batches,
+ IDocumentStorageService storage,
+ IPromotionService promotion)
{
_batches = batches;
_storage = storage;
+ _promotion = promotion;
}
///
/// Uploads a scanned document and creates a new digitization batch.
+ /// When supersedesBatchId is provided, the batch is treated as a correction
+ /// that will supersede the erroneous promoted batch upon its own promotion.
///
[HttpPost]
- [Consumes("multipart/form-data")]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ApiResponse