feature: Corrections and Supersession
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
# VigilCare Records API
|
# 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
|
## 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
|
## 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
|
- **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`)
|
- **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
|
- **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`)
|
- **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`
|
- **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
|
- **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`
|
- **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
|
- **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)
|
├── BatchService (batch CRUD, status machine, Redis assignment lock, duplicate detection)
|
||||||
├── DraftService (draft patient/encounter/observation CRUD, plausibility validation, submit-for-verification)
|
├── DraftService (draft patient/encounter/observation CRUD, plausibility validation, submit-for-verification)
|
||||||
├── VerificationService (verify/reject with separation of duties, site-config approval routing)
|
├── 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)
|
├── WorkQueueService (verification, entry, clinical approval queues)
|
||||||
├── DocumentStorageService (MinIO upload, SHA-256, presigned URLs)
|
├── DocumentStorageService (MinIO upload, SHA-256, presigned URLs)
|
||||||
├── PlausibilityValidator (per-code numeric range guard)
|
├── 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)
|
├── Redis (batch assignment locks)
|
||||||
└── MinIO (scanned document storage)
|
└── 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) │
|
│ VigilCare Records (this project) │
|
||||||
│ │
|
│ │
|
||||||
│ Scan → Entry → Verify → Approve │
|
│ Scan → Entry → Verify → Approve → Promote │
|
||||||
│ ↓ │
|
│ ↓ │
|
||||||
│ Draft tables (never alert) │
|
│ Draft tables (never alert) │
|
||||||
│ ↓ on approval (Phase 4) │
|
│ ↓ on approval │
|
||||||
│ Promotion service ──────────────────────────────────────────────┐ │
|
│ PromotionService (atomic txn + idempotency) ────────────────────┐ │
|
||||||
└──────────────────────────────────────────────────────────────────│──┘
|
└──────────────────────────────────────────────────────────────────│──┘
|
||||||
│
|
│
|
||||||
┌──────────────────────────────────────────────────────────────────▼──┐
|
┌──────────────────────────────────────────────────────────────────▼──┐
|
||||||
@@ -129,6 +137,7 @@ VigilCareRecordsAPI/
|
|||||||
├── Program.cs # Service registration, middleware, seed on startup
|
├── Program.cs # Service registration, middleware, seed on startup
|
||||||
├── appsettings.json # Connection strings, Redis, MinIO, Seq, JWT, site config
|
├── appsettings.json # Connection strings, Redis, MinIO, Seq, JWT, site config
|
||||||
├── Controllers/
|
├── Controllers/
|
||||||
|
│ ├── ApprovalController.cs # Batch approval and promotion to live clinical tables
|
||||||
│ ├── AuthController.cs # JWT login, token refresh, logout, authenticated user profile
|
│ ├── AuthController.cs # JWT login, token refresh, logout, authenticated user profile
|
||||||
│ ├── DigitizationBatchesController.cs # Batch CRUD, document upload, assignment
|
│ ├── DigitizationBatchesController.cs # Batch CRUD, document upload, assignment
|
||||||
│ ├── DraftController.cs # Draft data entry: patient, encounter, observations, submit
|
│ ├── DraftController.cs # Draft data entry: patient, encounter, observations, submit
|
||||||
@@ -136,10 +145,17 @@ VigilCareRecordsAPI/
|
|||||||
│ └── WorkQueueController.cs # Work queues: verification, entry, clinical approval
|
│ └── WorkQueueController.cs # Work queues: verification, entry, clinical approval
|
||||||
├── Domain/
|
├── Domain/
|
||||||
│ ├── Entities/
|
│ ├── 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
|
│ │ ├── 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
|
│ │ ├── ScannedDocument.cs # MinIO object reference with SHA-256 integrity hash
|
||||||
│ │ ├── DigitizationEvent.cs # Append-only audit trail for every status transition
|
│ │ ├── DigitizationEvent.cs # Append-only audit trail for every status transition
|
||||||
│ │ ├── User.cs # Username, BCrypt hash, full name, role, active flag
|
│ │ ├── 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-
|
│ ├── BloodType.cs # A+, A-, B+, B-, AB+, AB-, O+, O-
|
||||||
│ └── Department.cs # Clinical departments
|
│ └── Department.cs # Clinical departments
|
||||||
├── Services/
|
├── 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
|
│ ├── AuthService.cs # Login (BCrypt verify), JWT generation, refresh rotation, logout, audit logging
|
||||||
│ ├── BatchService.cs # Batch CRUD, status machine transitions, Redis assignment lock, duplicate detection
|
│ ├── BatchService.cs # Batch CRUD, status machine transitions, Redis assignment lock, duplicate detection
|
||||||
│ ├── DraftService.cs # Draft CRUD with plausibility validation, batch-type completeness on submit
|
│ ├── 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
|
│ ├── 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
|
│ ├── WorkQueueService.cs # Verification, entry, and clinical approval work queues
|
||||||
│ ├── DocumentStorageService.cs # MinIO upload with SHA-256, presigned URL generation
|
│ ├── DocumentStorageService.cs # MinIO upload with SHA-256, presigned URL generation
|
||||||
│ └── PlausibilityValidator.cs # Per-code numeric range guard (reused from VigilCareClinical)
|
│ └── PlausibilityValidator.cs # Per-code numeric range guard (reused from VigilCareClinical)
|
||||||
@@ -169,6 +188,7 @@ VigilCareRecordsAPI/
|
|||||||
├── Models/Records/
|
├── Models/Records/
|
||||||
│ ├── Auth/ # LoginRequest, LoginResponse, RefreshRequest, LogoutRequest, TokenResponse, UserProfileResponse
|
│ ├── Auth/ # LoginRequest, LoginResponse, RefreshRequest, LogoutRequest, TokenResponse, UserProfileResponse
|
||||||
│ ├── Batch/ # CreateBatchForm, CreateBatchRequest, AssignBatchRequest, BatchDetailResponse, BatchListResponse, DraftPayloadResponse, VerifyBatchRequest, RejectBatchRequest, FieldCheck
|
│ ├── Batch/ # CreateBatchForm, CreateBatchRequest, AssignBatchRequest, BatchDetailResponse, BatchListResponse, DraftPayloadResponse, VerifyBatchRequest, RejectBatchRequest, FieldCheck
|
||||||
|
│ ├── Promotion/ # ApproveRequest, PromotionResultResponse
|
||||||
│ ├── DraftPatient/ # DraftPatientDto, UpsertDraftPatientRequest
|
│ ├── DraftPatient/ # DraftPatientDto, UpsertDraftPatientRequest
|
||||||
│ ├── DraftEncounter/ # DraftEncounterDto, UpsertDraftEncounterRequest
|
│ ├── DraftEncounter/ # DraftEncounterDto, UpsertDraftEncounterRequest
|
||||||
│ ├── DraftObservation/ # DraftObservationDto, CreateDraftObservationRequest, UpdateDraftObservationRequest
|
│ ├── DraftObservation/ # DraftObservationDto, CreateDraftObservationRequest, UpdateDraftObservationRequest
|
||||||
@@ -176,10 +196,13 @@ VigilCareRecordsAPI/
|
|||||||
│ └── Common/ # PagedResult
|
│ └── Common/ # PagedResult
|
||||||
├── Data/
|
├── Data/
|
||||||
│ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints
|
│ ├── 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/
|
│ ├── Seed/
|
||||||
│ │ └── DataSeeder.cs # Seeds 12 demo users (two per role) on startup
|
│ │ └── DataSeeder.cs # Seeds 12 demo users (two per role) on startup
|
||||||
│ └── Migrations/ # InitialCreate, InitialSchema, AddRefreshTokensAndAuthAudit
|
│ └── Migrations/ # InitialCreate, InitialSchema, AddRefreshTokensAndAuthAudit, AddIdempotencyRecords, AddClinicalSchemaAndPromotion, AddMrnSequence
|
||||||
├── Common/
|
├── Common/
|
||||||
│ ├── ApiResponse.cs # { success, statusCode, data, error } envelope
|
│ ├── ApiResponse.cs # { success, statusCode, data, error } envelope
|
||||||
│ └── Exceptions/
|
│ └── Exceptions/
|
||||||
@@ -199,18 +222,21 @@ tests/
|
|||||||
└── VigilCareRecordsAPI.Tests/
|
└── VigilCareRecordsAPI.Tests/
|
||||||
├── DraftEntryTests.cs # Draft CRUD, plausibility validation, submit-for-verification completeness
|
├── DraftEntryTests.cs # Draft CRUD, plausibility validation, submit-for-verification completeness
|
||||||
├── VerificationTests.cs # Verification, rejection, separation of duties, clinical approval routing
|
├── VerificationTests.cs # Verification, rejection, separation of duties, clinical approval routing
|
||||||
|
├── PromotionTests.cs # Approval, atomic promotion, idempotency, separation of duties, retroactive alerts, patient dedup
|
||||||
├── Fixtures/
|
├── Fixtures/
|
||||||
│ ├── ApiFixture.cs # WebApplicationFactory with PostgreSQL, Redis, MinIO containers
|
│ ├── ApiFixture.cs # WebApplicationFactory with PostgreSQL, Redis, MinIO containers
|
||||||
│ └── DatabaseCollection.cs # Shared test collection
|
│ └── DatabaseCollection.cs # Shared test collection
|
||||||
└── Helpers/
|
└── Helpers/
|
||||||
├── AuthHelper.cs # JWT token generation for test users
|
├── AuthHelper.cs # JWT token generation for test users
|
||||||
├── BatchSeedHelper.cs # Creates seeded batches at various lifecycle stages
|
├── 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
|
└── DbResetHelper.cs # Database cleanup between tests
|
||||||
|
|
||||||
scripts/
|
scripts/
|
||||||
├── run-vigilcare-records-verification.sh # Phase 1 — schema, auth, roles, batch CRUD, MinIO, status machine
|
├── 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-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/
|
docs/
|
||||||
├── plans/ # Phase 1–9 implementation and verification guides
|
├── 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 |
|
| `DraftEntryTests` | 2 | Draft CRUD, plausibility validation, submit-for-verification completeness checks |
|
||||||
| `VerificationTests` | 3 | Verification, rejection, separation of duties enforcement, clinical approval routing |
|
| `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
|
### 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-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-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-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) |
|
| `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
|
### Work Queues
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
@@ -641,6 +717,81 @@ occurredAt DateTimeOffset
|
|||||||
metadataJson string? optional JSON (field checks, rejection reason, assigned-to)
|
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
|
### User
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -703,14 +854,14 @@ Response shape:
|
|||||||
|
|
||||||
## Implemented Phases
|
## 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 7 | Digitization workstation UI (Vue 3 side-by-side scan viewer + entry form) | Planned |
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for correction supersession via the full HTTP pipeline.
|
||||||
|
/// </summary>
|
||||||
|
[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<AppDbContext>();
|
||||||
|
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<AppDbContext>();
|
||||||
|
|
||||||
|
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<AppDbContext>();
|
||||||
|
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<ApiResponse<PatientDigitizationHistoryResponse>>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives lab-result batches with potassium observations through the HTTP pipeline.
|
||||||
|
/// Uses DataSeeder usernames (intake1, entry1, verifier1, approver1).
|
||||||
|
/// </summary>
|
||||||
|
public static class CorrectionPipelineHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<AppDbContext>();
|
||||||
|
var batch = await db.DigitizationBatches.AsNoTracking().FirstAsync(b => b.Id == batchId);
|
||||||
|
|
||||||
|
return (batchId, batch.PatientId!.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uploads a correction batch linked to a promoted batch, drives entry → verify → approve.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<Guid> 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<JsonElement>();
|
||||||
|
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<Guid> 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<JsonElement>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ public static class DbResetHelper
|
|||||||
public static async Task ResetAsync(AppDbContext db)
|
public static async Task ResetAsync(AppDbContext db)
|
||||||
{
|
{
|
||||||
await db.Database.ExecuteSqlRawAsync(@"
|
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,
|
clinical.encounters, clinical.patients,
|
||||||
idempotency_records,
|
idempotency_records,
|
||||||
digitization_events, draft_observations,
|
digitization_events, draft_observations,
|
||||||
|
|||||||
@@ -14,28 +14,36 @@ public class DigitizationBatchesController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly IBatchService _batches;
|
private readonly IBatchService _batches;
|
||||||
private readonly IDocumentStorageService _storage;
|
private readonly IDocumentStorageService _storage;
|
||||||
|
private readonly IPromotionService _promotion;
|
||||||
|
|
||||||
private static readonly HashSet<string> _allowedMimeTypes = new()
|
private static readonly HashSet<string> _allowedMimeTypes = new()
|
||||||
{
|
{
|
||||||
"application/pdf", "image/jpeg", "image/png"
|
"application/pdf", "image/jpeg", "image/png"
|
||||||
};
|
};
|
||||||
|
|
||||||
public DigitizationBatchesController(IBatchService batches, IDocumentStorageService storage)
|
public DigitizationBatchesController(
|
||||||
|
IBatchService batches,
|
||||||
|
IDocumentStorageService storage,
|
||||||
|
IPromotionService promotion)
|
||||||
{
|
{
|
||||||
_batches = batches;
|
_batches = batches;
|
||||||
_storage = storage;
|
_storage = storage;
|
||||||
|
_promotion = promotion;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Uploads a scanned document and creates a new digitization batch.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Consumes("multipart/form-data")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<BatchDetailResponse>), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(ApiResponse<BatchDetailResponse>), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||||
[RequestSizeLimit(25 * 1024 * 1024)]
|
[RequestSizeLimit(25 * 1024 * 1024)]
|
||||||
|
[Consumes("multipart/form-data")]
|
||||||
public async Task<IActionResult> Create([FromForm] CreateBatchForm form)
|
public async Task<IActionResult> Create([FromForm] CreateBatchForm form)
|
||||||
{
|
{
|
||||||
if (form.File is null || form.File.Length == 0)
|
if (form.File is null || form.File.Length == 0)
|
||||||
@@ -45,17 +53,19 @@ public class DigitizationBatchesController : ControllerBase
|
|||||||
return BadRequest(ApiResponse<object>.Fail(400,
|
return BadRequest(ApiResponse<object>.Fail(400,
|
||||||
"Accepted formats: PDF, JPEG, PNG.", "INVALID_MIME_TYPE"));
|
"Accepted formats: PDF, JPEG, PNG.", "INVALID_MIME_TYPE"));
|
||||||
|
|
||||||
var req = form.ToMetadata();
|
var parsedBatchType = BatchTypeExtensions.FromDbString(form.BatchType.ToUpperInvariant());
|
||||||
var parsedBatchType = BatchTypeExtensions.FromDbString(req.BatchType.ToUpperInvariant());
|
var parsedTrack = string.IsNullOrEmpty(form.Track)
|
||||||
var parsedTrack = string.IsNullOrEmpty(req.Track)
|
|
||||||
? BatchTrack.Backfill
|
? BatchTrack.Backfill
|
||||||
: BatchTrackExtensions.FromDbString(req.Track.ToUpperInvariant());
|
: BatchTrackExtensions.FromDbString(form.Track.ToUpperInvariant());
|
||||||
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
using var stream = form.File.OpenReadStream();
|
using var stream = form.File.OpenReadStream();
|
||||||
var batch = await _batches.CreateAsync(
|
var result = await _batches.CreateAsync(
|
||||||
stream, form.File.ContentType, parsedBatchType, parsedTrack, req.PatientId, actorUserId);
|
stream, form.File.ContentType, parsedBatchType, parsedTrack,
|
||||||
return StatusCode(201, ApiResponse<BatchDetailResponse>.Created(BatchDetailResponse.FromEntity(batch)));
|
form.PatientId, form.SupersedesBatchId, actorUserId);
|
||||||
|
|
||||||
|
return StatusCode(201, ApiResponse<BatchDetailResponse>.Created(
|
||||||
|
BatchDetailResponse.FromEntity(result.Batch, supersession: result.Supersession)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -115,4 +125,21 @@ public class DigitizationBatchesController : ControllerBase
|
|||||||
var batch = await _batches.AssignAsync(id, req.EntryClerkUserId, actorUserId);
|
var batch = await _batches.AssignAsync(id, req.EntryClerkUserId, actorUserId);
|
||||||
return Ok(ApiResponse<BatchDetailResponse>.Ok(BatchDetailResponse.FromEntity(batch)));
|
return Ok(ApiResponse<BatchDetailResponse>.Ok(BatchDetailResponse.FromEntity(batch)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Promotes an approved batch to live clinical data. For correction batches,
|
||||||
|
/// marks the original batch's live observations as superseded (append-only).
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("{id:guid}/promote")]
|
||||||
|
[Authorize(Roles = "CLINICAL_APPROVER,ADMINISTRATOR")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<PromotionResult>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<IActionResult> Promote(Guid id)
|
||||||
|
{
|
||||||
|
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
var result = await _promotion.PromoteAsync(id, actorUserId);
|
||||||
|
return Ok(ApiResponse<PromotionResult>.Ok(result));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Patient-scoped endpoints for digitization history and audit trail.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/patients")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Authorize]
|
||||||
|
public class PatientsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDigitizationHistoryService _history;
|
||||||
|
|
||||||
|
public PatientsController(IDigitizationHistoryService history) =>
|
||||||
|
_history = history;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the complete digitization history for a patient, including all
|
||||||
|
/// batches, their promotion status, correction chains, and per-batch audit trails.
|
||||||
|
/// Superseded observations are included with their supersession metadata.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{patientId:guid}/digitization-history")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<PatientDigitizationHistoryResponse>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetDigitizationHistory(Guid patientId)
|
||||||
|
{
|
||||||
|
var history = await _history.GetPatientHistoryAsync(patientId);
|
||||||
|
return Ok(ApiResponse<PatientDigitizationHistoryResponse>.Ok(history));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ public class AppDbContext : DbContext
|
|||||||
public DbSet<Encounter> Encounters => Set<Encounter>();
|
public DbSet<Encounter> Encounters => Set<Encounter>();
|
||||||
public DbSet<Observation> Observations => Set<Observation>();
|
public DbSet<Observation> Observations => Set<Observation>();
|
||||||
public DbSet<OutboxEvent> OutboxEvents => Set<OutboxEvent>();
|
public DbSet<OutboxEvent> OutboxEvents => Set<OutboxEvent>();
|
||||||
|
public DbSet<LiveEncounter> LiveEncounters => Set<LiveEncounter>();
|
||||||
|
public DbSet<LiveObservation> LiveObservations => Set<LiveObservation>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
public class LiveEncounterConfiguration : IEntityTypeConfiguration<LiveEncounter>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<LiveEncounter> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("live_encounters", t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("chk_live_encounters_department",
|
||||||
|
"department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')");
|
||||||
|
});
|
||||||
|
builder.HasKey(e => e.Id);
|
||||||
|
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
builder.Property(e => e.PatientId).HasColumnName("patient_id").IsRequired();
|
||||||
|
builder.Property(e => e.AdmissionDate).HasColumnName("admission_date").IsRequired();
|
||||||
|
builder.Property(e => e.Department)
|
||||||
|
.HasColumnName("department")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasConversion(
|
||||||
|
v => v.HasValue ? v.Value.ToDbString() : null,
|
||||||
|
v => v == null ? null : DepartmentExtensions.FromDbString(v));
|
||||||
|
builder.Property(e => e.RoomBed).HasColumnName("room_bed").HasMaxLength(50);
|
||||||
|
builder.Property(e => e.AdmissionReason).HasColumnName("admission_reason").HasMaxLength(500);
|
||||||
|
builder.Property(e => e.DischargeDiagnosis).HasColumnName("discharge_diagnosis").HasMaxLength(500);
|
||||||
|
builder.Property(e => e.Status).HasColumnName("status").HasMaxLength(20).IsRequired();
|
||||||
|
builder.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||||
|
|
||||||
|
builder.HasOne<Patient>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(e => e.PatientId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasIndex(e => new { e.PatientId, e.Status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
public class LiveObservationConfiguration : IEntityTypeConfiguration<LiveObservation>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<LiveObservation> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("live_observations");
|
||||||
|
builder.HasKey(o => o.Id);
|
||||||
|
builder.Property(o => o.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
builder.Property(o => o.EncounterId).HasColumnName("encounter_id").IsRequired();
|
||||||
|
builder.Property(o => o.PatientId).HasColumnName("patient_id");
|
||||||
|
builder.Property(o => o.SourceBatchId).HasColumnName("source_batch_id").IsRequired();
|
||||||
|
builder.Property(o => o.ObservationCode).HasColumnName("observation_code").HasMaxLength(50).IsRequired();
|
||||||
|
builder.Property(o => o.Value).HasColumnName("value").HasColumnType("decimal(10,3)").IsRequired();
|
||||||
|
builder.Property(o => o.Unit).HasColumnName("unit").HasMaxLength(20).IsRequired();
|
||||||
|
builder.Property(o => o.RecordedAt).HasColumnName("recorded_at").IsRequired();
|
||||||
|
builder.Property(o => o.Note).HasColumnName("note").HasMaxLength(500);
|
||||||
|
builder.Property(o => o.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||||
|
|
||||||
|
// Phase 5: Supersession columns
|
||||||
|
builder.Property(o => o.IsSuperseded).HasColumnName("is_superseded").HasDefaultValue(false);
|
||||||
|
builder.Property(o => o.SupersededByBatchId).HasColumnName("superseded_by_batch_id");
|
||||||
|
builder.Property(o => o.SupersededAt).HasColumnName("superseded_at");
|
||||||
|
|
||||||
|
builder.HasOne(o => o.Encounter)
|
||||||
|
.WithMany(e => e.Observations)
|
||||||
|
.HasForeignKey(o => o.EncounterId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasIndex(o => new { o.EncounterId, o.ObservationCode });
|
||||||
|
builder.HasIndex(o => o.SourceBatchId);
|
||||||
|
builder.HasIndex(o => o.IsSuperseded).HasFilter("is_superseded = true");
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1229
File diff suppressed because it is too large
Load Diff
+102
@@ -0,0 +1,102 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace VigilCareRecordsAPI.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLiveEncountersAndObservations : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "live_encounters",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
|
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
admission_date = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
department = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
room_bed = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
admission_reason = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
discharge_diagnosis = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_live_encounters", x => x.id);
|
||||||
|
table.CheckConstraint("chk_live_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_live_encounters_patients_patient_id",
|
||||||
|
column: x => x.patient_id,
|
||||||
|
principalSchema: "clinical",
|
||||||
|
principalTable: "patients",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "live_observations",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
|
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
patient_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
source_batch_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
value = table.Column<decimal>(type: "numeric(10,3)", nullable: false),
|
||||||
|
unit = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
recorded_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
note = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||||
|
is_superseded = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||||
|
superseded_by_batch_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
superseded_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_live_observations", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_live_observations_live_encounters_encounter_id",
|
||||||
|
column: x => x.encounter_id,
|
||||||
|
principalTable: "live_encounters",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_live_encounters_patient_id_status",
|
||||||
|
table: "live_encounters",
|
||||||
|
columns: new[] { "patient_id", "status" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_live_observations_encounter_id_observation_code",
|
||||||
|
table: "live_observations",
|
||||||
|
columns: new[] { "encounter_id", "observation_code" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_live_observations_is_superseded",
|
||||||
|
table: "live_observations",
|
||||||
|
column: "is_superseded",
|
||||||
|
filter: "is_superseded = true");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_live_observations_source_batch_id",
|
||||||
|
table: "live_observations",
|
||||||
|
column: "source_batch_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "live_observations");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "live_encounters");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1229
File diff suppressed because it is too large
Load Diff
+22
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace VigilCareRecordsAPI.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLiveObservationAndLiveEncounter : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -547,6 +547,141 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
|||||||
b.ToTable("idempotency_records", (string)null);
|
b.ToTable("idempotency_records", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveEncounter", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id")
|
||||||
|
.HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("AdmissionDate")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("admission_date");
|
||||||
|
|
||||||
|
b.Property<string>("AdmissionReason")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)")
|
||||||
|
.HasColumnName("admission_reason");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasDefaultValueSql("NOW()");
|
||||||
|
|
||||||
|
b.Property<string>("Department")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)")
|
||||||
|
.HasColumnName("department");
|
||||||
|
|
||||||
|
b.Property<string>("DischargeDiagnosis")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)")
|
||||||
|
.HasColumnName("discharge_diagnosis");
|
||||||
|
|
||||||
|
b.Property<Guid>("PatientId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("patient_id");
|
||||||
|
|
||||||
|
b.Property<string>("RoomBed")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)")
|
||||||
|
.HasColumnName("room_bed");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("PatientId", "Status");
|
||||||
|
|
||||||
|
b.ToTable("live_encounters", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("chk_live_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveObservation", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id")
|
||||||
|
.HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasDefaultValueSql("NOW()");
|
||||||
|
|
||||||
|
b.Property<Guid>("EncounterId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("encounter_id");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSuperseded")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("is_superseded");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)")
|
||||||
|
.HasColumnName("note");
|
||||||
|
|
||||||
|
b.Property<string>("ObservationCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)")
|
||||||
|
.HasColumnName("observation_code");
|
||||||
|
|
||||||
|
b.Property<Guid?>("PatientId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("patient_id");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("RecordedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("recorded_at");
|
||||||
|
|
||||||
|
b.Property<Guid>("SourceBatchId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("source_batch_id");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("SupersededAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("superseded_at");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SupersededByBatchId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("superseded_by_batch_id");
|
||||||
|
|
||||||
|
b.Property<string>("Unit")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasColumnName("unit");
|
||||||
|
|
||||||
|
b.Property<decimal>("Value")
|
||||||
|
.HasColumnType("decimal(10,3)")
|
||||||
|
.HasColumnName("value");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("IsSuperseded")
|
||||||
|
.HasFilter("is_superseded = true");
|
||||||
|
|
||||||
|
b.HasIndex("SourceBatchId");
|
||||||
|
|
||||||
|
b.HasIndex("EncounterId", "ObservationCode");
|
||||||
|
|
||||||
|
b.ToTable("live_observations", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Observation", b =>
|
modelBuilder.Entity("Observation", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1000,6 +1135,26 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
|||||||
b.Navigation("Patient");
|
b.Navigation("Patient");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveEncounter", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Patient", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PatientId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveObservation", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("LiveEncounter", "Encounter")
|
||||||
|
.WithMany("Observations")
|
||||||
|
.HasForeignKey("EncounterId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Encounter");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Observation", b =>
|
modelBuilder.Entity("Observation", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Encounter", "Encounter")
|
b.HasOne("Encounter", "Encounter")
|
||||||
@@ -1060,6 +1215,11 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
|||||||
|
|
||||||
b.Navigation("Events");
|
b.Navigation("Events");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveEncounter", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Observations");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
public class LiveEncounter
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid PatientId { get; set; }
|
||||||
|
public DateTimeOffset AdmissionDate { get; set; }
|
||||||
|
public Department? Department { get; set; }
|
||||||
|
public string? RoomBed { get; set; }
|
||||||
|
public string? AdmissionReason { get; set; }
|
||||||
|
public string? DischargeDiagnosis { get; set; }
|
||||||
|
public string Status { get; set; } = "active";
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public ICollection<LiveObservation> Observations { get; set; } = new List<LiveObservation>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
public class LiveObservation
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid EncounterId { get; set; }
|
||||||
|
public Guid? PatientId { get; set; }
|
||||||
|
public Guid SourceBatchId { get; set; }
|
||||||
|
public string ObservationCode { get; set; } = null!;
|
||||||
|
public decimal Value { get; set; }
|
||||||
|
public string Unit { get; set; } = null!;
|
||||||
|
public DateTimeOffset RecordedAt { get; set; }
|
||||||
|
public string? Note { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
|
||||||
|
// Phase 5: Supersession fields
|
||||||
|
public bool IsSuperseded { get; set; }
|
||||||
|
public Guid? SupersededByBatchId { get; set; }
|
||||||
|
public DateTimeOffset? SupersededAt { get; set; }
|
||||||
|
|
||||||
|
public LiveEncounter Encounter { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -19,11 +19,16 @@ public record BatchDetailResponse(
|
|||||||
Guid? PromotionEncounterId,
|
Guid? PromotionEncounterId,
|
||||||
Guid? SupersedesBatchId,
|
Guid? SupersedesBatchId,
|
||||||
bool ClinicianAttestation,
|
bool ClinicianAttestation,
|
||||||
|
bool IsCorrection,
|
||||||
|
SupersessionInfo? Supersession,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
DateTimeOffset UpdatedAt
|
DateTimeOffset UpdatedAt
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
public static BatchDetailResponse FromEntity(DigitizationBatch batch, string? documentUrl = null) =>
|
public static BatchDetailResponse FromEntity(
|
||||||
|
DigitizationBatch batch,
|
||||||
|
string? documentUrl = null,
|
||||||
|
SupersessionInfo? supersession = null) =>
|
||||||
new(
|
new(
|
||||||
batch.Id,
|
batch.Id,
|
||||||
batch.Status.ToDbString(),
|
batch.Status.ToDbString(),
|
||||||
@@ -41,7 +46,19 @@ public record BatchDetailResponse(
|
|||||||
batch.PromotionEncounterId,
|
batch.PromotionEncounterId,
|
||||||
batch.SupersedesBatchId,
|
batch.SupersedesBatchId,
|
||||||
batch.ClinicianAttestation,
|
batch.ClinicianAttestation,
|
||||||
|
batch.SupersedesBatchId.HasValue,
|
||||||
|
supersession,
|
||||||
batch.CreatedAt,
|
batch.CreatedAt,
|
||||||
batch.UpdatedAt
|
batch.UpdatedAt
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public static SupersessionInfo ToSupersessionInfo(
|
||||||
|
DigitizationBatch supersededBatch,
|
||||||
|
int originalObservationCount) =>
|
||||||
|
new(
|
||||||
|
supersededBatch.Id,
|
||||||
|
supersededBatch.Status.ToDbString(),
|
||||||
|
supersededBatch.PromotedAt ?? supersededBatch.UpdatedAt,
|
||||||
|
originalObservationCount
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Multipart form for batch creation. Combines the uploaded scan with batch metadata
|
|
||||||
/// so Swagger documents a single <c>multipart/form-data</c> request.
|
|
||||||
/// </summary>
|
|
||||||
public class CreateBatchForm
|
public class CreateBatchForm
|
||||||
{
|
{
|
||||||
/// <summary>PDF, JPEG, or PNG scan (max 25 MB).</summary>
|
|
||||||
[Required]
|
[Required]
|
||||||
public IFormFile File { get; set; } = null!;
|
public IFormFile File { get; set; } = null!;
|
||||||
|
|
||||||
/// <summary>Batch type literal, e.g. PATIENT_REGISTRATION or ENCOUNTER_SUMMARY.</summary>
|
|
||||||
[Required]
|
[Required]
|
||||||
public string BatchType { get; set; } = null!;
|
public string BatchType { get; set; } = null!;
|
||||||
|
|
||||||
/// <summary>BACKFILL (default) or LIVE_CAPTURE.</summary>
|
|
||||||
public string? Track { get; set; }
|
public string? Track { get; set; }
|
||||||
|
|
||||||
/// <summary>Optional patient link used for duplicate-document detection.</summary>
|
|
||||||
public Guid? PatientId { get; set; }
|
public Guid? PatientId { get; set; }
|
||||||
|
|
||||||
public CreateBatchRequest ToMetadata() => new(BatchType, Track, PatientId);
|
public Guid? SupersedesBatchId { get; set; }
|
||||||
|
|
||||||
|
public CreateBatchRequest ToMetadata() =>
|
||||||
|
new(BatchType, Track, PatientId, SupersedesBatchId);
|
||||||
}
|
}
|
||||||
@@ -4,5 +4,6 @@
|
|||||||
public record CreateBatchRequest(
|
public record CreateBatchRequest(
|
||||||
string BatchType,
|
string BatchType,
|
||||||
string? Track,
|
string? Track,
|
||||||
Guid? PatientId
|
Guid? PatientId,
|
||||||
|
Guid? SupersedesBatchId
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
public record CreateBatchResult(
|
||||||
|
DigitizationBatch Batch,
|
||||||
|
SupersessionInfo? Supersession
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
public record DigitizationEventSummary(
|
||||||
|
string EventType,
|
||||||
|
DateTimeOffset OccurredAt,
|
||||||
|
Guid ActorUserId,
|
||||||
|
string ActorName,
|
||||||
|
string? MetadataJson
|
||||||
|
);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
public record DigitizationHistoryEntry(
|
||||||
|
Guid BatchId,
|
||||||
|
string Status,
|
||||||
|
string BatchType,
|
||||||
|
string Track,
|
||||||
|
Guid? SupersedesBatchId,
|
||||||
|
bool IsCorrection,
|
||||||
|
bool HasBeenSuperseded,
|
||||||
|
Guid? SupersededByBatchId,
|
||||||
|
int DraftObservationCount,
|
||||||
|
int LiveObservationCount,
|
||||||
|
int SupersededObservationCount,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset? PromotedAt,
|
||||||
|
Guid? PromotionEncounterId,
|
||||||
|
Guid? EnteredByUserId,
|
||||||
|
string? EnteredByUserName,
|
||||||
|
Guid? VerifiedByUserId,
|
||||||
|
string? VerifiedByUserName,
|
||||||
|
Guid? ApprovedByUserId,
|
||||||
|
string? ApprovedByUserName,
|
||||||
|
IReadOnlyList<DigitizationEventSummary> AuditTrail
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
public record PatientDigitizationHistoryResponse(
|
||||||
|
Guid PatientId,
|
||||||
|
int TotalBatches,
|
||||||
|
int PromotedBatches,
|
||||||
|
int SupersededBatches,
|
||||||
|
int PendingBatches,
|
||||||
|
IReadOnlyList<DigitizationHistoryEntry> Entries
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
public record SupersessionInfo(
|
||||||
|
Guid OriginalBatchId,
|
||||||
|
string OriginalBatchStatus,
|
||||||
|
DateTimeOffset OriginalPromotedAt,
|
||||||
|
int OriginalObservationCount
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
public record PromotionResult(
|
||||||
|
Guid BatchId,
|
||||||
|
Guid EncounterId,
|
||||||
|
int ObservationsPromoted,
|
||||||
|
bool IsCorrection,
|
||||||
|
SupersessionResult? Supersession
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
public record SupersessionResult(
|
||||||
|
Guid OriginalBatchId,
|
||||||
|
int ObservationsSuperseded,
|
||||||
|
int ObservationsReplaced,
|
||||||
|
DateTimeOffset SupersededAt
|
||||||
|
);
|
||||||
@@ -65,6 +65,7 @@ try
|
|||||||
builder.Services.AddScoped<IPromotionService, PromotionService>();
|
builder.Services.AddScoped<IPromotionService, PromotionService>();
|
||||||
builder.Services.AddScoped<IIdempotencyService, IdempotencyService>();
|
builder.Services.AddScoped<IIdempotencyService, IdempotencyService>();
|
||||||
builder.Services.AddScoped<IMrnGenerator, MrnGenerator>();
|
builder.Services.AddScoped<IMrnGenerator, MrnGenerator>();
|
||||||
|
builder.Services.AddScoped<IDigitizationHistoryService, DigitizationHistoryService>();
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
@@ -89,8 +90,9 @@ try
|
|||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
using (var scope = app.Services.CreateScope())
|
if (!app.Environment.IsEnvironment("Testing"))
|
||||||
{
|
{
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
await db.Database.MigrateAsync();
|
await db.Database.MigrateAsync();
|
||||||
await DataSeeder.SeedAsync(db);
|
await DataSeeder.SeedAsync(db);
|
||||||
|
|||||||
@@ -30,14 +30,47 @@ public class BatchService : IBatchService
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DigitizationBatch> CreateAsync(
|
public async Task<CreateBatchResult> CreateAsync(
|
||||||
Stream fileStream, string contentType, BatchType batchType,
|
Stream fileStream, string contentType, BatchType batchType,
|
||||||
BatchTrack track, Guid? patientId, Guid actorUserId)
|
BatchTrack track, Guid? patientId, Guid? supersedesBatchId, Guid actorUserId)
|
||||||
{
|
{
|
||||||
|
// Supersession validation: target batch must exist and be in Promoted status
|
||||||
|
DigitizationBatch? supersededBatch = null;
|
||||||
|
if (supersedesBatchId.HasValue)
|
||||||
|
{
|
||||||
|
supersededBatch = await _db.DigitizationBatches
|
||||||
|
.FirstOrDefaultAsync(b => b.Id == supersedesBatchId.Value);
|
||||||
|
|
||||||
|
if (supersededBatch is null)
|
||||||
|
throw new NotFoundException(
|
||||||
|
$"Batch {supersedesBatchId.Value} not found.",
|
||||||
|
"SUPERSEDED_BATCH_NOT_FOUND");
|
||||||
|
|
||||||
|
if (supersededBatch.Status != BatchStatus.Promoted)
|
||||||
|
throw new ValidationException(
|
||||||
|
"Only promoted batches can be superseded. " +
|
||||||
|
$"Batch {supersedesBatchId.Value} is in '{supersededBatch.Status.ToDbString()}' status.",
|
||||||
|
"SUPERSEDED_BATCH_NOT_PROMOTED");
|
||||||
|
|
||||||
|
// Prevent supersession chains: the target batch must not itself be a correction
|
||||||
|
// that has already been superseded by another promoted correction
|
||||||
|
var existingCorrection = await _db.DigitizationBatches
|
||||||
|
.AnyAsync(b => b.SupersedesBatchId == supersedesBatchId.Value
|
||||||
|
&& b.Status == BatchStatus.Promoted);
|
||||||
|
|
||||||
|
if (existingCorrection)
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Batch {supersedesBatchId.Value} has already been superseded by a promoted correction. " +
|
||||||
|
"Create a new correction against the latest promoted batch instead.",
|
||||||
|
"BATCH_ALREADY_SUPERSEDED");
|
||||||
|
|
||||||
|
// Inherit patient context from the superseded batch
|
||||||
|
patientId ??= supersededBatch.PatientId;
|
||||||
|
}
|
||||||
|
|
||||||
var (objectKey, sha256, fileSize) = await _storage.UploadAsync(fileStream, contentType, Guid.NewGuid());
|
var (objectKey, sha256, fileSize) = await _storage.UploadAsync(fileStream, contentType, Guid.NewGuid());
|
||||||
|
|
||||||
// Duplicate detection: same SHA-256 for same patient within 24 hours.
|
// Duplicate detection: same SHA-256 for same patient within 24 hours
|
||||||
// Cross-patient duplicates (same form scanned for two patients) are allowed.
|
|
||||||
if (patientId.HasValue)
|
if (patientId.HasValue)
|
||||||
{
|
{
|
||||||
var cutoff = DateTimeOffset.UtcNow.AddHours(-24);
|
var cutoff = DateTimeOffset.UtcNow.AddHours(-24);
|
||||||
@@ -63,6 +96,7 @@ public class BatchService : IBatchService
|
|||||||
DocumentRef = objectKey,
|
DocumentRef = objectKey,
|
||||||
DocumentSha256 = sha256,
|
DocumentSha256 = sha256,
|
||||||
EnableRetroactiveAlerts = false,
|
EnableRetroactiveAlerts = false,
|
||||||
|
SupersedesBatchId = supersedesBatchId,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
@@ -78,13 +112,21 @@ public class BatchService : IBatchService
|
|||||||
UploadedAt = DateTimeOffset.UtcNow
|
UploadedAt = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var eventType = supersedesBatchId.HasValue
|
||||||
|
? DigitizationEventType.CorrectionUploaded
|
||||||
|
: DigitizationEventType.Uploaded;
|
||||||
|
var eventMetadata = supersedesBatchId.HasValue
|
||||||
|
? JsonSerializer.Serialize(new { supersedesBatchId = supersedesBatchId.Value })
|
||||||
|
: null;
|
||||||
|
|
||||||
var evt = new DigitizationEvent
|
var evt = new DigitizationEvent
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
BatchId = batchId,
|
BatchId = batchId,
|
||||||
EventType = DigitizationEventType.Uploaded,
|
EventType = eventType,
|
||||||
ActorUserId = actorUserId,
|
ActorUserId = actorUserId,
|
||||||
OccurredAt = DateTimeOffset.UtcNow
|
OccurredAt = DateTimeOffset.UtcNow,
|
||||||
|
MetadataJson = eventMetadata
|
||||||
};
|
};
|
||||||
|
|
||||||
_db.DigitizationBatches.Add(batch);
|
_db.DigitizationBatches.Add(batch);
|
||||||
@@ -92,8 +134,19 @@ public class BatchService : IBatchService
|
|||||||
_db.DigitizationEvents.Add(evt);
|
_db.DigitizationEvents.Add(evt);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
_logger.LogInformation("Batch {BatchId} created with document {ObjectKey}", batchId, objectKey);
|
_logger.LogInformation(
|
||||||
return batch;
|
"Batch {BatchId} created (correction={IsCorrection}, supersedes={SupersedesBatchId})",
|
||||||
|
batchId, supersedesBatchId.HasValue, supersedesBatchId);
|
||||||
|
|
||||||
|
SupersessionInfo? supersession = null;
|
||||||
|
if (supersededBatch is not null)
|
||||||
|
{
|
||||||
|
var obsCount = await _db.Observations
|
||||||
|
.CountAsync(o => o.SourceBatchId == supersededBatch.Id);
|
||||||
|
supersession = BatchDetailResponse.ToSupersessionInfo(supersededBatch, obsCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CreateBatchResult(batch, supersession);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DigitizationBatch> GetByIdAsync(Guid id)
|
public async Task<DigitizationBatch> GetByIdAsync(Guid id)
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
public class DigitizationHistoryService : IDigitizationHistoryService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public DigitizationHistoryService(AppDbContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PatientDigitizationHistoryResponse> GetPatientHistoryAsync(Guid patientId)
|
||||||
|
{
|
||||||
|
// Verify the patient exists (has at least one batch)
|
||||||
|
var hasBatches = await _db.DigitizationBatches
|
||||||
|
.AnyAsync(b => b.PatientId == patientId);
|
||||||
|
|
||||||
|
if (!hasBatches)
|
||||||
|
throw new NotFoundException(
|
||||||
|
$"No digitization history found for patient {patientId}.",
|
||||||
|
"PATIENT_HISTORY_NOT_FOUND");
|
||||||
|
|
||||||
|
// Load all batches for this patient with their relationships
|
||||||
|
var batches = await _db.DigitizationBatches
|
||||||
|
.Include(b => b.DraftObservations)
|
||||||
|
.Include(b => b.Events)
|
||||||
|
.ThenInclude(e => e.Actor)
|
||||||
|
.Include(b => b.EnteredByUser)
|
||||||
|
.Include(b => b.VerifiedByUser)
|
||||||
|
.Include(b => b.ApprovedByUser)
|
||||||
|
.Where(b => b.PatientId == patientId)
|
||||||
|
.OrderByDescending(b => b.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// For each promoted batch, count live vs superseded observations
|
||||||
|
var promotedBatchIds = batches
|
||||||
|
.Where(b => b.Status == BatchStatus.Promoted)
|
||||||
|
.Select(b => b.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var liveObservationCounts = await _db.LiveObservations
|
||||||
|
.Where(o => promotedBatchIds.Contains(o.SourceBatchId))
|
||||||
|
.GroupBy(o => new { o.SourceBatchId, o.IsSuperseded })
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
g.Key.SourceBatchId,
|
||||||
|
g.Key.IsSuperseded,
|
||||||
|
Count = g.Count()
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Build a lookup: batchId -> (activeCount, supersededCount)
|
||||||
|
var obsCountLookup = promotedBatchIds.ToDictionary(
|
||||||
|
id => id,
|
||||||
|
id =>
|
||||||
|
{
|
||||||
|
var active = liveObservationCounts
|
||||||
|
.FirstOrDefault(x => x.SourceBatchId == id && !x.IsSuperseded)?.Count ?? 0;
|
||||||
|
var superseded = liveObservationCounts
|
||||||
|
.FirstOrDefault(x => x.SourceBatchId == id && x.IsSuperseded)?.Count ?? 0;
|
||||||
|
return (Active: active, Superseded: superseded);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build a lookup for "has been superseded" — batches that appear as
|
||||||
|
// SupersedesBatchId on another batch that reached Promoted
|
||||||
|
var supersededByLookup = await _db.DigitizationBatches
|
||||||
|
.Where(b => b.SupersedesBatchId.HasValue
|
||||||
|
&& b.Status == BatchStatus.Promoted
|
||||||
|
&& promotedBatchIds.Contains(b.SupersedesBatchId.Value))
|
||||||
|
.ToDictionaryAsync(
|
||||||
|
b => b.SupersedesBatchId!.Value,
|
||||||
|
b => b.Id);
|
||||||
|
|
||||||
|
var entries = batches.Select(b =>
|
||||||
|
{
|
||||||
|
var counts = obsCountLookup.GetValueOrDefault(b.Id, (Active: 0, Superseded: 0));
|
||||||
|
var hasBeenSuperseded = supersededByLookup.ContainsKey(b.Id);
|
||||||
|
supersededByLookup.TryGetValue(b.Id, out var supersededByBatchId);
|
||||||
|
|
||||||
|
return new DigitizationHistoryEntry(
|
||||||
|
BatchId: b.Id,
|
||||||
|
Status: b.Status.ToDbString(),
|
||||||
|
BatchType: b.BatchType.ToDbString(),
|
||||||
|
Track: b.Track.ToDbString(),
|
||||||
|
SupersedesBatchId: b.SupersedesBatchId,
|
||||||
|
IsCorrection: b.SupersedesBatchId.HasValue,
|
||||||
|
HasBeenSuperseded: hasBeenSuperseded,
|
||||||
|
SupersededByBatchId: hasBeenSuperseded ? supersededByBatchId : null,
|
||||||
|
DraftObservationCount: b.DraftObservations.Count,
|
||||||
|
LiveObservationCount: counts.Active,
|
||||||
|
SupersededObservationCount: counts.Superseded,
|
||||||
|
CreatedAt: b.CreatedAt,
|
||||||
|
PromotedAt: b.PromotedAt,
|
||||||
|
PromotionEncounterId: b.PromotionEncounterId,
|
||||||
|
EnteredByUserId: b.EnteredByUserId,
|
||||||
|
EnteredByUserName: b.EnteredByUser?.FullName,
|
||||||
|
VerifiedByUserId: b.VerifiedByUserId,
|
||||||
|
VerifiedByUserName: b.VerifiedByUser?.FullName,
|
||||||
|
ApprovedByUserId: b.ApprovedByUserId,
|
||||||
|
ApprovedByUserName: b.ApprovedByUser?.FullName,
|
||||||
|
AuditTrail: b.Events
|
||||||
|
.OrderBy(e => e.OccurredAt)
|
||||||
|
.Select(e => new DigitizationEventSummary(
|
||||||
|
e.EventType.ToDbString(),
|
||||||
|
e.OccurredAt,
|
||||||
|
e.ActorUserId,
|
||||||
|
e.Actor?.FullName ?? "Unknown",
|
||||||
|
e.MetadataJson))
|
||||||
|
.ToList()
|
||||||
|
);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return new PatientDigitizationHistoryResponse(
|
||||||
|
PatientId: patientId,
|
||||||
|
TotalBatches: entries.Count,
|
||||||
|
PromotedBatches: entries.Count(e => e.Status == "PROMOTED"),
|
||||||
|
SupersededBatches: entries.Count(e => e.HasBeenSuperseded),
|
||||||
|
PendingBatches: entries.Count(e => e.Status != "PROMOTED" && e.Status != "REJECTED"),
|
||||||
|
Entries: entries
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -453,12 +453,15 @@ public class DraftService : IDraftService
|
|||||||
private static void ValidateLabResults(
|
private static void ValidateLabResults(
|
||||||
DigitizationBatch batch, List<string> errors)
|
DigitizationBatch batch, List<string> errors)
|
||||||
{
|
{
|
||||||
// Required: Linked patient, encounter, >= 1 lab observation code, recordedAt
|
// Corrections inherit patient and encounter from the superseded promoted batch
|
||||||
|
if (!batch.SupersedesBatchId.HasValue)
|
||||||
|
{
|
||||||
if (batch.DraftPatient is null)
|
if (batch.DraftPatient is null)
|
||||||
errors.Add("Linked patient is required for lab results");
|
errors.Add("Linked patient is required for lab results");
|
||||||
|
|
||||||
if (batch.DraftEncounter is null)
|
if (batch.DraftEncounter is null)
|
||||||
errors.Add("Encounter context is required for lab results");
|
errors.Add("Encounter context is required for lab results");
|
||||||
|
}
|
||||||
|
|
||||||
if (batch.DraftObservations.Count == 0)
|
if (batch.DraftObservations.Count == 0)
|
||||||
errors.Add("At least one lab observation is required");
|
errors.Add("At least one lab observation is required");
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
public interface IBatchService
|
public interface IBatchService
|
||||||
{
|
{
|
||||||
Task<DigitizationBatch> CreateAsync(Stream fileStream, string contentType, BatchType batchType, BatchTrack track, Guid? patientId, Guid actorUserId);
|
Task<CreateBatchResult> CreateAsync(
|
||||||
|
Stream fileStream,
|
||||||
|
string contentType,
|
||||||
|
BatchType batchType,
|
||||||
|
BatchTrack track,
|
||||||
|
Guid? patientId,
|
||||||
|
Guid? supersedesBatchId,
|
||||||
|
Guid actorUserId);
|
||||||
|
|
||||||
Task<DigitizationBatch> GetByIdAsync(Guid id);
|
Task<DigitizationBatch> GetByIdAsync(Guid id);
|
||||||
Task<PagedResult<DigitizationBatch>> ListAsync(BatchStatus? status, BatchType? batchType, Guid? assignedTo, BatchTrack? track, int page, int pageSize);
|
|
||||||
|
Task<PagedResult<DigitizationBatch>> ListAsync(
|
||||||
|
BatchStatus? status, BatchType? batchType,
|
||||||
|
Guid? assignedTo, BatchTrack? track,
|
||||||
|
int page, int pageSize);
|
||||||
|
|
||||||
Task<DigitizationBatch> AssignAsync(Guid batchId, Guid entryClerkUserId, Guid actorUserId);
|
Task<DigitizationBatch> AssignAsync(Guid batchId, Guid entryClerkUserId, Guid actorUserId);
|
||||||
|
|
||||||
BatchStatus[] GetAllowedTransitions(BatchStatus current);
|
BatchStatus[] GetAllowedTransitions(BatchStatus current);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
public interface IDigitizationHistoryService
|
||||||
|
{
|
||||||
|
Task<PatientDigitizationHistoryResponse> GetPatientHistoryAsync(Guid patientId);
|
||||||
|
}
|
||||||
@@ -20,4 +20,6 @@ public interface IPromotionService
|
|||||||
/// Returns the promotion result for an already-promoted batch.
|
/// Returns the promotion result for an already-promoted batch.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<PromotionResultResponse> GetPromotionResultAsync(Guid batchId);
|
Task<PromotionResultResponse> GetPromotionResultAsync(Guid batchId);
|
||||||
|
|
||||||
|
Task<PromotionResult> PromoteAsync(Guid batchId, Guid actorUserId);
|
||||||
}
|
}
|
||||||
@@ -67,12 +67,21 @@ public class PromotionService : IPromotionService
|
|||||||
"Separation of duties: the verifier cannot also approve the same batch.",
|
"Separation of duties: the verifier cannot also approve the same batch.",
|
||||||
"SEPARATION_OF_DUTIES_VIOLATION");
|
"SEPARATION_OF_DUTIES_VIOLATION");
|
||||||
|
|
||||||
// --- Validate draft data completeness ---
|
// --- Validate draft data completeness (corrections reuse the linked live patient) ---
|
||||||
|
if (!batch.SupersedesBatchId.HasValue)
|
||||||
|
{
|
||||||
if (batch.DraftPatient is null)
|
if (batch.DraftPatient is null)
|
||||||
throw new ValidationException("Batch has no draft patient data.", "MISSING_DRAFT_PATIENT");
|
throw new ValidationException("Batch has no draft patient data.", "MISSING_DRAFT_PATIENT");
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(batch.DraftPatient.FullName))
|
if (string.IsNullOrWhiteSpace(batch.DraftPatient.FullName))
|
||||||
throw new ValidationException("Draft patient must have a full name.", "INVALID_DRAFT_PATIENT");
|
throw new ValidationException("Draft patient must have a full name.", "INVALID_DRAFT_PATIENT");
|
||||||
|
}
|
||||||
|
else if (!batch.PatientId.HasValue)
|
||||||
|
{
|
||||||
|
throw new ValidationException(
|
||||||
|
"Correction batch has no linked patient.",
|
||||||
|
"MISSING_PATIENT");
|
||||||
|
}
|
||||||
|
|
||||||
// --- Begin atomic transaction ---
|
// --- Begin atomic transaction ---
|
||||||
await using var transaction = await _db.Database.BeginTransactionAsync();
|
await using var transaction = await _db.Database.BeginTransactionAsync();
|
||||||
@@ -82,41 +91,79 @@ public class PromotionService : IPromotionService
|
|||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
// === Step 1: Create or update Patient ===
|
// === Step 1: Create or update Patient ===
|
||||||
var patient = await CreateOrUpdatePatientAsync(batch.DraftPatient, now);
|
Patient patient;
|
||||||
|
if (batch.SupersedesBatchId.HasValue)
|
||||||
|
{
|
||||||
|
patient = await _db.Patients.FirstOrDefaultAsync(p => p.Id == batch.PatientId!.Value)
|
||||||
|
?? throw new NotFoundException(
|
||||||
|
$"Patient {batch.PatientId.Value} not found.",
|
||||||
|
"PATIENT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
patient = await CreateOrUpdatePatientAsync(batch.DraftPatient!, now);
|
||||||
|
}
|
||||||
|
|
||||||
// === Step 2: Create or match Encounter ===
|
// === Step 2: Create or match Encounter (reuse original encounter for corrections) ===
|
||||||
var encounter = await CreateOrMatchEncounterAsync(batch, patient.Id, now);
|
var encounter = await ResolveClinicalEncounterAsync(batch, patient.Id, now);
|
||||||
|
await EnsureLiveEncounterAsync(encounter, now);
|
||||||
|
|
||||||
// === Step 3: Insert each DraftObservation as live Observation ===
|
// === Step 3: Insert each DraftObservation as live Observation ===
|
||||||
var (observationIds, outboxCount) = await PromoteObservationsAsync(
|
var (observationIds, outboxCount) = await PromoteObservationsAsync(
|
||||||
batch, patient.Id, encounter.Id, enableRetroactiveAlerts, now);
|
batch, patient.Id, encounter.Id, enableRetroactiveAlerts, now);
|
||||||
|
|
||||||
|
// === Step 3b: Mirror to live_observations for supersession / digitization history ===
|
||||||
|
PromoteLiveObservationsAsync(batch, patient.Id, encounter.Id, now);
|
||||||
|
|
||||||
|
SupersessionResult? supersessionResult = null;
|
||||||
|
if (batch.SupersedesBatchId.HasValue)
|
||||||
|
{
|
||||||
|
supersessionResult = await SupersedeOriginalBatchAsync(
|
||||||
|
batch.SupersedesBatchId.Value, batchId, approverUserId, now);
|
||||||
|
}
|
||||||
|
|
||||||
// === Step 4: Update batch status to Promoted ===
|
// === Step 4: Update batch status to Promoted ===
|
||||||
batch.Status = BatchStatus.Promoted;
|
batch.Status = BatchStatus.Promoted;
|
||||||
batch.ApprovedByUserId = approverUserId;
|
batch.ApprovedByUserId = approverUserId;
|
||||||
|
batch.PatientId = patient.Id;
|
||||||
batch.PromotedAt = now;
|
batch.PromotedAt = now;
|
||||||
batch.PromotionEncounterId = encounter.Id;
|
batch.PromotionEncounterId = encounter.Id;
|
||||||
batch.EnableRetroactiveAlerts = enableRetroactiveAlerts;
|
batch.EnableRetroactiveAlerts = enableRetroactiveAlerts;
|
||||||
batch.UpdatedAt = now;
|
batch.UpdatedAt = now;
|
||||||
|
|
||||||
// === Step 5: Write DigitizationEvent ===
|
// === Step 5: Write DigitizationEvent ===
|
||||||
|
var promotionMetadata = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["patientId"] = patient.Id,
|
||||||
|
["mrn"] = patient.Mrn,
|
||||||
|
["encounterId"] = encounter.Id,
|
||||||
|
["observationCount"] = observationIds.Length,
|
||||||
|
["outboxEventsWritten"] = outboxCount,
|
||||||
|
["enableRetroactiveAlerts"] = enableRetroactiveAlerts,
|
||||||
|
["track"] = batch.Track.ToDbString(),
|
||||||
|
["isCorrection"] = batch.SupersedesBatchId.HasValue
|
||||||
|
};
|
||||||
|
|
||||||
|
if (supersessionResult is not null)
|
||||||
|
{
|
||||||
|
promotionMetadata["supersession"] = new
|
||||||
|
{
|
||||||
|
originalBatchId = supersessionResult.OriginalBatchId,
|
||||||
|
observationsSuperseded = supersessionResult.ObservationsSuperseded,
|
||||||
|
observationsReplaced = supersessionResult.ObservationsReplaced
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
_db.DigitizationEvents.Add(new DigitizationEvent
|
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
BatchId = batchId,
|
BatchId = batchId,
|
||||||
EventType = DigitizationEventType.Promoted,
|
EventType = batch.SupersedesBatchId.HasValue
|
||||||
|
? DigitizationEventType.CorrectionPromoted
|
||||||
|
: DigitizationEventType.Promoted,
|
||||||
ActorUserId = approverUserId,
|
ActorUserId = approverUserId,
|
||||||
OccurredAt = now,
|
OccurredAt = now,
|
||||||
MetadataJson = JsonSerializer.Serialize(new
|
MetadataJson = JsonSerializer.Serialize(promotionMetadata)
|
||||||
{
|
|
||||||
patientId = patient.Id,
|
|
||||||
mrn = patient.Mrn,
|
|
||||||
encounterId = encounter.Id,
|
|
||||||
observationCount = observationIds.Length,
|
|
||||||
outboxEventsWritten = outboxCount,
|
|
||||||
enableRetroactiveAlerts,
|
|
||||||
track = batch.Track.ToDbString()
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// === Step 6: Store idempotency record (within same transaction) ===
|
// === Step 6: Store idempotency record (within same transaction) ===
|
||||||
@@ -258,6 +305,80 @@ public class PromotionService : IPromotionService
|
|||||||
return patient;
|
return patient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<Encounter> ResolveClinicalEncounterAsync(
|
||||||
|
DigitizationBatch batch, Guid patientId, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
if (batch.SupersedesBatchId.HasValue)
|
||||||
|
{
|
||||||
|
var originalBatch = await _db.DigitizationBatches
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(b => b.Id == batch.SupersedesBatchId.Value);
|
||||||
|
|
||||||
|
if (originalBatch?.PromotionEncounterId is not Guid encounterId)
|
||||||
|
throw new ValidationException(
|
||||||
|
"Original batch has no promotion encounter for correction reuse.",
|
||||||
|
"MISSING_ORIGINAL_ENCOUNTER");
|
||||||
|
|
||||||
|
var encounter = await _db.Encounters.FirstOrDefaultAsync(e => e.Id == encounterId);
|
||||||
|
if (encounter is null)
|
||||||
|
throw new NotFoundException(
|
||||||
|
"Original promotion encounter not found.",
|
||||||
|
"PROMOTION_ENCOUNTER_NOT_FOUND");
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Correction batch {CorrectionBatchId} reusing clinical encounter {EncounterId} " +
|
||||||
|
"from original batch {OriginalBatchId}",
|
||||||
|
batch.Id, encounter.Id, originalBatch.Id);
|
||||||
|
|
||||||
|
return encounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await CreateOrMatchEncounterAsync(batch, patientId, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureLiveEncounterAsync(Encounter encounter, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
if (await _db.LiveEncounters.AnyAsync(e => e.Id == encounter.Id))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_db.LiveEncounters.Add(new LiveEncounter
|
||||||
|
{
|
||||||
|
Id = encounter.Id,
|
||||||
|
PatientId = encounter.PatientId,
|
||||||
|
AdmissionDate = encounter.AdmissionDate ?? now,
|
||||||
|
Department = encounter.Department,
|
||||||
|
RoomBed = encounter.RoomBed,
|
||||||
|
AdmissionReason = encounter.AdmissionReason,
|
||||||
|
DischargeDiagnosis = encounter.DischargeDiagnosis,
|
||||||
|
Status = encounter.Status,
|
||||||
|
CreatedAt = now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PromoteLiveObservationsAsync(
|
||||||
|
DigitizationBatch batch, Guid patientId, Guid encounterId, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
foreach (var draft in batch.DraftObservations)
|
||||||
|
{
|
||||||
|
_db.LiveObservations.Add(new LiveObservation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EncounterId = encounterId,
|
||||||
|
PatientId = patientId,
|
||||||
|
SourceBatchId = batch.Id,
|
||||||
|
ObservationCode = draft.ObservationCode,
|
||||||
|
Value = draft.Value,
|
||||||
|
Unit = draft.Unit,
|
||||||
|
RecordedAt = draft.RecordedAt,
|
||||||
|
Note = draft.Note,
|
||||||
|
CreatedAt = now,
|
||||||
|
IsSuperseded = false,
|
||||||
|
SupersededByBatchId = null,
|
||||||
|
SupersededAt = null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<Encounter> CreateOrMatchEncounterAsync(
|
private async Task<Encounter> CreateOrMatchEncounterAsync(
|
||||||
DigitizationBatch batch, Guid patientId, DateTimeOffset now)
|
DigitizationBatch batch, Guid patientId, DateTimeOffset now)
|
||||||
{
|
{
|
||||||
@@ -389,4 +510,227 @@ public class PromotionService : IPromotionService
|
|||||||
|
|
||||||
return (observationIds.ToArray(), outboxCount);
|
return (observationIds.ToArray(), outboxCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<PromotionResult> PromoteAsync(Guid batchId, Guid actorUserId)
|
||||||
|
{
|
||||||
|
await using var transaction = await _db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Load the batch with all draft data
|
||||||
|
var batch = await _db.DigitizationBatches
|
||||||
|
.Include(b => b.DraftPatient)
|
||||||
|
.Include(b => b.DraftEncounter)
|
||||||
|
.Include(b => b.DraftObservations)
|
||||||
|
.FirstOrDefaultAsync(b => b.Id == batchId);
|
||||||
|
|
||||||
|
if (batch is null)
|
||||||
|
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
|
||||||
|
|
||||||
|
if (batch.Status != BatchStatus.Approved)
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Only approved batches can be promoted. Batch is in '{batch.Status.ToDbString()}' status.",
|
||||||
|
"ILLEGAL_STATUS_TRANSITION");
|
||||||
|
|
||||||
|
// Resolve or create the live encounter
|
||||||
|
var encounterId = await ResolveEncounterAsync(batch);
|
||||||
|
|
||||||
|
// Promote draft observations to live observations
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var liveObservations = batch.DraftObservations.Select(draft => new LiveObservation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EncounterId = encounterId,
|
||||||
|
PatientId = batch.PatientId,
|
||||||
|
SourceBatchId = batch.Id,
|
||||||
|
ObservationCode = draft.ObservationCode,
|
||||||
|
Value = draft.Value,
|
||||||
|
Unit = draft.Unit,
|
||||||
|
RecordedAt = draft.RecordedAt,
|
||||||
|
Note = draft.Note,
|
||||||
|
CreatedAt = now,
|
||||||
|
IsSuperseded = false,
|
||||||
|
SupersededByBatchId = null,
|
||||||
|
SupersededAt = null
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
_db.LiveObservations.AddRange(liveObservations);
|
||||||
|
|
||||||
|
// Handle supersession if this is a correction batch
|
||||||
|
SupersessionResult? supersessionResult = null;
|
||||||
|
if (batch.SupersedesBatchId.HasValue)
|
||||||
|
{
|
||||||
|
supersessionResult = await SupersedeOriginalBatchAsync(
|
||||||
|
batch.SupersedesBatchId.Value,
|
||||||
|
batch.Id,
|
||||||
|
actorUserId,
|
||||||
|
now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update batch status to Promoted
|
||||||
|
batch.Status = BatchStatus.Promoted;
|
||||||
|
batch.PromotedAt = now;
|
||||||
|
batch.PromotionEncounterId = encounterId;
|
||||||
|
batch.UpdatedAt = now;
|
||||||
|
|
||||||
|
// Record promotion event on the correction batch
|
||||||
|
var promotionMetadata = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["encounterId"] = encounterId,
|
||||||
|
["observationsPromoted"] = liveObservations.Count,
|
||||||
|
["isCorrection"] = batch.SupersedesBatchId.HasValue
|
||||||
|
};
|
||||||
|
|
||||||
|
if (supersessionResult is not null)
|
||||||
|
{
|
||||||
|
promotionMetadata["supersession"] = new
|
||||||
|
{
|
||||||
|
originalBatchId = supersessionResult.OriginalBatchId,
|
||||||
|
observationsSuperseded = supersessionResult.ObservationsSuperseded,
|
||||||
|
observationsReplaced = supersessionResult.ObservationsReplaced
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
BatchId = batch.Id,
|
||||||
|
EventType = batch.SupersedesBatchId.HasValue
|
||||||
|
? DigitizationEventType.CorrectionPromoted
|
||||||
|
: DigitizationEventType.Promoted,
|
||||||
|
ActorUserId = actorUserId,
|
||||||
|
OccurredAt = now,
|
||||||
|
MetadataJson = JsonSerializer.Serialize(promotionMetadata)
|
||||||
|
});
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Batch {BatchId} promoted (correction={IsCorrection}, " +
|
||||||
|
"observations={ObservationCount}, superseded={SupersededCount})",
|
||||||
|
batchId,
|
||||||
|
batch.SupersedesBatchId.HasValue,
|
||||||
|
liveObservations.Count,
|
||||||
|
supersessionResult?.ObservationsSuperseded ?? 0);
|
||||||
|
|
||||||
|
return new PromotionResult(
|
||||||
|
batch.Id,
|
||||||
|
encounterId,
|
||||||
|
liveObservations.Count,
|
||||||
|
batch.SupersedesBatchId.HasValue,
|
||||||
|
supersessionResult);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks all live observations sourced from the original batch as superseded.
|
||||||
|
/// Records an audit event on the original batch documenting the supersession.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<SupersessionResult> SupersedeOriginalBatchAsync(
|
||||||
|
Guid originalBatchId, Guid correctionBatchId, Guid actorUserId, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
// Load all live observations that came from the original erroneous batch
|
||||||
|
var originalObservations = await _db.LiveObservations
|
||||||
|
.Where(o => o.SourceBatchId == originalBatchId && !o.IsSuperseded)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (originalObservations.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Supersession: no active live observations found for original batch {OriginalBatchId}",
|
||||||
|
originalBatchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark each observation as superseded — never delete
|
||||||
|
foreach (var obs in originalObservations)
|
||||||
|
{
|
||||||
|
obs.IsSuperseded = true;
|
||||||
|
obs.SupersededByBatchId = correctionBatchId;
|
||||||
|
obs.SupersededAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count the correction batch's draft observations for the replacement count
|
||||||
|
var replacementCount = await _db.DigitizationBatches
|
||||||
|
.Where(b => b.Id == correctionBatchId)
|
||||||
|
.SelectMany(b => b.DraftObservations)
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
|
// Record supersession event on the original batch's audit trail
|
||||||
|
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
BatchId = originalBatchId,
|
||||||
|
EventType = DigitizationEventType.Superseded,
|
||||||
|
ActorUserId = actorUserId,
|
||||||
|
OccurredAt = now,
|
||||||
|
MetadataJson = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
supersededByBatchId = correctionBatchId,
|
||||||
|
observationsSuperseded = originalObservations.Count,
|
||||||
|
replacementObservations = replacementCount,
|
||||||
|
reason = "Correction batch promoted — original observations marked superseded"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
return new SupersessionResult(
|
||||||
|
originalBatchId,
|
||||||
|
originalObservations.Count,
|
||||||
|
replacementCount,
|
||||||
|
now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the live encounter for promotion. For correction batches, reuses the
|
||||||
|
/// encounter from the original batch to maintain continuity. For new batches,
|
||||||
|
/// creates or resolves the encounter from draft data.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<Guid> ResolveEncounterAsync(DigitizationBatch batch)
|
||||||
|
{
|
||||||
|
// For correction batches, reuse the encounter from the original promoted batch
|
||||||
|
if (batch.SupersedesBatchId.HasValue)
|
||||||
|
{
|
||||||
|
var originalBatch = await _db.DigitizationBatches
|
||||||
|
.FirstOrDefaultAsync(b => b.Id == batch.SupersedesBatchId.Value);
|
||||||
|
|
||||||
|
if (originalBatch?.PromotionEncounterId.HasValue == true)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Correction batch {CorrectionBatchId} reusing encounter {EncounterId} " +
|
||||||
|
"from original batch {OriginalBatchId}",
|
||||||
|
batch.Id, originalBatch.PromotionEncounterId.Value, originalBatch.Id);
|
||||||
|
|
||||||
|
return originalBatch.PromotionEncounterId.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-correction batches, create or find the encounter from draft data
|
||||||
|
if (batch.DraftEncounter is null)
|
||||||
|
throw new ValidationException(
|
||||||
|
"Batch has no draft encounter data for promotion.",
|
||||||
|
"MISSING_ENCOUNTER_DATA");
|
||||||
|
|
||||||
|
var encounter = new LiveEncounter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
PatientId = batch.PatientId
|
||||||
|
?? throw new ValidationException("Patient ID is required for promotion.", "MISSING_PATIENT_ID"),
|
||||||
|
AdmissionDate = batch.DraftEncounter.AdmissionDate
|
||||||
|
?? throw new ValidationException("Admission date is required.", "MISSING_ADMISSION_DATE"),
|
||||||
|
Department = batch.DraftEncounter.Department,
|
||||||
|
RoomBed = batch.DraftEncounter.RoomBed,
|
||||||
|
AdmissionReason = batch.DraftEncounter.AdmissionReason,
|
||||||
|
DischargeDiagnosis = batch.DraftEncounter.DischargeDiagnosis,
|
||||||
|
Status = batch.DraftEncounter.Status ?? "active",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.LiveEncounters.Add(encounter);
|
||||||
|
return encounter.Id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+828
@@ -0,0 +1,828 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs Phase 5 verification checks from docs/plans/phase-5-plan.md.
|
||||||
|
#
|
||||||
|
# Covers correction batch creation, supersession on promotion, live observation
|
||||||
|
# flags, patient digitization history, audit trail integrity, and integration tests.
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# docker compose up -d (PostgreSQL + Redis + MinIO)
|
||||||
|
# dotnet ef database update --project VigilCareRecordsAPI
|
||||||
|
# dotnet run --project VigilCareRecordsAPI
|
||||||
|
# Phase 1–4 seed data (intake1, entry1/2, verifier1/2, approver1/2, clinician1, admin1)
|
||||||
|
#
|
||||||
|
# PostgreSQL checks use docker compose exec when the postgres service is running,
|
||||||
|
# otherwise host psql against VIGILCARE_PG_HOST:VIGILCARE_PG_PORT.
|
||||||
|
# Environment overrides (same defaults as Phase 1–4 scripts):
|
||||||
|
# VIGILCARE_API_URL default: http://localhost:5217
|
||||||
|
# VIGILCARE_COMPOSE_FILE default: <repo>/docker-compose.yml
|
||||||
|
# VIGILCARE_PG_HOST default: localhost
|
||||||
|
# VIGILCARE_PG_PORT default: 5437
|
||||||
|
# VIGILCARE_PG_DB default: vigilcare_records
|
||||||
|
# VIGILCARE_PG_USER default: postgres
|
||||||
|
# VIGILCARE_PG_PASSWORD default: password
|
||||||
|
# VIGILCARE_SKIP_DB_CHECKS set to 1 to skip PostgreSQL assertions
|
||||||
|
# VIGILCARE_SKIP_TEST_CHECKS set to 1 to skip dotnet integration tests
|
||||||
|
# VIGILCARE_RECORDED_AT default: 2024-06-01T10:00:00Z
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
FIXTURE_PDF="$SCRIPT_DIR/fixtures/test-scan.pdf"
|
||||||
|
|
||||||
|
API_URL="${VIGILCARE_API_URL:-http://localhost:5217}"
|
||||||
|
COMPOSE_FILE="${VIGILCARE_COMPOSE_FILE:-$REPO_ROOT/docker-compose.yml}"
|
||||||
|
COMPOSE=(docker compose -f "$COMPOSE_FILE")
|
||||||
|
PG_HOST="${VIGILCARE_PG_HOST:-localhost}"
|
||||||
|
PG_PORT="${VIGILCARE_PG_PORT:-5437}"
|
||||||
|
PG_DB="${VIGILCARE_PG_DB:-vigilcare_records}"
|
||||||
|
PG_USER="${VIGILCARE_PG_USER:-postgres}"
|
||||||
|
PG_PASSWORD="${VIGILCARE_PG_PASSWORD:-password}"
|
||||||
|
SKIP_DB_CHECKS="${VIGILCARE_SKIP_DB_CHECKS:-0}"
|
||||||
|
SKIP_TEST_CHECKS="${VIGILCARE_SKIP_TEST_CHECKS:-0}"
|
||||||
|
|
||||||
|
RECORDED_AT="${VIGILCARE_RECORDED_AT:-2024-06-01T10:00:00Z}"
|
||||||
|
|
||||||
|
PASS_COUNT=0
|
||||||
|
FAIL_COUNT=0
|
||||||
|
FAILED_TESTS=()
|
||||||
|
|
||||||
|
# Populated by the full correction flow test for downstream checks.
|
||||||
|
SHARED_ORIGINAL_BATCH_ID=""
|
||||||
|
SHARED_CORRECTION_BATCH_ID=""
|
||||||
|
SHARED_PATIENT_ID=""
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '%s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
section() {
|
||||||
|
log ""
|
||||||
|
log "== $1 =="
|
||||||
|
}
|
||||||
|
|
||||||
|
pass() {
|
||||||
|
PASS_COUNT=$((PASS_COUNT + 1))
|
||||||
|
log " PASS: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||||
|
FAILED_TESTS+=("$1")
|
||||||
|
log " FAIL: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
local cmd="$1"
|
||||||
|
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||||
|
log "ERROR: required command not found: $cmd"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
compose_service_running() {
|
||||||
|
local service="$1"
|
||||||
|
"${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -qx "$service"
|
||||||
|
}
|
||||||
|
|
||||||
|
psql_available() {
|
||||||
|
[[ "$SKIP_DB_CHECKS" == "1" ]] && return 1
|
||||||
|
compose_service_running postgres && return 0
|
||||||
|
command -v psql >/dev/null 2>&1 && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
psql_query() {
|
||||||
|
if [[ "$SKIP_DB_CHECKS" == "1" ]]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if compose_service_running postgres; then
|
||||||
|
"${COMPOSE[@]}" exec -T postgres \
|
||||||
|
psql -U "$PG_USER" -d "$PG_DB" -Atqc "$1"
|
||||||
|
elif command -v psql >/dev/null 2>&1; then
|
||||||
|
PGPASSWORD="$PG_PASSWORD" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -Atqc "$1"
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
new_idempotency_key() {
|
||||||
|
if command -v uuidgen >/dev/null 2>&1; then
|
||||||
|
uuidgen
|
||||||
|
else
|
||||||
|
cat /proc/sys/kernel/random/uuid
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Unique PDF per upload — duplicate SHA-256 detection rejects same file for one patient.
|
||||||
|
create_temp_pdf() {
|
||||||
|
local suffix="${1:-$(date +%s%N)}"
|
||||||
|
local path
|
||||||
|
path="$(mktemp "/tmp/vigilcare-correction-${suffix}-XXXXXX.pdf")"
|
||||||
|
printf '%%PDF-1.4 correction-%s\n' "$suffix" > "$path"
|
||||||
|
printf '%s' "$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
http_code() {
|
||||||
|
curl -sS -o /dev/null -w '%{http_code}' "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
json_post() {
|
||||||
|
local url="$1"
|
||||||
|
local body="$2"
|
||||||
|
local token="${3:-}"
|
||||||
|
if [[ -n "$token" ]]; then
|
||||||
|
curl -sS -X POST "$url" \
|
||||||
|
-H "Authorization: Bearer $token" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$body"
|
||||||
|
else
|
||||||
|
curl -sS -X POST "$url" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$body"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
json_put() {
|
||||||
|
local url="$1"
|
||||||
|
local body="$2"
|
||||||
|
local token="$3"
|
||||||
|
curl -sS -X PUT "$url" \
|
||||||
|
-H "Authorization: Bearer $token" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
login() {
|
||||||
|
local username="$1"
|
||||||
|
local password="${2:-password}"
|
||||||
|
json_post "$API_URL/api/v1/auth/login" \
|
||||||
|
"{\"username\":\"$username\",\"password\":\"$password\"}"
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_data_field() {
|
||||||
|
local json="$1"
|
||||||
|
local field="$2"
|
||||||
|
jq -er ".data.$field // empty" <<<"$json"
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_error_code() {
|
||||||
|
local json="$1"
|
||||||
|
jq -er '.error.code // .extensions.code // empty' <<<"$json" 2>/dev/null ||
|
||||||
|
jq -er '.title // empty' <<<"$json" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_batch() {
|
||||||
|
local token="$1"
|
||||||
|
local batch_type="${2:-LAB_RESULTS}"
|
||||||
|
local track="${3:-BACKFILL}"
|
||||||
|
local file_path="${4:-$FIXTURE_PDF}"
|
||||||
|
local supersedes_batch_id="${5:-}"
|
||||||
|
local patient_id="${6:-}"
|
||||||
|
|
||||||
|
local -a form_args=(
|
||||||
|
-H "Authorization: Bearer $token"
|
||||||
|
-F "file=@${file_path};type=application/pdf"
|
||||||
|
-F "batchType=$batch_type"
|
||||||
|
-F "track=$track"
|
||||||
|
)
|
||||||
|
if [[ -n "$supersedes_batch_id" ]]; then
|
||||||
|
form_args+=(-F "supersedesBatchId=$supersedes_batch_id")
|
||||||
|
fi
|
||||||
|
if [[ -n "$patient_id" ]]; then
|
||||||
|
form_args+=(-F "patientId=$patient_id")
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -sS -X POST "$API_URL/api/v1/digitization-batches" "${form_args[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_batch() {
|
||||||
|
local token="$1"
|
||||||
|
local batch_id="$2"
|
||||||
|
local body="$3"
|
||||||
|
json_post "$API_URL/api/v1/digitization-batches/$batch_id/verify" "$body" "$token"
|
||||||
|
}
|
||||||
|
|
||||||
|
approve_batch() {
|
||||||
|
local token="$1"
|
||||||
|
local batch_id="$2"
|
||||||
|
local idempotency_key="$3"
|
||||||
|
local body="${4:-{\"enableRetroactiveAlerts\":false}}"
|
||||||
|
|
||||||
|
curl -sS -X POST "$API_URL/api/v1/digitization-batches/$batch_id/approve" \
|
||||||
|
-H "Authorization: Bearer $token" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "Idempotency-Key: $idempotency_key" \
|
||||||
|
-d "$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_api_reachable() {
|
||||||
|
local code
|
||||||
|
code="$(http_code "$API_URL/swagger/index.html" || true)"
|
||||||
|
if [[ "$code" != "200" ]]; then
|
||||||
|
log "ERROR: API not reachable at $API_URL (HTTP $code)."
|
||||||
|
log "Start infrastructure with: docker compose up -d"
|
||||||
|
log "Apply migrations with: dotnet ef database update --project VigilCareRecordsAPI"
|
||||||
|
log "Start API with: dotnet run --project VigilCareRecordsAPI"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upload, enter lab draft data, submit, and verify.
|
||||||
|
# Leaves batch in AWAITING_CLINICAL_APPROVAL (LAB_RESULTS). Prints batch id to stdout.
|
||||||
|
create_lab_batch_ready_for_approval() {
|
||||||
|
local intake_token="$1"
|
||||||
|
local potassium_value="${2:-3.5}"
|
||||||
|
local sodium_value="${3:-140}"
|
||||||
|
local patient_name="${4:-Phase 5 Lab Patient}"
|
||||||
|
local patient_dob="${5:-1980-01-15}"
|
||||||
|
|
||||||
|
local entry_token verifier_token batch_id patient_json upload_json verify_json verify_status
|
||||||
|
|
||||||
|
patient_json="$(jq -nc \
|
||||||
|
--arg name "$patient_name" \
|
||||||
|
--arg dob "$patient_dob" \
|
||||||
|
'{fullName: $name, dateOfBirth: $dob, sex: "Female"}')"
|
||||||
|
|
||||||
|
upload_json="$(upload_batch "$intake_token" "LAB_RESULTS" "BACKFILL")"
|
||||||
|
if [[ "$(jq -er '.success' <<<"$upload_json")" != "true" ]]; then
|
||||||
|
log "ERROR: failed to upload LAB_RESULTS batch"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
batch_id="$(extract_data_field "$upload_json" id)"
|
||||||
|
entry_token="$(extract_data_field "$(login entry1)" token)"
|
||||||
|
|
||||||
|
json_put \
|
||||||
|
"$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \
|
||||||
|
"$patient_json" \
|
||||||
|
"$entry_token" >/dev/null
|
||||||
|
|
||||||
|
json_put \
|
||||||
|
"$API_URL/api/v1/digitization-batches/$batch_id/draft/encounter" \
|
||||||
|
'{"admissionDate":"2024-06-01T08:00:00Z","department":"Internal Medicine","roomBed":"4A-12","admissionReason":"Electrolyte panel","status":"active"}' \
|
||||||
|
"$entry_token" >/dev/null
|
||||||
|
|
||||||
|
json_post \
|
||||||
|
"$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \
|
||||||
|
"{\"observationCode\":\"K\",\"value\":$potassium_value,\"unit\":\"mmol/L\",\"recordedAt\":\"$RECORDED_AT\"}" \
|
||||||
|
"$entry_token" >/dev/null
|
||||||
|
|
||||||
|
json_post \
|
||||||
|
"$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \
|
||||||
|
"{\"observationCode\":\"Na\",\"value\":$sodium_value,\"unit\":\"mmol/L\",\"recordedAt\":\"$RECORDED_AT\"}" \
|
||||||
|
"$entry_token" >/dev/null
|
||||||
|
|
||||||
|
local submit_code
|
||||||
|
submit_code="$(http_code -X POST \
|
||||||
|
"$API_URL/api/v1/digitization-batches/$batch_id/submit-for-verification" \
|
||||||
|
-H "Authorization: Bearer $entry_token")"
|
||||||
|
if [[ "$submit_code" != "200" ]]; then
|
||||||
|
log "ERROR: submit-for-verification failed (HTTP $submit_code)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
verifier_token="$(extract_data_field "$(login verifier1)" token)"
|
||||||
|
verify_json="$(verify_batch "$verifier_token" "$batch_id" \
|
||||||
|
'{"fieldChecks":[{"fieldName":"observation.K","status":"ok","note":null}],"passed":true}')"
|
||||||
|
verify_status="$(extract_data_field "$verify_json" status)"
|
||||||
|
|
||||||
|
if [[ "$(jq -er '.success' <<<"$verify_json")" != "true" || "$verify_status" != "AWAITING_CLINICAL_APPROVAL" ]]; then
|
||||||
|
log "ERROR: verification failed (status=${verify_status:-<none>})"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "$batch_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Promote a correction batch (observations only) through entry, verify, and approve.
|
||||||
|
# Prints correction batch id to stdout.
|
||||||
|
promote_correction_batch() {
|
||||||
|
local intake_token="$1"
|
||||||
|
local original_batch_id="$2"
|
||||||
|
local patient_id="$3"
|
||||||
|
local potassium_value="${4:-5.3}"
|
||||||
|
local sodium_value="${5:-140}"
|
||||||
|
|
||||||
|
local entry_token verifier_token approver_token
|
||||||
|
local upload_json correction_batch_id approve_json correction_pdf
|
||||||
|
|
||||||
|
correction_pdf="$(create_temp_pdf "$original_batch_id")"
|
||||||
|
upload_json="$(upload_batch "$intake_token" "LAB_RESULTS" "BACKFILL" "$correction_pdf" \
|
||||||
|
"$original_batch_id" "$patient_id")"
|
||||||
|
rm -f "$correction_pdf"
|
||||||
|
if [[ "$(jq -er '.success' <<<"$upload_json")" != "true" ]]; then
|
||||||
|
log "ERROR: failed to upload correction batch: $(jq -c '.' <<<"$upload_json" 2>/dev/null || echo "$upload_json")"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
correction_batch_id="$(extract_data_field "$upload_json" id)"
|
||||||
|
local upload_status is_correction
|
||||||
|
upload_status="$(extract_data_field "$upload_json" status)"
|
||||||
|
is_correction="$(extract_data_field "$upload_json" isCorrection)"
|
||||||
|
|
||||||
|
if [[ "$upload_status" != "UPLOADED" || "$is_correction" != "true" ]]; then
|
||||||
|
log "ERROR: correction upload status=$upload_status isCorrection=$is_correction"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
entry_token="$(extract_data_field "$(login entry2)" token)"
|
||||||
|
json_post \
|
||||||
|
"$API_URL/api/v1/digitization-batches/$correction_batch_id/draft/observations" \
|
||||||
|
"{\"observationCode\":\"K\",\"value\":$potassium_value,\"unit\":\"mmol/L\",\"recordedAt\":\"$RECORDED_AT\"}" \
|
||||||
|
"$entry_token" >/dev/null
|
||||||
|
json_post \
|
||||||
|
"$API_URL/api/v1/digitization-batches/$correction_batch_id/draft/observations" \
|
||||||
|
"{\"observationCode\":\"Na\",\"value\":$sodium_value,\"unit\":\"mmol/L\",\"recordedAt\":\"$RECORDED_AT\"}" \
|
||||||
|
"$entry_token" >/dev/null
|
||||||
|
|
||||||
|
local submit_code
|
||||||
|
submit_code="$(http_code -X POST \
|
||||||
|
"$API_URL/api/v1/digitization-batches/$correction_batch_id/submit-for-verification" \
|
||||||
|
-H "Authorization: Bearer $entry_token")"
|
||||||
|
if [[ "$submit_code" != "200" ]]; then
|
||||||
|
log "ERROR: correction submit-for-verification failed (HTTP $submit_code)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
verifier_token="$(extract_data_field "$(login verifier2)" token)"
|
||||||
|
local verify_json
|
||||||
|
verify_json="$(verify_batch "$verifier_token" "$correction_batch_id" \
|
||||||
|
'{"fieldChecks":[{"fieldName":"observation.K","status":"ok","note":null}],"passed":true}')"
|
||||||
|
if [[ "$(jq -er '.success' <<<"$verify_json")" != "true" ]]; then
|
||||||
|
log "ERROR: correction verification failed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
approver_token="$(extract_data_field "$(login approver2)" token)"
|
||||||
|
approve_json="$(approve_batch "$approver_token" "$correction_batch_id" "$(new_idempotency_key)")"
|
||||||
|
if [[ "$(jq -er '.success' <<<"$approve_json")" != "true" ]]; then
|
||||||
|
log "ERROR: correction approve failed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$(extract_data_field "$approve_json" status)" != "PROMOTED" ]]; then
|
||||||
|
log "ERROR: correction batch not promoted"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "$correction_batch_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_schema_supersession_columns() {
|
||||||
|
section "1. Schema — live_observations supersession columns and partial index"
|
||||||
|
|
||||||
|
if ! psql_available; then
|
||||||
|
log " SKIP: PostgreSQL not reachable"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local columns index_count
|
||||||
|
columns="$(psql_query "
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'live_observations'
|
||||||
|
AND column_name IN ('is_superseded', 'superseded_by_batch_id', 'superseded_at')
|
||||||
|
ORDER BY column_name;
|
||||||
|
" | tr '\n' ',' | sed 's/,$//')"
|
||||||
|
|
||||||
|
if [[ "$columns" == "is_superseded,superseded_at,superseded_by_batch_id" ]]; then
|
||||||
|
pass "live_observations has is_superseded, superseded_by_batch_id, superseded_at"
|
||||||
|
else
|
||||||
|
fail "live_observations has supersession columns (got: ${columns:-<none>})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
index_count="$(psql_query "
|
||||||
|
SELECT count(*)
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE tablename = 'live_observations'
|
||||||
|
AND indexdef ILIKE '%is_superseded%';
|
||||||
|
")"
|
||||||
|
if [[ "$index_count" -ge 1 ]]; then
|
||||||
|
pass "partial index on is_superseded exists"
|
||||||
|
else
|
||||||
|
fail "partial index on is_superseded exists (count=${index_count:-<none>})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_no_live_observation_mutation_endpoint() {
|
||||||
|
section "2. Invariant — no API endpoint mutates live observations directly"
|
||||||
|
|
||||||
|
local swagger_paths
|
||||||
|
swagger_paths="$(curl -sS "$API_URL/swagger/v1/swagger.json")"
|
||||||
|
|
||||||
|
if jq -e '.paths | keys[] | select(test("live-observation|live_observation"; "i"))' \
|
||||||
|
<<<"$swagger_paths" >/dev/null 2>&1; then
|
||||||
|
fail "swagger exposes live observation mutation routes"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
pass "swagger has no live observation mutation routes"
|
||||||
|
|
||||||
|
local patch_code
|
||||||
|
patch_code="$(http_code -X PATCH \
|
||||||
|
"$API_URL/api/v1/live-observations/$(new_idempotency_key)" \
|
||||||
|
-H "Authorization: Bearer $(extract_data_field "$(login admin1)" token)" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"value":99}')"
|
||||||
|
|
||||||
|
if [[ "$patch_code" == "404" || "$patch_code" == "405" ]]; then
|
||||||
|
pass "PATCH /api/v1/live-observations/:id is not available ($patch_code)"
|
||||||
|
else
|
||||||
|
fail "PATCH /api/v1/live-observations/:id is not available (http=$patch_code)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_full_correction_supersession_flow() {
|
||||||
|
section "3. Full cycle — wrong K promoted, correction supersedes original"
|
||||||
|
|
||||||
|
local intake_token approver_token original_batch_id correction_batch_id
|
||||||
|
local approve_json patient_id
|
||||||
|
|
||||||
|
intake_token="$(extract_data_field "$(login intake1)" token)"
|
||||||
|
original_batch_id="$(create_lab_batch_ready_for_approval "$intake_token" "3.5" "140" \
|
||||||
|
"Phase5 Correction Patient $(date +%s)" "1980-01-15")" || return
|
||||||
|
|
||||||
|
approver_token="$(extract_data_field "$(login approver1)" token)"
|
||||||
|
approve_json="$(approve_batch "$approver_token" "$original_batch_id" "$(new_idempotency_key)")"
|
||||||
|
if [[ "$(jq -er '.success' <<<"$approve_json")" != "true" ]]; then
|
||||||
|
fail "original lab batch promotes successfully"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
patient_id="$(extract_data_field "$approve_json" patientId)"
|
||||||
|
if [[ -z "$patient_id" ]]; then
|
||||||
|
fail "original promotion returns patientId"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
pass "original lab batch promoted with wrong K=3.5"
|
||||||
|
|
||||||
|
correction_batch_id="$(promote_correction_batch "$intake_token" "$original_batch_id" \
|
||||||
|
"$patient_id" "5.3" "140")" || {
|
||||||
|
fail "correction batch promoted with corrected K=5.3"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pass "correction batch promoted with corrected K=5.3"
|
||||||
|
|
||||||
|
SHARED_ORIGINAL_BATCH_ID="$original_batch_id"
|
||||||
|
SHARED_CORRECTION_BATCH_ID="$correction_batch_id"
|
||||||
|
SHARED_PATIENT_ID="$patient_id"
|
||||||
|
|
||||||
|
if ! psql_available; then
|
||||||
|
log " SKIP: live_observations supersession DB checks"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local active_k superseded_k total_count correction_event
|
||||||
|
active_k="$(psql_query "
|
||||||
|
SELECT value::text
|
||||||
|
FROM live_observations
|
||||||
|
WHERE patient_id = '$patient_id'
|
||||||
|
AND observation_code = 'K'
|
||||||
|
AND is_superseded = false;
|
||||||
|
")"
|
||||||
|
superseded_k="$(psql_query "
|
||||||
|
SELECT value::text
|
||||||
|
FROM live_observations
|
||||||
|
WHERE patient_id = '$patient_id'
|
||||||
|
AND observation_code = 'K'
|
||||||
|
AND is_superseded = true
|
||||||
|
AND superseded_by_batch_id = '$correction_batch_id';
|
||||||
|
")"
|
||||||
|
total_count="$(psql_query "
|
||||||
|
SELECT count(*)
|
||||||
|
FROM live_observations
|
||||||
|
WHERE patient_id = '$patient_id';
|
||||||
|
")"
|
||||||
|
correction_event="$(psql_query "
|
||||||
|
SELECT count(*)
|
||||||
|
FROM digitization_events
|
||||||
|
WHERE batch_id = '$correction_batch_id'
|
||||||
|
AND event_type = 'correction_uploaded';
|
||||||
|
")"
|
||||||
|
|
||||||
|
if [[ "$active_k" == "5.300" || "$active_k" == "5.3" ]]; then
|
||||||
|
pass "active potassium value is 5.3 after correction"
|
||||||
|
else
|
||||||
|
fail "active potassium value is 5.3 after correction (got: ${active_k:-<none>})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$superseded_k" == "3.500" || "$superseded_k" == "3.5" ]]; then
|
||||||
|
pass "superseded potassium value 3.5 preserved for audit"
|
||||||
|
else
|
||||||
|
fail "superseded potassium value 3.5 preserved for audit (got: ${superseded_k:-<none>})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$total_count" == "4" ]]; then
|
||||||
|
pass "four live_observations rows retained (2 superseded + 2 active)"
|
||||||
|
else
|
||||||
|
fail "four live_observations rows retained (got: ${total_count:-<none>})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$correction_event" == "1" ]]; then
|
||||||
|
pass "correction_uploaded event recorded on correction batch"
|
||||||
|
else
|
||||||
|
fail "correction_uploaded event recorded on correction batch (count=${correction_event:-<none>})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_supersession_validation_non_promoted() {
|
||||||
|
section "4. Validation — correction against non-promoted batch returns 422"
|
||||||
|
|
||||||
|
local intake_token upload_json error_code upload_code
|
||||||
|
|
||||||
|
intake_token="$(extract_data_field "$(login intake1)" token)"
|
||||||
|
local pending_batch_id
|
||||||
|
pending_batch_id="$(extract_data_field "$(upload_batch "$intake_token")" id)"
|
||||||
|
|
||||||
|
upload_json="$(upload_batch "$intake_token" "LAB_RESULTS" "BACKFILL" "$FIXTURE_PDF" \
|
||||||
|
"$pending_batch_id")"
|
||||||
|
upload_code="$(jq -er '.statusCode // empty' <<<"$upload_json")"
|
||||||
|
error_code="$(extract_error_code "$upload_json")"
|
||||||
|
|
||||||
|
if [[ "$upload_code" == "422" && "$error_code" == "SUPERSEDED_BATCH_NOT_PROMOTED" ]]; then
|
||||||
|
pass "non-promoted batch supersession returns 422 SUPERSEDED_BATCH_NOT_PROMOTED"
|
||||||
|
else
|
||||||
|
fail "non-promoted batch supersession returns 422 SUPERSEDED_BATCH_NOT_PROMOTED (http=$upload_code code=${error_code:-<none>})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_supersession_validation_not_found() {
|
||||||
|
section "5. Validation — correction against missing batch returns 404"
|
||||||
|
|
||||||
|
local intake_token upload_json error_code upload_code fake_id
|
||||||
|
|
||||||
|
fake_id="$(new_idempotency_key)"
|
||||||
|
intake_token="$(extract_data_field "$(login intake1)" token)"
|
||||||
|
upload_json="$(upload_batch "$intake_token" "LAB_RESULTS" "BACKFILL" "$FIXTURE_PDF" "$fake_id")"
|
||||||
|
upload_code="$(jq -er '.statusCode // empty' <<<"$upload_json")"
|
||||||
|
error_code="$(extract_error_code "$upload_json")"
|
||||||
|
|
||||||
|
if [[ "$upload_code" == "404" && "$error_code" == "SUPERSEDED_BATCH_NOT_FOUND" ]]; then
|
||||||
|
pass "missing batch supersession returns 404 SUPERSEDED_BATCH_NOT_FOUND"
|
||||||
|
else
|
||||||
|
fail "missing batch supersession returns 404 SUPERSEDED_BATCH_NOT_FOUND (http=$upload_code code=${error_code:-<none>})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_supersession_validation_already_superseded() {
|
||||||
|
section "6. Validation — second correction against superseded batch returns 409"
|
||||||
|
|
||||||
|
if [[ -z "$SHARED_ORIGINAL_BATCH_ID" || -z "$SHARED_PATIENT_ID" ]]; then
|
||||||
|
fail "already-superseded validation requires full correction flow (run test 3 first)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local intake_token upload_json error_code upload_code correction_pdf
|
||||||
|
|
||||||
|
intake_token="$(extract_data_field "$(login intake1)" token)"
|
||||||
|
correction_pdf="$(create_temp_pdf "already-superseded")"
|
||||||
|
upload_json="$(upload_batch "$intake_token" "LAB_RESULTS" "BACKFILL" "$correction_pdf" \
|
||||||
|
"$SHARED_ORIGINAL_BATCH_ID" "$SHARED_PATIENT_ID")"
|
||||||
|
rm -f "$correction_pdf"
|
||||||
|
upload_code="$(jq -er '.statusCode // empty' <<<"$upload_json")"
|
||||||
|
error_code="$(extract_error_code "$upload_json")"
|
||||||
|
|
||||||
|
if [[ "$upload_code" == "409" && "$error_code" == "BATCH_ALREADY_SUPERSEDED" ]]; then
|
||||||
|
pass "already-superseded batch returns 409 BATCH_ALREADY_SUPERSEDED"
|
||||||
|
else
|
||||||
|
fail "already-superseded batch returns 409 BATCH_ALREADY_SUPERSEDED (http=$upload_code code=${error_code:-<none>})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_patient_digitization_history() {
|
||||||
|
section "7. Patient digitization history — correction chain in API response"
|
||||||
|
|
||||||
|
if [[ -z "$SHARED_PATIENT_ID" || -z "$SHARED_ORIGINAL_BATCH_ID" || -z "$SHARED_CORRECTION_BATCH_ID" ]]; then
|
||||||
|
fail "digitization history requires full correction flow (run test 3 first)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local clinician_token history_json
|
||||||
|
clinician_token="$(extract_data_field "$(login clinician1)" token)"
|
||||||
|
history_json="$(curl -sS \
|
||||||
|
"$API_URL/api/v1/patients/$SHARED_PATIENT_ID/digitization-history" \
|
||||||
|
-H "Authorization: Bearer $clinician_token")"
|
||||||
|
|
||||||
|
local total promoted superseded
|
||||||
|
total="$(extract_data_field "$history_json" totalBatches)"
|
||||||
|
promoted="$(extract_data_field "$history_json" promotedBatches)"
|
||||||
|
superseded="$(extract_data_field "$history_json" supersededBatches)"
|
||||||
|
|
||||||
|
if [[ "$(jq -er '.success' <<<"$history_json")" == "true" &&
|
||||||
|
"$total" == "2" && "$promoted" == "2" && "$superseded" == "1" ]]; then
|
||||||
|
pass "history summary counts: totalBatches=2, promotedBatches=2, supersededBatches=1"
|
||||||
|
else
|
||||||
|
fail "history summary counts (total=$total promoted=$promoted superseded=$superseded)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local original_superseded correction_is_correction
|
||||||
|
original_superseded="$(jq -er \
|
||||||
|
--arg id "$SHARED_ORIGINAL_BATCH_ID" \
|
||||||
|
'.data.entries[] | select(.batchId == $id) | .hasBeenSuperseded' <<<"$history_json")"
|
||||||
|
correction_is_correction="$(jq -er \
|
||||||
|
--arg id "$SHARED_CORRECTION_BATCH_ID" \
|
||||||
|
'.data.entries[] | select(.batchId == $id) | .isCorrection' <<<"$history_json")"
|
||||||
|
|
||||||
|
if [[ "$original_superseded" == "true" && "$correction_is_correction" == "true" ]]; then
|
||||||
|
pass "original marked hasBeenSuperseded; correction marked isCorrection"
|
||||||
|
else
|
||||||
|
fail "original hasBeenSuperseded=$original_superseded correction isCorrection=$correction_is_correction"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local audit_len
|
||||||
|
audit_len="$(jq -er \
|
||||||
|
--arg id "$SHARED_ORIGINAL_BATCH_ID" \
|
||||||
|
'.data.entries[] | select(.batchId == $id) | .auditTrail | length' <<<"$history_json")"
|
||||||
|
if [[ "$audit_len" -ge 2 ]]; then
|
||||||
|
pass "original batch entry includes audit trail events"
|
||||||
|
else
|
||||||
|
fail "original batch entry includes audit trail events (len=${audit_len:-0})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_patient_history_unknown_patient() {
|
||||||
|
section "8. Patient digitization history — unknown patient returns 404"
|
||||||
|
|
||||||
|
local admin_token unknown_id history_code history_json error_code
|
||||||
|
|
||||||
|
unknown_id="$(new_idempotency_key)"
|
||||||
|
admin_token="$(extract_data_field "$(login admin1)" token)"
|
||||||
|
history_json="$(curl -sS \
|
||||||
|
"$API_URL/api/v1/patients/$unknown_id/digitization-history" \
|
||||||
|
-H "Authorization: Bearer $admin_token")"
|
||||||
|
history_code="$(jq -er '.statusCode // empty' <<<"$history_json")"
|
||||||
|
error_code="$(extract_error_code "$history_json")"
|
||||||
|
|
||||||
|
if [[ "$history_code" == "404" && "$error_code" == "PATIENT_HISTORY_NOT_FOUND" ]]; then
|
||||||
|
pass "unknown patient returns 404 PATIENT_HISTORY_NOT_FOUND"
|
||||||
|
else
|
||||||
|
fail "unknown patient returns 404 PATIENT_HISTORY_NOT_FOUND (http=$history_code code=${error_code:-<none>})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_audit_trail_integrity() {
|
||||||
|
section "9. Audit trail — superseded and correction_promoted events"
|
||||||
|
|
||||||
|
if [[ -z "$SHARED_ORIGINAL_BATCH_ID" || -z "$SHARED_CORRECTION_BATCH_ID" ]]; then
|
||||||
|
fail "audit trail check requires full correction flow (run test 3 first)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! psql_available; then
|
||||||
|
log " SKIP: audit trail DB checks"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local original_superseded correction_promoted original_promoted
|
||||||
|
original_superseded="$(psql_query "
|
||||||
|
SELECT count(*)
|
||||||
|
FROM digitization_events
|
||||||
|
WHERE batch_id = '$SHARED_ORIGINAL_BATCH_ID'
|
||||||
|
AND event_type = 'superseded';
|
||||||
|
")"
|
||||||
|
correction_promoted="$(psql_query "
|
||||||
|
SELECT count(*)
|
||||||
|
FROM digitization_events
|
||||||
|
WHERE batch_id = '$SHARED_CORRECTION_BATCH_ID'
|
||||||
|
AND event_type = 'correction_promoted';
|
||||||
|
")"
|
||||||
|
original_promoted="$(psql_query "
|
||||||
|
SELECT count(*)
|
||||||
|
FROM digitization_events
|
||||||
|
WHERE batch_id = '$SHARED_ORIGINAL_BATCH_ID'
|
||||||
|
AND event_type = 'promoted';
|
||||||
|
")"
|
||||||
|
|
||||||
|
if [[ "$original_promoted" == "1" ]]; then
|
||||||
|
pass "original batch has promoted event"
|
||||||
|
else
|
||||||
|
fail "original batch has promoted event (count=${original_promoted:-<none>})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$original_superseded" == "1" ]]; then
|
||||||
|
pass "original batch has superseded event after correction promotion"
|
||||||
|
else
|
||||||
|
fail "original batch has superseded event (count=${original_superseded:-<none>})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$correction_promoted" == "1" ]]; then
|
||||||
|
pass "correction batch has correction_promoted event"
|
||||||
|
else
|
||||||
|
fail "correction batch has correction_promoted event (count=${correction_promoted:-<none>})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_active_observation_filter() {
|
||||||
|
section "10. Clinical query — active observations exclude superseded rows"
|
||||||
|
|
||||||
|
if [[ -z "$SHARED_PATIENT_ID" ]]; then
|
||||||
|
fail "active observation filter requires full correction flow (run test 3 first)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! psql_available; then
|
||||||
|
log " SKIP: active observation filter DB checks"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local active_count audit_count
|
||||||
|
active_count="$(psql_query "
|
||||||
|
SELECT count(*)
|
||||||
|
FROM live_observations
|
||||||
|
WHERE patient_id = '$SHARED_PATIENT_ID'
|
||||||
|
AND is_superseded = false;
|
||||||
|
")"
|
||||||
|
audit_count="$(psql_query "
|
||||||
|
SELECT count(*)
|
||||||
|
FROM live_observations
|
||||||
|
WHERE patient_id = '$SHARED_PATIENT_ID'
|
||||||
|
AND is_superseded = true;
|
||||||
|
")"
|
||||||
|
|
||||||
|
if [[ "$active_count" == "2" ]]; then
|
||||||
|
pass "default active query returns 2 non-superseded observations"
|
||||||
|
else
|
||||||
|
fail "default active query returns 2 non-superseded observations (got: ${active_count:-<none>})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$audit_count" == "2" ]]; then
|
||||||
|
pass "audit query returns 2 superseded observations"
|
||||||
|
else
|
||||||
|
fail "audit query returns 2 superseded observations (got: ${audit_count:-<none>})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_integration_tests() {
|
||||||
|
section "11. Integration tests — CorrectionSupersessionTests"
|
||||||
|
|
||||||
|
if [[ "$SKIP_TEST_CHECKS" == "1" ]]; then
|
||||||
|
log " SKIP: VIGILCARE_SKIP_TEST_CHECKS=1"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v dotnet >/dev/null 2>&1; then
|
||||||
|
log " SKIP: dotnet not found"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local test_output test_exit
|
||||||
|
test_output="$(dotnet test "$REPO_ROOT/VigilCareRecordsAPI.Tests" \
|
||||||
|
--filter "FullyQualifiedName~CorrectionSupersessionTests" \
|
||||||
|
--verbosity minimal 2>&1)"
|
||||||
|
test_exit=$?
|
||||||
|
|
||||||
|
if [[ "$test_exit" -eq 0 ]] && grep -q "Passed!" <<<"$test_output"; then
|
||||||
|
pass "CorrectionSupersessionTests pass (dotnet test)"
|
||||||
|
else
|
||||||
|
fail "CorrectionSupersessionTests pass (dotnet test)"
|
||||||
|
log "$test_output"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
require_cmd curl
|
||||||
|
require_cmd jq
|
||||||
|
require_cmd docker
|
||||||
|
|
||||||
|
if [[ ! -f "$FIXTURE_PDF" ]]; then
|
||||||
|
log "ERROR: missing fixture PDF at $FIXTURE_PDF"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "VigilCare Records — Phase 5 verification"
|
||||||
|
log "API: $API_URL"
|
||||||
|
if compose_service_running postgres; then
|
||||||
|
log "PostgreSQL: docker compose exec (service: postgres)"
|
||||||
|
elif command -v psql >/dev/null 2>&1; then
|
||||||
|
log "PostgreSQL: host psql ($PG_HOST:$PG_PORT)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
assert_api_reachable
|
||||||
|
|
||||||
|
test_schema_supersession_columns
|
||||||
|
test_no_live_observation_mutation_endpoint
|
||||||
|
test_full_correction_supersession_flow
|
||||||
|
test_supersession_validation_non_promoted
|
||||||
|
test_supersession_validation_not_found
|
||||||
|
test_supersession_validation_already_superseded
|
||||||
|
test_patient_digitization_history
|
||||||
|
test_patient_history_unknown_patient
|
||||||
|
test_audit_trail_integrity
|
||||||
|
test_active_observation_filter
|
||||||
|
test_integration_tests
|
||||||
|
|
||||||
|
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 5 verification checks passed."
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Reference in New Issue
Block a user