From 470df683dd638940566b8486b61befb514d11bc7 Mon Sep 17 00:00:00 2001 From: voltsrage Date: Fri, 26 Jun 2026 14:04:52 +0800 Subject: [PATCH] feature: Approval and Promotion to VigilCareClinical --- README.md | 718 ++++++++++++++++++ VigilCareRecordsAPI.Tests/DraftEntryTests.cs | 3 +- .../Fixtures/DatabaseCollection.cs | 2 + .../Helpers/BatchSeedHelper.cs | 103 +++ .../VerificationTests.cs | 569 ++++++++++++++ .../Configurations/SiteConfigOptions.cs | 26 + .../Controllers/VerificationController.cs | 70 ++ .../Controllers/WorkQueueController.cs | 71 ++ VigilCareRecordsAPI/Data/Seed/DataSeeder.cs | 11 +- .../Models/Records/Batch/FieldCheck.cs | 11 + .../Records/Batch/RejectBatchRequest.cs | 5 + .../Records/Batch/VerifyBatchRequest.cs | 9 + .../WorkQueue/WorkQueueItemResponse.cs | 17 + .../Records/WorkQueue/WorkQueueResponse.cs | 11 + VigilCareRecordsAPI/Program.cs | 6 + .../Interfaces/IVerificationService.cs | 20 + .../Services/Interfaces/IWorkQueueService.cs | 27 + .../Services/VerificationService.cs | 215 ++++++ .../Services/WorkQueueService.cs | 83 ++ VigilCareRecordsAPI/appsettings.json | 11 + ...-vigilcare-records-phase-3-verification.sh | 654 ++++++++++++++++ 21 files changed, 2638 insertions(+), 4 deletions(-) create mode 100644 README.md create mode 100644 VigilCareRecordsAPI.Tests/Fixtures/DatabaseCollection.cs create mode 100644 VigilCareRecordsAPI.Tests/Helpers/BatchSeedHelper.cs create mode 100644 VigilCareRecordsAPI.Tests/VerificationTests.cs create mode 100644 VigilCareRecordsAPI/Configurations/SiteConfigOptions.cs create mode 100644 VigilCareRecordsAPI/Controllers/VerificationController.cs create mode 100644 VigilCareRecordsAPI/Controllers/WorkQueueController.cs create mode 100644 VigilCareRecordsAPI/Models/Records/Batch/FieldCheck.cs create mode 100644 VigilCareRecordsAPI/Models/Records/Batch/RejectBatchRequest.cs create mode 100644 VigilCareRecordsAPI/Models/Records/Batch/VerifyBatchRequest.cs create mode 100644 VigilCareRecordsAPI/Models/Records/WorkQueue/WorkQueueItemResponse.cs create mode 100644 VigilCareRecordsAPI/Models/Records/WorkQueue/WorkQueueResponse.cs create mode 100644 VigilCareRecordsAPI/Services/Interfaces/IVerificationService.cs create mode 100644 VigilCareRecordsAPI/Services/Interfaces/IWorkQueueService.cs create mode 100644 VigilCareRecordsAPI/Services/VerificationService.cs create mode 100644 VigilCareRecordsAPI/Services/WorkQueueService.cs create mode 100755 scripts/run-vigilcare-records-phase-3-verification.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..99a429f --- /dev/null +++ b/README.md @@ -0,0 +1,718 @@ +# 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. + +**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. + +## Domain Model — How It Maps to a Real Clinical System + +In a paper-based hospital, patient records exist as handwritten charts, ward books, and index cards. VigilCare Records converts these into structured digital data through a governed workflow: an intake clerk scans a paper chart and uploads it to MinIO. A data entry clerk reads the scan and transcribes patient demographics, encounter context, and observation values into structured draft fields. A verifier (who cannot be the entry clerk) compares the draft against the original scan and approves or rejects field-by-field. For high-stakes batch types (vitals, labs, encounter summaries, medications), a clinical approver provides a final sign-off before promotion. On approval, draft records are promoted atomically to VigilCareClinical's live tables — Patient, Encounter, Observation — where they enter the real-time alerting and scoring pipeline. Unapproved drafts never trigger alerts, scoring, or surveillance. + +``` +DigitizationBatch ────────────── one unit of work: one scan, one chart section + ├── ScannedDocument MinIO object — PDF/JPEG/PNG, SHA-256, never deleted + ├── DraftPatient demographics, allergies, blood type, medications (structured from paper) + ├── DraftEncounter admission date, department, room/bed, admission reason + ├── DraftObservation[] one measurement per row: observation code, value, unit, recordedAt + └── DigitizationEvent[] append-only audit log: every status transition, every actor, every timestamp +``` + +### DigitizationBatch + +The unit of work for one digitization effort — typically one scanned document or one logical chart section (vitals sheet, lab report, admission face sheet). Tracks the full lifecycle from upload through promotion with actor attribution at every gate. Seven batch types (`PATIENT_REGISTRATION`, `ENCOUNTER_SUMMARY`, `VITALS_SHEET`, `LAB_RESULTS`, `MEDICATION_LIST`, `ALLERGY_UPDATE`, `MIXED`) drive completeness validation rules on submit. Two tracks: `BACKFILL` (full dual-human gate for historical charts) and `LIVE_CAPTURE` (clinician attestation at bedside, lighter gate). + +### DraftPatient + +Structured patient fields extracted from paper — full name, date of birth, sex, blood type, allergies (JSON), emergency contact, medications (JSON for `MEDICATION_LIST` batches). On approval of a `PATIENT_REGISTRATION` or `ALLERGY_UPDATE` batch, merges into VigilCareClinical's live `Patient` record. + +### DraftEncounter + +A clinical episode extracted from the chart: admission date, department, room/bed, admission reason, discharge diagnosis. Promotes to VigilCareClinical's live `Encounter`. + +### DraftObservation + +A single measurable value: observation code, numeric value, unit, `recordedAt` (from the chart, required), optional note. Subject to the same plausibility ranges as VigilCareClinical ingest. Never written to live `observations` until batch approval. + +### ScannedDocument + +Stored in MinIO. Original paper is the legal source; the scan is the working reference for entry and verification. Scanned documents are **never deleted** when a batch is rejected — only the draft is returned for correction. + +### DigitizationEvent + +Append-only audit log entry for every state transition, field-level correction, and workflow action. Each event records the batch, event type, actor user ID, timestamp, and optional JSON metadata (field checks, rejection reasons, assigned-to user). + +--- + +## 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 +- **Batch Assignment** — `PATCH /digitization-batches/:id/assign` assigns an entry clerk with a Redis lock (`SET batch:assign:{id} NX EX 3600`) to prevent double-assignment; `409 BATCH_ALREADY_ASSIGNED` on conflict +- **Draft Data Entry** — full CRUD on draft payload: upsert patient demographics, upsert encounter context, add/edit/delete observation rows; plausibility validation on observation values at draft save time (catches decimal errors before verification); automatic `UPLOADED → IN_ENTRY` transition on first save; draft save requires the acting user to match `enteredByUserId` or hold `Administrator` role (`409 BATCH_NOT_ASSIGNED`) +- **Submit for Verification** — validates completeness per batch type (e.g. vitals sheet requires linked patient, encounter context, and at least one observation with `recordedAt`); transitions `IN_ENTRY → PENDING_VERIFICATION`; returns `422` with missing fields if incomplete +- **Verification and Rejection** — verifier reviews entry against the scan with field-level checks (`fieldName`, `status: ok|warning|error`, optional `note`); verify pass transitions to `VERIFIED` or `AWAITING_CLINICAL_APPROVAL` based on site configuration for the batch type; verify fail transitions to `REJECTED` with mandatory reason; **separation of duties** enforced: entry clerk cannot verify their own batch (`409 SEPARATION_OF_DUTIES_VIOLATION`) +- **Clinical Approval Routing** — site-configurable per batch type (`SiteConfig.ClinicalApprovalRequired`); high-stakes batch types (encounter summary, vitals sheet, lab results, medication list, mixed) route to `AWAITING_CLINICAL_APPROVAL` after verification for physician sign-off; low-stakes types (patient registration, allergy update) go directly to `VERIFIED` +- **Work Queues** — `GET /work-queue/verification` (batches in `PENDING_VERIFICATION`, sorted by submission time ASC); `GET /work-queue/entry` (batches in `UPLOADED`, `IN_ENTRY`, or `REJECTED`); `GET /work-queue/clinical-approval` (batches in `AWAITING_CLINICAL_APPROVAL`); role-restricted access +- **Batch Status Machine** — explicit transition matrix enforced in the service layer; illegal transitions return `409`; `PROMOTED` is terminal — corrections require a new batch with `supersedesBatchId` +- **JWT Authentication** — `POST /auth/login` returns access token (15 min) and refresh token (7 days); `POST /auth/refresh` rotates tokens; `POST /auth/logout` revokes server-side; `GET /auth/me` returns authenticated user profile; BCrypt password hashing +- **Role-Based Access** — six roles (`INTAKE_CLERK`, `DATA_ENTRY_CLERK`, `VERIFIER`, `CLINICAL_APPROVER`, `CLINICIAN`, `ADMINISTRATOR`) with role-based endpoint authorization; twelve seeded demo users (two per role) +- **Auth Audit Events** — append-only `auth_audit_events` table records login, logout, token refresh, and failed login attempts with user ID, IP address, and timestamp +- **Standard Envelope** — all responses use `{ success, statusCode, data, error }` wrapper; validation errors use the same shape with stable error codes +- **Observability** — Serilog structured logging with Seq sink; correlation IDs via `CorrelationIdMiddleware`; `ExceptionHandlerMiddleware` for consistent error responses +- **Swagger UI** — OpenAPI spec via Swashbuckle (Development only) at `http://localhost:5217/swagger` + +--- + +## Architecture + +``` +HTTP request + → CorrelationIdMiddleware + → ExceptionHandlerMiddleware + → JWT Authentication (Bearer) + → Role-based Authorization ([Authorize(Roles = "...")]) + → Controllers (REST API) + → Services + ├── AuthService (login, refresh token rotation, logout, BCrypt verify) + ├── BatchService (batch CRUD, status machine, Redis assignment lock, duplicate detection) + ├── DraftService (draft patient/encounter/observation CRUD, plausibility validation, submit-for-verification) + ├── VerificationService (verify/reject with separation of duties, site-config approval routing) + ├── WorkQueueService (verification, entry, clinical approval queues) + ├── DocumentStorageService (MinIO upload, SHA-256, presigned URLs) + ├── PlausibilityValidator (per-code numeric range guard) + ├── PostgreSQL (EF Core — writes, keyed reads) + ├── Redis (batch assignment locks) + └── MinIO (scanned document storage) +``` + +**Relationship to VigilCareClinical:** VigilCare Records is the precursor that creates the fuel — patient identity, encounters, and observations — in a governed, auditable way. Draft observations are invisible to VigilCareClinical's alert engine, Elasticsearch ward projection, and scoring consumers. Only batches that pass approval are promoted to live tables. Promotion (Phase 4) will write directly to VigilCareClinical's `patients`, `encounters`, `observations`, and `outbox_events` tables in a single transaction. + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ VigilCare Records (this project) │ +│ │ +│ Scan → Entry → Verify → Approve │ +│ ↓ │ +│ Draft tables (never alert) │ +│ ↓ on approval (Phase 4) │ +│ Promotion service ──────────────────────────────────────────────┐ │ +└──────────────────────────────────────────────────────────────────│──┘ + │ +┌──────────────────────────────────────────────────────────────────▼──┐ +│ VigilCareClinicalAPI │ +│ │ +│ Patient → Encounter → Observation → Outbox → Kafka → Alerts │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Tech Stack + +| Layer | Technology | +|---|---| +| Server | ASP.NET Core 8 (.NET 8.0) | +| Database | PostgreSQL 16 with EF Core 8 (code-first migrations) | +| Cache / locking | Redis 7 (batch assignment locks) | +| Object storage | MinIO (scanned documents — PDF, JPEG, PNG) | +| Authentication | JWT Bearer (Microsoft.AspNetCore.Authentication.JwtBearer) | +| Password hashing | BCrypt.Net-Next | +| Logging | Serilog + Seq sink | +| Docs | Swagger / OpenAPI (Swashbuckle) | +| Testing | xUnit + FluentAssertions + WebApplicationFactory | + +--- + +## Project Structure + +``` +VigilCareRecordsAPI/ +├── Program.cs # Service registration, middleware, seed on startup +├── appsettings.json # Connection strings, Redis, MinIO, Seq, JWT, site config +├── Controllers/ +│ ├── AuthController.cs # JWT login, token refresh, logout, authenticated user profile +│ ├── DigitizationBatchesController.cs # Batch CRUD, document upload, assignment +│ ├── DraftController.cs # Draft data entry: patient, encounter, observations, submit +│ ├── VerificationController.cs # Batch verification and rejection with separation of duties +│ └── WorkQueueController.cs # Work queues: verification, entry, clinical approval +├── Domain/ +│ ├── Entities/ +│ │ ├── DigitizationBatch.cs # Central workflow entity with status machine +│ │ ├── DraftPatient.cs # Structured patient demographics from paper chart +│ │ ├── DraftEncounter.cs # Encounter context: admission, department, room/bed +│ │ ├── DraftObservation.cs # Single measurement: code, value, unit, recordedAt +│ │ ├── ScannedDocument.cs # MinIO object reference with SHA-256 integrity hash +│ │ ├── DigitizationEvent.cs # Append-only audit trail for every status transition +│ │ ├── User.cs # Username, BCrypt hash, full name, role, active flag +│ │ ├── RefreshToken.cs # Opaque refresh token with user FK, expiry, revocation +│ │ └── AuthAuditEvent.cs # Auth event audit: login, logout, refresh, failed attempts +│ └── Enums/ +│ ├── BatchStatus.cs # Uploaded → InEntry → PendingVerification → Verified/AwaitingClinicalApproval → Approved → Promoted +│ ├── BatchType.cs # PatientRegistration, VitalsSheet, LabResults, EncounterSummary, MedicationList, AllergyUpdate, Mixed +│ ├── BatchTrack.cs # Backfill (Track A) or LiveCapture (Track B) +│ ├── UserRole.cs # IntakeClerk, DataEntryClerk, Verifier, ClinicalApprover, Clinician, Administrator +│ ├── DigitizationEventType.cs # 18 event types covering full lifecycle + corrections + promotion retry +│ ├── BloodType.cs # A+, A-, B+, B-, AB+, AB-, O+, O- +│ └── Department.cs # Clinical departments +├── Services/ +│ ├── Interfaces/ # IAuthService, IBatchService, IDraftService, IVerificationService, IWorkQueueService, IDocumentStorageService +│ ├── AuthService.cs # Login (BCrypt verify), JWT generation, refresh rotation, logout, audit logging +│ ├── BatchService.cs # Batch CRUD, status machine transitions, Redis assignment lock, duplicate detection +│ ├── DraftService.cs # Draft CRUD with plausibility validation, batch-type completeness on submit +│ ├── VerificationService.cs # Verify/reject with separation of duties, site-config clinical approval routing +│ ├── WorkQueueService.cs # Verification, entry, and clinical approval work queues +│ ├── DocumentStorageService.cs # MinIO upload with SHA-256, presigned URL generation +│ └── PlausibilityValidator.cs # Per-code numeric range guard (reused from VigilCareClinical) +├── Configurations/ +│ ├── JwtOptions.cs # Issuer, audience, signing key, access/refresh token expiration +│ ├── MinioOptions.cs # Endpoint, credentials, bucket, presigned URL expiry +│ └── SiteConfigOptions.cs # ClinicalApprovalRequired map per batch type +├── Models/Records/ +│ ├── Auth/ # LoginRequest, LoginResponse, RefreshRequest, LogoutRequest, TokenResponse, UserProfileResponse +│ ├── Batch/ # CreateBatchForm, CreateBatchRequest, AssignBatchRequest, BatchDetailResponse, BatchListResponse, DraftPayloadResponse, VerifyBatchRequest, RejectBatchRequest, FieldCheck +│ ├── DraftPatient/ # DraftPatientDto, UpsertDraftPatientRequest +│ ├── DraftEncounter/ # DraftEncounterDto, UpsertDraftEncounterRequest +│ ├── DraftObservation/ # DraftObservationDto, CreateDraftObservationRequest, UpdateDraftObservationRequest +│ ├── WorkQueue/ # WorkQueueResponse, WorkQueueItemResponse +│ └── Common/ # PagedResult +├── Data/ +│ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints +│ ├── Configurations/ # IEntityTypeConfiguration per entity (snake_case mapping) +│ ├── Seed/ +│ │ └── DataSeeder.cs # Seeds 12 demo users (two per role) on startup +│ └── Migrations/ # InitialCreate, InitialSchema, AddRefreshTokensAndAuthAudit +├── Common/ +│ ├── ApiResponse.cs # { success, statusCode, data, error } envelope +│ └── Exceptions/ +│ ├── NotFoundException.cs +│ ├── ConflictException.cs # Status machine, separation of duties, assignment conflicts +│ ├── BadRequestException.cs +│ ├── DomainException.cs +│ └── ValidationException.cs +├── Middleware/ +│ ├── CorrelationIdMiddleware.cs # Per-request correlation IDs +│ └── ExceptionHandlerMiddleware.cs # Consistent error responses +└── Infrastructure/ + └── OpenApi/ + └── SwaggerServiceCollectionExtensions.cs + +tests/ +└── VigilCareRecordsAPI.Tests/ + ├── DraftEntryTests.cs # Draft CRUD, plausibility validation, submit-for-verification completeness + ├── VerificationTests.cs # Verification, rejection, separation of duties, clinical approval routing + ├── Fixtures/ + │ ├── ApiFixture.cs # WebApplicationFactory with PostgreSQL, Redis, MinIO containers + │ └── DatabaseCollection.cs # Shared test collection + └── Helpers/ + ├── AuthHelper.cs # JWT token generation for test users + ├── BatchSeedHelper.cs # Creates seeded batches at various lifecycle stages + └── DbResetHelper.cs # Database cleanup between tests + +scripts/ +├── run-vigilcare-records-verification.sh # Phase 1 — schema, auth, roles, batch CRUD, MinIO, status machine +├── run-vigilcare-records-phase-2-verification.sh # Phase 2 — draft entry, plausibility, submit-for-verification +└── run-vigilcare-records-phase-3-verification.sh # Phase 3 — verification, rejection, separation of duties, work queues + +docs/ +├── plans/ # Phase 1–9 implementation and verification guides +└── vigilcare-records-prd.md # Product requirements and phase roadmap +``` + +--- + +## Batch Status State Machine + +``` + ┌──────────────┐ + │ UPLOADED │ + └──────┬───────┘ + │ assign / first save + ▼ + ┌──────────────┐ + ┌──────────│ IN_ENTRY │◄─────────┐ + │ └──────┬───────┘ │ + │ │ submit │ reject + │ ▼ │ + │ ┌──────────────┐ │ + │ │ PENDING │─────────┘ + │ │ VERIFICATION │ + │ └──────┬───────┘ + │ │ + │ verify fail │ verify pass + │ ─────────┤ + │ │ + │ site config │ site config + │ = false │ = true + │ ┌────────┴────────┐ + │ ▼ ▼ + │ ┌──────────────┐ ┌──────────────────────┐ + │ │ VERIFIED │ │ AWAITING_CLINICAL │ + │ └──────┬───────┘ │ _APPROVAL │ + │ │ └──────────┬─────────────┘ + │ └──────────┬──────────┘ + │ │ approve + │ ▼ + │ ┌──────────────┐ + └────────────►│ APPROVED │ + └──────┬───────┘ + │ promotion (Phase 4) + ▼ + ┌──────────────┐ + │ PROMOTED │ (terminal — live records exist) + └──────────────┘ +``` + +**Allowed transitions:** + +| From | To | +|---|---| +| `UPLOADED` | `IN_ENTRY` | +| `IN_ENTRY` | `PENDING_VERIFICATION` | +| `PENDING_VERIFICATION` | `VERIFIED`, `AWAITING_CLINICAL_APPROVAL`, `REJECTED` | +| `REJECTED` | `IN_ENTRY` | +| `VERIFIED` | `APPROVED` | +| `AWAITING_CLINICAL_APPROVAL` | `APPROVED`, `REJECTED` | +| `APPROVED` | `PROMOTED` | +| `PROMOTED` | *(none — terminal)* | + +Illegal transitions return `409` with a stable error code. A `PROMOTED` batch cannot return to any earlier state. Corrections require a **new** batch referencing `supersedesBatchId`. + +**Site-configurable clinical approval routing:** + +| batchType | Clinical sign-off after verify? | +|---|---| +| `PATIENT_REGISTRATION` | No | +| `ALLERGY_UPDATE` | No | +| `ENCOUNTER_SUMMARY` | Yes | +| `VITALS_SHEET` | Yes | +| `LAB_RESULTS` | Yes | +| `MEDICATION_LIST` | Yes | +| `MIXED` | Yes | + +--- + +## Architecture Decisions + +### Draft Isolation — The Core Safety Invariant + +Draft observations are invisible to VigilCareClinical's alert engine, Elasticsearch ward projection, and scoring consumers. Only batches that have passed approval are promoted to live tables. This is the architectural decision that separates a digitization system from a data entry form. If an entry clerk misreads a potassium of 5.2 mEq/L as 52 mEq/L, the plausibility validator catches it at draft save. If the plausibility range allows it but the value is wrong, the verifier catches it against the scan. If neither catches it, the clinical approver is the last gate for high-stakes batch types. At no point does the incorrect value enter the alerting pipeline until all human gates have cleared. + +### Separation of Duties — Who Cannot Do What + +The person who enters data cannot verify their own entry. This is enforced in the service layer, not only in the UI. `enteredByUserId === currentUserId` blocks verify and approve actions with `409 SEPARATION_OF_DUTIES_VIOLATION`. In a small island clinic with limited staff, the same person may hold intake and entry roles — but **never** entry and verifier on the same batch. + +### Two-Track Workflow (Backfill vs Live Capture) + +Track A (backfill) is the full pipeline for historical charts: scan → entry → verification → clinical approval → promotion. Track B (live capture) is for credentialed clinicians entering vitals at bedside — attestation replaces the dual-human gate. Both tracks create `DigitizationBatch` records with full audit trails, keeping metrics and coverage stats consistent. + +### Redis for Batch Assignment Locking + +Redis serves one purpose in this project: preventing double-assignment of batches to entry clerks. `SET batch:assign:{id} NX EX 3600` acquires an exclusive lock with a 1-hour TTL. Work-queue counters are derived from PostgreSQL queries, not Redis counters. + +### Integrated Database Deployment + +VigilCare Records draft tables and VigilCareClinical live tables share one PostgreSQL instance (separate logical concerns). Promotion runs in a single local transaction — no distributed saga required. Split deployment with HTTP + saga retry is documented as a future deployment option. + +--- + +## Getting Started + +### Prerequisites + +- .NET 8 SDK +- Docker and Docker Compose + +### Start Infrastructure + +```bash +docker compose up -d +``` + +| Service | Host Port | Notes | +|---|---|---| +| PostgreSQL 16 | 5437 | Database: `vigilcare_records`, user: `postgres`, password: `password` | +| Redis 7 | 6383 | No auth | +| Seq | 5346 | UI at `http://localhost:5346` | +| MinIO | 9012 (S3 API), 9013 (console) | login: `minioadmin` / `minioadmin` | + +### Install and Run + +```bash +cd VigilCareRecordsAPI +dotnet restore +dotnet run +``` + +On startup the application: +1. Runs EF Core migrations +2. Seeds twelve demo users (two per role: intake clerk, data entry clerk, verifier, clinical approver, clinician, administrator) + +Swagger UI is available at `http://localhost:5217/swagger` in Development. + +### Run Tests + +```bash +dotnet test +``` + +Integration tests use `WebApplicationFactory` with PostgreSQL, Redis, and MinIO containers. No manual infrastructure setup is required for `dotnet test`. + +| Test class | Phase | Coverage | +|---|---|---| +| `DraftEntryTests` | 2 | Draft CRUD, plausibility validation, submit-for-verification completeness checks | +| `VerificationTests` | 3 | Verification, rejection, separation of duties enforcement, clinical approval routing | + +### Verification Scripts + +With the API running (`dotnet run`) and Docker Compose up: + +```bash +./scripts/run-vigilcare-records-verification.sh # Phase 1 — schema, auth, batch CRUD, MinIO, status machine +./scripts/run-vigilcare-records-phase-2-verification.sh # Phase 2 — draft entry, plausibility, submit +./scripts/run-vigilcare-records-phase-3-verification.sh # Phase 3 — verification, rejection, separation of duties +``` + +--- + +## API Reference + +All endpoints are prefixed `/api/v1`. Responses follow the standard envelope: + +```json +{ "success": true, "statusCode": 200, "data": {}, "error": null } +``` + +Error response: + +```json +{ + "success": false, + "statusCode": 409, + "data": null, + "error": { + "message": "Verifier cannot approve a batch they entered.", + "code": "SEPARATION_OF_DUTIES_VIOLATION" + } +} +``` + +### Authentication + +| Method | Path | Auth | Description | +|---|---|---|---| +| POST | `/auth/login` | Anonymous | Authenticate with username/password; returns access + refresh tokens | +| POST | `/auth/refresh` | Anonymous | Exchange a valid refresh token for new access + refresh token pair | +| POST | `/auth/logout` | Anonymous | Revoke the refresh token server-side | +| GET | `/auth/me` | JWT | Returns the authenticated user's profile | + +**POST `/auth/login` body:** + +| Field | Type | Required | Description | +|---|---|---|---| +| `username` | string | yes | Username | +| `password` | string | yes | Password | + +**Login response:** + +| Field | Type | Description | +|---|---|---| +| `accessToken` | string | JWT bearer token (15 min) | +| `refreshToken` | string | Opaque refresh token (7 days) | +| `expiresAt` | DateTimeOffset | Access token expiration | +| `userId` | Guid | User ID | +| `username` | string | Username | +| `displayName` | string | Display name | +| `role` | string | `INTAKE_CLERK`, `DATA_ENTRY_CLERK`, `VERIFIER`, `CLINICAL_APPROVER`, `CLINICIAN`, `ADMINISTRATOR` | + +**Seeded demo users:** + +| Username | Password | Role | +|---|---|---| +| `intake1` | `password` | Intake Clerk | +| `intake2` | `password` | Intake Clerk | +| `entry1` | `password` | Data Entry Clerk | +| `entry2` | `password` | Data Entry Clerk | +| `verifier1` | `password` | Verifier | +| `verifier2` | `password` | Verifier | +| `approver1` | `password` | Clinical Approver | +| `approver2` | `password` | Clinical Approver | +| `clinician1` | `password` | Clinician | +| `clinician2` | `password` | Clinician | +| `admin1` | `password` | Administrator | +| `admin2` | `password` | Administrator | + +### Digitization Batches + +| Method | Path | Description | +|---|---|---| +| POST | `/digitization-batches` | Upload a scanned document and create a batch (multipart/form-data) | +| GET | `/digitization-batches` | List batches; optional `status`, `batchType`, `assignedTo`, `track` filters; paginated | +| GET | `/digitization-batches/{id}` | Batch detail with presigned document URL (15-minute expiry) | +| PATCH | `/digitization-batches/{id}/assign` | Assign batch to an entry clerk (Redis lock) | + +**POST body (multipart/form-data):** + +| Field | Type | Required | Description | +|---|---|---|---| +| `file` | binary | yes | PDF, JPEG, or PNG (max 25 MB) | +| `batchType` | string | yes | `PATIENT_REGISTRATION`, `ENCOUNTER_SUMMARY`, `VITALS_SHEET`, `LAB_RESULTS`, `MEDICATION_LIST`, `ALLERGY_UPDATE`, `MIXED` | +| `track` | string | no | `BACKFILL` (default) or `LIVE_CAPTURE` | +| `patientId` | Guid | no | Link to existing patient (enables duplicate detection) | + +**Status codes:** + +| Code | Meaning | +|---|---| +| 201 | Batch created | +| 400 | Empty file or invalid MIME type | +| 409 | Duplicate document (same SHA-256 for same patient within 24 hours) | + +**PATCH `/digitization-batches/{id}/assign` body:** + +| Field | Type | Required | Description | +|---|---|---|---| +| `entryClerkUserId` | Guid | yes | User ID of the entry clerk to assign | + +### Draft Data Entry + +| Method | Path | Description | +|---|---|---| +| GET | `/digitization-batches/{id}/draft` | Full draft payload: patient, encounter, observations | +| PUT | `/digitization-batches/{id}/draft/patient` | Upsert draft patient demographics | +| PUT | `/digitization-batches/{id}/draft/encounter` | Upsert draft encounter fields | +| POST | `/digitization-batches/{id}/draft/observations` | Add an observation row | +| PUT | `/digitization-batches/{id}/draft/observations/{obsId}` | Edit an observation row | +| DELETE | `/digitization-batches/{id}/draft/observations/{obsId}` | Remove an observation from draft | +| POST | `/digitization-batches/{id}/submit-for-verification` | Validate completeness and transition to `PENDING_VERIFICATION` | + +**Observation request body:** + +| Field | Type | Required | Description | +|---|---|---|---| +| `observationCode` | string | yes | e.g. `HEART_RATE`, `SYSTOLIC_BP`, `TEMP_C`, `SPO2` | +| `value` | decimal | yes | Numeric measurement | +| `unit` | string | yes | Unit of measure | +| `recordedAt` | DateTimeOffset | yes | When the measurement was taken (from the chart) | +| `note` | string | no | Optional note about the reading | + +**Required fields before submit (by batch type):** + +| batchType | Required draft content | +|---|---| +| `PATIENT_REGISTRATION` | Full name, date of birth, sex | +| `ENCOUNTER_SUMMARY` | Linked patient; encounter with admission date, department, admission reason | +| `VITALS_SHEET` | Linked patient, encounter context, at least one observation with `recordedAt` | +| `LAB_RESULTS` | Linked patient, encounter, at least one lab observation code, `recordedAt` | +| `MEDICATION_LIST` | Linked patient; `medicationsJson` with at least one entry or explicit `noActiveMedications: true` | +| `ALLERGY_UPDATE` | Linked patient, allergies list (may be empty with explicit `noKnownAllergies: true`) | +| `MIXED` | Linked patient, encounter context, and at least one of: observation with `recordedAt`, or complete encounter summary | + +### Verification and Rejection + +| Method | Path | Auth | Description | +|---|---|---|---| +| POST | `/digitization-batches/{id}/verify` | Verifier, Clinical Approver, Administrator | Verify a batch with field-level checks | +| POST | `/digitization-batches/{id}/reject` | Verifier, Clinical Approver, Administrator | Reject a batch with mandatory reason | + +**POST `/verify` body:** + +| Field | Type | Required | Description | +|---|---|---|---| +| `fieldChecks` | array | yes | Field-level review results | +| `passed` | bool | yes | Overall verification pass/fail | + +**Field check object:** + +| Field | Type | Required | Description | +|---|---|---|---| +| `fieldName` | string | yes | e.g. `observations[0].value`, `patient.fullName` | +| `status` | string | yes | `ok`, `warning`, or `error` | +| `note` | string | no | Optional reviewer note | + +**POST `/reject` body:** + +| Field | Type | Required | Description | +|---|---|---|---| +| `reason` | string | yes | Rejection reason (minimum 10 characters) | + +### Work Queues + +| Method | Path | Auth | Description | +|---|---|---|---| +| GET | `/work-queue/verification` | Verifier, Clinical Approver, Administrator | Batches in `PENDING_VERIFICATION`, sorted by submission time | +| GET | `/work-queue/entry` | Data Entry Clerk, Administrator | Batches awaiting or in entry (`UPLOADED`, `IN_ENTRY`, `REJECTED`) | +| GET | `/work-queue/clinical-approval` | Clinical Approver, Administrator | Batches in `AWAITING_CLINICAL_APPROVAL` | + +All work queue endpoints support pagination via `?page=1&pageSize=20`. + +--- + +## Data Models + +### DigitizationBatch + +``` +id Guid PK +status string UPLOADED | IN_ENTRY | PENDING_VERIFICATION | REJECTED | VERIFIED | AWAITING_CLINICAL_APPROVAL | APPROVED | PROMOTED +batchType string PATIENT_REGISTRATION | ENCOUNTER_SUMMARY | VITALS_SHEET | LAB_RESULTS | MEDICATION_LIST | ALLERGY_UPDATE | MIXED +track string BACKFILL | LIVE_CAPTURE +patientId Guid? nullable until linked +encounterDraftId Guid? encounter context for vitals/labs +documentRef string MinIO object key for the scanned document +documentSha256 string content hash for integrity verification +enableRetroactiveAlerts bool default false +enteredByUserId Guid? set on first draft save +verifiedByUserId Guid? set on verification pass +approvedByUserId Guid? set on final approval +rejectionReason string? required when status → REJECTED +promotedAt DateTimeOffset? timestamp when live records created +promotionEncounterId Guid? VigilCareClinical encounter ID after promotion +supersedesBatchId Guid? links a correction batch to the batch it replaces +clinicianAttestation bool true for Track B batches attested at bedside +createdAt DateTimeOffset +updatedAt DateTimeOffset +``` + +### DraftPatient + +``` +id Guid PK +batchId Guid FK → DigitizationBatch +fullName string? required for patient_registration on submit +dateOfBirth DateOnly? required for patient_registration on submit +sex string? required for patient_registration on submit +bloodType string? A+ | A- | B+ | B- | AB+ | AB- | O+ | O- +emergencyContact string? +allergiesJson string? JSON array +noKnownAllergies bool +medicationsJson string? JSON array (for medication_list batches) +noActiveMedications bool +createdAt DateTimeOffset +updatedAt DateTimeOffset +``` + +### DraftEncounter + +``` +id Guid PK +batchId Guid FK → DigitizationBatch +admissionDate DateTimeOffset? +department string? ICU, ED, MedSurg, etc. +roomBed string? ward/bed assignment +admissionReason string? +dischargeDiagnosis string? +status string? encounter status from chart +createdAt DateTimeOffset +updatedAt DateTimeOffset +``` + +### DraftObservation + +``` +id Guid PK +batchId Guid FK → DigitizationBatch +observationCode string required (e.g. HEART_RATE, TEMP_C) +value decimal required — validated against plausibility ranges +unit string required (e.g. bpm, °C, mmHg) +recordedAt DateTimeOffset required — from the paper chart +note string? optional transcription note +createdAt DateTimeOffset +``` + +### ScannedDocument + +``` +id Guid PK +batchId Guid FK → DigitizationBatch +objectKey string MinIO object key +sha256 string content hash +contentType string application/pdf | image/jpeg | image/png +fileSizeBytes long +uploadedAt DateTimeOffset +``` + +### DigitizationEvent + +``` +id Guid PK +batchId Guid FK → DigitizationBatch +eventType string uploaded | entry_started | submitted_for_verification | rejected | verified | awaiting_clinical_approval | approved | promoted | ... +actorUserId Guid FK → User +occurredAt DateTimeOffset +metadataJson string? optional JSON (field checks, rejection reason, assigned-to) +``` + +### User + +``` +id Guid PK +username string required, unique +passwordHash string required (BCrypt) +fullName string required +role string INTAKE_CLERK | DATA_ENTRY_CLERK | VERIFIER | CLINICAL_APPROVER | CLINICIAN | ADMINISTRATOR +isActive bool default true +createdAt DateTimeOffset +lastLoginAt DateTimeOffset? +``` + +### RefreshToken + +``` +id Guid PK +token string required, unique — opaque base64 token +userId Guid FK → User +expiresAt DateTimeOffset required +createdAt DateTimeOffset +revokedAt DateTimeOffset? — set on refresh rotation or explicit logout +``` + +### AuthAuditEvent + +``` +id Guid PK +eventType string LOGIN | LOGOUT | TOKEN_REFRESHED | LOGIN_FAILED +userId Guid? +username string? +ipAddress string? +occurredAt DateTimeOffset +``` + +--- + +## Pagination + +List endpoints use offset pagination: + +| Param | Default | Description | +|---|---|---| +| `page` | 1 | Page number (1-based) | +| `pageSize` | 20 | Items per page | + +Response shape: + +```json +{ + "items": [], + "page": 1, + "pageSize": 20, + "totalCount": 42, + "totalPages": 3 +} +``` + +--- + +## 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. + +| Phase | Feature | Status | +|---|---|---| +| 1 | Schema, EF Core migrations, JWT authentication with refresh tokens, six user roles, batch CRUD, MinIO upload with SHA-256 and presigned URLs, batch status machine with transition matrix, duplicate document detection, Redis batch assignment locks, twelve seeded demo users, auth audit events | Done | +| 2 | Draft data entry API (patient demographics, encounter context, observations), plausibility validation on observation values, batch-type completeness validation on submit-for-verification, automatic `UPLOADED → IN_ENTRY` transition on first save, assignment guard (`BATCH_NOT_ASSIGNED`), `DraftEntryTests` integration tests | Done | +| 3 | Verification with field-level checks (`ok`, `warning`, `error` per field), rejection with mandatory reason (min 10 chars), separation of duties enforcement (`SEPARATION_OF_DUTIES_VIOLATION`), site-configurable clinical approval routing per batch type, work queue endpoints (verification, entry, clinical approval) with role-based access, `VerificationTests` integration tests | Done | +| 4 | Approval and promotion to VigilCareClinical live tables, outbox integration, idempotent promotion, retroactive alert policy | Planned | +| 5 | Corrections and supersession — new batch replaces old, superseded observations soft-flagged | Planned | +| 6 | Track B live capture with clinician attestation, synchronous alert evaluation | Planned | +| 7 | Digitization workstation UI (Vue 3 side-by-side scan viewer + entry form) | Planned | +| 8 | Prometheus metrics, supervisor dashboard, promotion retry job | Planned | +| 9 | Seed data, E2E verification script, clinical scenario documentation | Planned | diff --git a/VigilCareRecordsAPI.Tests/DraftEntryTests.cs b/VigilCareRecordsAPI.Tests/DraftEntryTests.cs index ddcc98e..08a0052 100644 --- a/VigilCareRecordsAPI.Tests/DraftEntryTests.cs +++ b/VigilCareRecordsAPI.Tests/DraftEntryTests.cs @@ -5,7 +5,8 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -public class DraftEntryTests : IClassFixture, IAsyncLifetime +[Collection("Database")] +public class DraftEntryTests : IAsyncLifetime { private readonly ApiFixture _fixture; private HttpClient _client = null!; diff --git a/VigilCareRecordsAPI.Tests/Fixtures/DatabaseCollection.cs b/VigilCareRecordsAPI.Tests/Fixtures/DatabaseCollection.cs new file mode 100644 index 0000000..1d5ce96 --- /dev/null +++ b/VigilCareRecordsAPI.Tests/Fixtures/DatabaseCollection.cs @@ -0,0 +1,2 @@ +[CollectionDefinition("Database")] +public class DatabaseCollection : ICollectionFixture; diff --git a/VigilCareRecordsAPI.Tests/Helpers/BatchSeedHelper.cs b/VigilCareRecordsAPI.Tests/Helpers/BatchSeedHelper.cs new file mode 100644 index 0000000..497f894 --- /dev/null +++ b/VigilCareRecordsAPI.Tests/Helpers/BatchSeedHelper.cs @@ -0,0 +1,103 @@ +using Microsoft.EntityFrameworkCore; + + +/// +/// Seeds batch state for verification integration tests. Uses usernames from +/// (entry1, entry2, verifier1, etc.) — never hard-coded user IDs. +/// +public static class BatchSeedHelper +{ + public static async Task UserIdAsync(AppDbContext db, string username) => + (await db.Users.AsNoTracking().FirstAsync(u => u.Username == username)).Id; + + /// + /// Creates a batch in PendingVerification status with EnteredByUserId set, + /// simulating a batch that has been through data entry and submission. + /// + public static async Task SeedBatchInPendingVerificationAsync( + AppDbContext db, + Guid enteredByUserId, + BatchType batchType = BatchType.VitalsSheet) + { + var batchId = Guid.NewGuid(); + var batch = new DigitizationBatch + { + Id = batchId, + Status = BatchStatus.PendingVerification, + BatchType = batchType, + Track = BatchTrack.Backfill, + DocumentRef = $"scans/2026/01/{batchId}/abc123.pdf", + DocumentSha256 = Guid.NewGuid().ToString("N"), // Fake SHA-256 for testing + EnteredByUserId = enteredByUserId, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-30), + UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-5) + }; + + db.DigitizationBatches.Add(batch); + + // Add the submission event + db.DigitizationEvents.Add(new DigitizationEvent + { + Id = Guid.NewGuid(), + BatchId = batchId, + EventType = DigitizationEventType.SubmittedForVerification, + ActorUserId = enteredByUserId, + OccurredAt = DateTimeOffset.UtcNow.AddMinutes(-5) + }); + + // Add a ScannedDocument so FK constraints are satisfied + db.ScannedDocuments.Add(new ScannedDocument + { + Id = Guid.NewGuid(), + BatchId = batchId, + ObjectKey = batch.DocumentRef, + Sha256 = batch.DocumentSha256, + ContentType = "application/pdf", + FileSizeBytes = 1024, + UploadedAt = DateTimeOffset.UtcNow.AddMinutes(-30) + }); + + await db.SaveChangesAsync(); + return batch; + } + + /// + /// Creates a batch in Rejected status for re-entry testing. + /// + public static async Task SeedBatchInRejectedAsync( + AppDbContext db, + Guid enteredByUserId, + string rejectionReason = "Vital signs appear inconsistent with chart notes") + { + var batchId = Guid.NewGuid(); + var batch = new DigitizationBatch + { + Id = batchId, + Status = BatchStatus.Rejected, + BatchType = BatchType.VitalsSheet, + Track = BatchTrack.Backfill, + DocumentRef = $"scans/2026/01/{batchId}/abc123.pdf", + DocumentSha256 = Guid.NewGuid().ToString("N"), + EnteredByUserId = enteredByUserId, + RejectionReason = rejectionReason, + CreatedAt = DateTimeOffset.UtcNow.AddHours(-2), + UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-10) + }; + + db.DigitizationBatches.Add(batch); + + db.ScannedDocuments.Add(new ScannedDocument + { + Id = Guid.NewGuid(), + BatchId = batchId, + ObjectKey = batch.DocumentRef, + Sha256 = batch.DocumentSha256, + ContentType = "application/pdf", + FileSizeBytes = 1024, + UploadedAt = DateTimeOffset.UtcNow.AddHours(-2) + }); + + await db.SaveChangesAsync(); + return batch; + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI.Tests/VerificationTests.cs b/VigilCareRecordsAPI.Tests/VerificationTests.cs new file mode 100644 index 0000000..d89b381 --- /dev/null +++ b/VigilCareRecordsAPI.Tests/VerificationTests.cs @@ -0,0 +1,569 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + + +/// +/// Integration tests for Phase 3: verification, rejection, separation of duties, +/// and work queues. Uses ApiFixture with real PostgreSQL and Redis from Phase 2. +/// +[Collection("Database")] +public class VerificationTests : IAsyncLifetime +{ + private readonly ApiFixture _fixture; + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public VerificationTests(ApiFixture fixture) => _fixture = fixture; + + public async Task InitializeAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + await DataSeeder.SeedAsync(db); + } + + public Task DisposeAsync() => Task.CompletedTask; + + // --------------------------------------------------------------- + // Separation of duties + // --------------------------------------------------------------- + + /// + /// Test: entry1 entered the batch. verifier1 attempts to verify a batch they + /// also entered (EnteredByUserId = verifier1). Returns 409 SEPARATION_OF_DUTIES_VIOLATION. + /// + [Fact] + public async Task Verify_SameUserAsEntryClerk_Returns409SeparationOfDuties() + { + // Arrange: batch entered by verifier1 — same user will attempt verification + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var verifierId = await BatchSeedHelper.UserIdAsync(db, "verifier1"); + var batch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( + db, verifierId); + + // Act: verifier1 tries to verify their own batch + var client = await AuthHelper.LoginAsync(_fixture, "verifier1"); + + var request = new VerifyBatchRequest( + new List + { + new("patient.fullName", "ok", null), + new("observation.heartRate", "ok", null) + }, + Passed: true + ); + + var response = await client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batch.Id}/verify", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("SEPARATION_OF_DUTIES_VIOLATION"); + } + + /// + /// Test: Entry clerk A submits a batch. Verifier B (a different person) verifies it. + /// The system accepts the verification because the verifier is not the same user + /// who entered the data. + /// + /// Uses a VitalsSheet batch type which requires clinical approval per site config, + /// so the expected target status is AwaitingClinicalApproval. + /// + [Fact] + public async Task Verify_DifferentUserFromEntryClerk_Succeeds() + { + // Arrange + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var batch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( + db, (await BatchSeedHelper.UserIdAsync(db, "entry1")), BatchType.VitalsSheet); + + // Act: VerifierB verifies the batch (different user from EntryClerkA) + var client = await AuthHelper.LoginAsync(_fixture, "verifier2"); + + var request = new VerifyBatchRequest( + new List + { + new("patient.fullName", "ok", null), + new("observation.heartRate", "ok", "Within normal range"), + new("observation.bloodPressureSystolic", "warning", "Slightly elevated but plausible") + }, + Passed: true + ); + + var response = await client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batch.Id}/verify", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + // Reload batch from database to verify status transition + var updatedBatch = await db.DigitizationBatches + .AsNoTracking() + .FirstAsync(b => b.Id == batch.Id); + + // VitalsSheet requires clinical approval per site config + updatedBatch.Status.Should().Be(BatchStatus.AwaitingClinicalApproval); + updatedBatch.VerifiedByUserId.Should().Be((await BatchSeedHelper.UserIdAsync(db, "verifier2"))); + + // Verify event was written + var events = await db.DigitizationEvents + .Where(e => e.BatchId == batch.Id && e.EventType == DigitizationEventType.VerifiedPendingClinical) + .ToListAsync(); + events.Should().HaveCount(1); + events[0].ActorUserId.Should().Be((await BatchSeedHelper.UserIdAsync(db, "verifier2"))); + events[0].MetadataJson.Should().Contain("fieldChecks"); + } + + /// + /// Test: Verify a PatientRegistration batch (clinical approval NOT required). + /// The batch should transition directly to Verified, not AwaitingClinicalApproval. + /// + [Fact] + public async Task Verify_NoClinicalApprovalRequired_TransitionsToVerified() + { + // Arrange: PatientRegistration does NOT require clinical approval + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var batch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( + db, (await BatchSeedHelper.UserIdAsync(db, "entry1")), BatchType.PatientRegistration); + + // Act + var client = await AuthHelper.LoginAsync(_fixture, "verifier2"); + + var request = new VerifyBatchRequest( + new List + { + new("patient.fullName", "ok", null), + new("patient.dateOfBirth", "ok", null) + }, + Passed: true + ); + + var response = await client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batch.Id}/verify", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var updatedBatch = await db.DigitizationBatches + .AsNoTracking() + .FirstAsync(b => b.Id == batch.Id); + + updatedBatch.Status.Should().Be(BatchStatus.Verified); + + var events = await db.DigitizationEvents + .Where(e => e.BatchId == batch.Id && e.EventType == DigitizationEventType.Verified) + .ToListAsync(); + events.Should().HaveCount(1); + } + + // --------------------------------------------------------------- + // Rejection + // --------------------------------------------------------------- + + /// + /// Test: Verifier B rejects a batch with a reason. The batch transitions to + /// Rejected, the rejection reason is stored, and the batch appears in the + /// entry work queue for re-entry. + /// + [Fact] + public async Task Reject_WithValidReason_TransitionsToRejectedAndAppearsInEntryQueue() + { + // Arrange + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var batch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( + db, (await BatchSeedHelper.UserIdAsync(db, "entry1"))); + + // Act: VerifierB rejects + var client = await AuthHelper.LoginAsync(_fixture, "verifier2"); + + var request = new RejectBatchRequest( + "Patient name does not match the scanned registration form. " + + "Please re-enter the patient demographics from the chart."); + + var response = await client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batch.Id}/reject", request); + + // Assert — rejection accepted + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var updatedBatch = await db.DigitizationBatches + .AsNoTracking() + .FirstAsync(b => b.Id == batch.Id); + updatedBatch.Status.Should().Be(BatchStatus.Rejected); + updatedBatch.RejectionReason.Should().Contain("Patient name does not match"); + + // Assert — event written + var events = await db.DigitizationEvents + .Where(e => e.BatchId == batch.Id && e.EventType == DigitizationEventType.Rejected) + .ToListAsync(); + events.Should().HaveCount(1); + events[0].MetadataJson.Should().Contain("PENDING_VERIFICATION"); + + // Assert — batch appears in entry work queue + var entryClient = await AuthHelper.LoginAsync(_fixture, "entry1"); + + var queueResponse = await entryClient.GetAsync("/api/v1/work-queue/entry"); + queueResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var queueBody = await queueResponse.Content.ReadAsStringAsync(); + queueBody.Should().Contain(batch.Id.ToString()); + } + + /// + /// Test: Rejection without a reason returns 422. + /// + [Fact] + public async Task Reject_WithoutReason_Returns422() + { + // Arrange + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var batch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( + db, (await BatchSeedHelper.UserIdAsync(db, "entry1"))); + + // Act + var client = await AuthHelper.LoginAsync(_fixture, "verifier2"); + + var request = new RejectBatchRequest(""); + + var response = await client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batch.Id}/reject", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("REJECTION_REASON_REQUIRED"); + } + + /// + /// Test: Rejection with a reason shorter than 10 characters returns 422. + /// + [Fact] + public async Task Reject_WithShortReason_Returns422() + { + // Arrange + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var batch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( + db, (await BatchSeedHelper.UserIdAsync(db, "entry1"))); + + // Act + var client = await AuthHelper.LoginAsync(_fixture, "verifier2"); + + var request = new RejectBatchRequest("Bad data"); + + var response = await client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batch.Id}/reject", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("REJECTION_REASON_TOO_SHORT"); + } + + // --------------------------------------------------------------- + // Rejection loop: reject → re-enter → re-submit → verify + // --------------------------------------------------------------- + + /// + /// Test: Full rejection loop. + /// 1. Batch is in PendingVerification (entered by EntryClerkA). + /// 2. VerifierB rejects it with a reason. + /// 3. Batch status → Rejected, appears in entry queue. + /// 4. EntryClerkA or EntryClerkB picks it up (status → InEntry, simulated). + /// 5. Re-submits (status → PendingVerification, simulated). + /// 6. VerifierB verifies it (should succeed this time). + /// + /// Steps 4-5 are simulated by directly updating the database since the + /// re-entry and re-submission endpoints are Phase 2 code. + /// + [Fact] + public async Task RejectionLoop_RejectThenReenterThenVerify_Succeeds() + { + // Arrange: batch in PendingVerification entered by EntryClerkA + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var batch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( + db, (await BatchSeedHelper.UserIdAsync(db, "entry1")), BatchType.PatientRegistration); + + var verifierClient = await AuthHelper.LoginAsync(_fixture, "verifier2"); + + // Step 1: VerifierB rejects the batch + var rejectRequest = new RejectBatchRequest( + "Date of birth is clearly wrong — year 1899 is not plausible for a current patient."); + + var rejectResponse = await verifierClient.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batch.Id}/reject", rejectRequest); + rejectResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + // Verify it's now Rejected + var rejectedBatch = await db.DigitizationBatches + .AsNoTracking() + .FirstAsync(b => b.Id == batch.Id); + rejectedBatch.Status.Should().Be(BatchStatus.Rejected); + + // Step 2: Verify it appears in the entry queue + var entryClient = await AuthHelper.LoginAsync(_fixture, "entry2"); + + var entryQueueResponse = await entryClient.GetAsync("/api/v1/work-queue/entry"); + var entryQueueBody = await entryQueueResponse.Content.ReadAsStringAsync(); + entryQueueBody.Should().Contain(batch.Id.ToString()); + + // Step 3: Simulate re-entry by EntryClerkB (direct DB update) + // In production, this would go through the Phase 2 draft entry endpoints + var trackedBatch = await db.DigitizationBatches.FindAsync(batch.Id); + trackedBatch!.Status = BatchStatus.InEntry; + trackedBatch.EnteredByUserId = (await BatchSeedHelper.UserIdAsync(db, "entry2")); // Different clerk re-enters + trackedBatch.RejectionReason = null; + trackedBatch.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + + // Step 4: Simulate re-submission (status → PendingVerification) + trackedBatch.Status = BatchStatus.PendingVerification; + trackedBatch.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + + // Step 5: VerifierB verifies the corrected batch — should succeed + // Note: EntryClerkB did the re-entry, so VerifierB (different user) can verify + var verifyRequest = new VerifyBatchRequest( + new List + { + new("patient.fullName", "ok", null), + new("patient.dateOfBirth", "ok", "Corrected to 1989") + }, + Passed: true + ); + + var verifyResponse = await verifierClient.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batch.Id}/verify", verifyRequest); + verifyResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + // Final assertion: batch is now Verified (PatientRegistration doesn't need clinical) + var finalBatch = await db.DigitizationBatches + .AsNoTracking() + .FirstAsync(b => b.Id == batch.Id); + finalBatch.Status.Should().Be(BatchStatus.Verified); + finalBatch.VerifiedByUserId.Should().Be((await BatchSeedHelper.UserIdAsync(db, "verifier2"))); + + // Verify the full event trail + var allEvents = await db.DigitizationEvents + .Where(e => e.BatchId == batch.Id) + .OrderBy(e => e.OccurredAt) + .ToListAsync(); + + // submitted_for_verification (seed), rejected, verified + allEvents.Should().HaveCountGreaterThanOrEqualTo(3); + allEvents.Last().EventType.Should().Be(DigitizationEventType.Verified); + } + + // --------------------------------------------------------------- + // Work queue tests + // --------------------------------------------------------------- + + /// + /// Test: Verification queue returns only PendingVerification batches, + /// sorted by UpdatedAt ASC (oldest first). + /// + [Fact] + public async Task VerificationQueue_ReturnsPendingVerificationBatches_SortedByAge() + { + // Arrange: seed two batches at different times + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var olderBatch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( + db, (await BatchSeedHelper.UserIdAsync(db, "entry1"))); + + // Make the second batch newer by updating its timestamp + await Task.Delay(100); // Ensure different timestamps + + var newerBatch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( + db, (await BatchSeedHelper.UserIdAsync(db, "entry2"))); + + // Also seed a rejected batch — should NOT appear in verification queue + await BatchSeedHelper.SeedBatchInRejectedAsync(db, (await BatchSeedHelper.UserIdAsync(db, "entry1"))); + + // Act + var client = await AuthHelper.LoginAsync(_fixture, "verifier1"); + + var response = await client.GetAsync("/api/v1/work-queue/verification"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + var json = JsonDocument.Parse(body); + var items = json.RootElement + .GetProperty("data") + .GetProperty("items"); + + items.GetArrayLength().Should().Be(2); // Only PendingVerification batches + + // First item should be the older batch (FIFO) + var firstBatchId = items[0].GetProperty("batchId").GetString(); + firstBatchId.Should().Be(olderBatch.Id.ToString()); + } + + /// + /// Test: Entry queue returns Uploaded, InEntry, and Rejected batches. + /// + [Fact] + public async Task EntryQueue_ReturnsUploadedInEntryAndRejectedBatches() + { + // Arrange + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Seed a rejected batch (should appear in entry queue) + var rejectedBatch = await BatchSeedHelper.SeedBatchInRejectedAsync( + db, (await BatchSeedHelper.UserIdAsync(db, "entry1"))); + + // Seed a PendingVerification batch (should NOT appear in entry queue) + await BatchSeedHelper.SeedBatchInPendingVerificationAsync( + db, (await BatchSeedHelper.UserIdAsync(db, "entry2"))); + + // Seed an Uploaded batch directly + var uploadedBatchId = Guid.NewGuid(); + db.DigitizationBatches.Add(new DigitizationBatch + { + Id = uploadedBatchId, + Status = BatchStatus.Uploaded, + BatchType = BatchType.EncounterSummary, + Track = BatchTrack.Backfill, + DocumentRef = $"scans/2026/01/{uploadedBatchId}/test.pdf", + DocumentSha256 = Guid.NewGuid().ToString("N"), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + db.ScannedDocuments.Add(new ScannedDocument + { + Id = Guid.NewGuid(), + BatchId = uploadedBatchId, + ObjectKey = $"scans/2026/01/{uploadedBatchId}/test.pdf", + Sha256 = Guid.NewGuid().ToString("N"), + ContentType = "application/pdf", + FileSizeBytes = 512, + UploadedAt = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + + // Act + var client = await AuthHelper.LoginAsync(_fixture, "entry1"); + + var response = await client.GetAsync("/api/v1/work-queue/entry"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + var json = JsonDocument.Parse(body); + var items = json.RootElement + .GetProperty("data") + .GetProperty("items"); + + // Should contain the rejected batch and uploaded batch, but NOT the PendingVerification batch + items.GetArrayLength().Should().Be(2); + + var batchIds = Enumerable.Range(0, items.GetArrayLength()) + .Select(i => items[i].GetProperty("batchId").GetString()) + .ToList(); + + batchIds.Should().Contain(rejectedBatch.Id.ToString()); + batchIds.Should().Contain(uploadedBatchId.ToString()); + } + + // --------------------------------------------------------------- + // Status guard tests + // --------------------------------------------------------------- + + /// + /// Test: Attempting to verify a batch that is not in PendingVerification + /// status returns 409 ILLEGAL_STATUS_TRANSITION. + /// + [Fact] + public async Task Verify_BatchNotInPendingVerification_Returns409() + { + // Arrange: seed a batch in Rejected status + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var batch = await BatchSeedHelper.SeedBatchInRejectedAsync( + db, (await BatchSeedHelper.UserIdAsync(db, "entry1"))); + + // Act + var client = await AuthHelper.LoginAsync(_fixture, "verifier2"); + + var request = new VerifyBatchRequest( + new List { new("patient.fullName", "ok", null) }, + Passed: true + ); + + var response = await client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batch.Id}/verify", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("ILLEGAL_STATUS_TRANSITION"); + } + + /// + /// Test: Verification with passed=false transitions to Rejected with + /// field-level error details as the rejection reason. + /// + [Fact] + public async Task Verify_PassedFalse_TransitionsToRejectedWithFieldErrors() + { + // Arrange + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var batch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( + db, (await BatchSeedHelper.UserIdAsync(db, "entry1"))); + + // Act + var client = await AuthHelper.LoginAsync(_fixture, "verifier2"); + + var request = new VerifyBatchRequest( + new List + { + new("patient.fullName", "ok", null), + new("observation.heartRate", "error", "Value 350 is not physiologically possible"), + new("observation.temperature", "error", "Missing unit — cannot verify") + }, + Passed: false + ); + + var response = await client.PostAsJsonAsync( + $"/api/v1/digitization-batches/{batch.Id}/verify", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var updatedBatch = await db.DigitizationBatches + .AsNoTracking() + .FirstAsync(b => b.Id == batch.Id); + + updatedBatch.Status.Should().Be(BatchStatus.Rejected); + updatedBatch.RejectionReason.Should().Contain("350 is not physiologically possible"); + updatedBatch.RejectionReason.Should().Contain("Missing unit"); + + // Verify event metadata contains field checks + var evt = await db.DigitizationEvents + .Where(e => e.BatchId == batch.Id && e.EventType == DigitizationEventType.VerificationFailed) + .FirstAsync(); + evt.MetadataJson.Should().Contain("fieldChecks"); + evt.MetadataJson.Should().Contain("errorCount"); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Configurations/SiteConfigOptions.cs b/VigilCareRecordsAPI/Configurations/SiteConfigOptions.cs new file mode 100644 index 0000000..a8bb16b --- /dev/null +++ b/VigilCareRecordsAPI/Configurations/SiteConfigOptions.cs @@ -0,0 +1,26 @@ +/// +/// Site-level configuration controlling workflow routing per batch type. +/// ClinicalApprovalRequired maps BatchType DB strings to whether a clinical +/// approver must sign off after verification before the batch can be approved. +/// +public class SiteConfigOptions +{ + public const string Section = "SiteConfig"; + + /// + /// Maps batch type DB string (e.g. "LAB_RESULTS") to whether clinical + /// approval is required after verification. If a batch type is not listed, + /// clinical approval is NOT required (defaults to false). + /// + public Dictionary ClinicalApprovalRequired { get; set; } = new(); + + /// + /// Returns true if the given batch type requires clinical approval after + /// verification. Batch types not present in the dictionary default to false. + /// + public bool RequiresClinicalApproval(BatchType batchType) + { + var key = batchType.ToDbString(); + return ClinicalApprovalRequired.TryGetValue(key, out var required) && required; + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Controllers/VerificationController.cs b/VigilCareRecordsAPI/Controllers/VerificationController.cs new file mode 100644 index 0000000..4243c42 --- /dev/null +++ b/VigilCareRecordsAPI/Controllers/VerificationController.cs @@ -0,0 +1,70 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + + +/// +/// Batch verification and rejection endpoints. +/// Enforces separation of duties: the user who entered the data cannot verify +/// or reject the same batch. All transitions write DigitizationEvents. +/// +[ApiController] +[Route("api/v1/digitization-batches")] +[Produces("application/json")] +[Authorize] +public class VerificationController : ControllerBase +{ + private readonly IVerificationService _verification; + + public VerificationController(IVerificationService verification) + { + _verification = verification; + } + + /// + /// Verifies a batch that is in PendingVerification status. + /// Requires field-level checks. If passed is true, transitions to Verified + /// or AwaitingClinicalApproval based on site configuration for the batch type. + /// If passed is false, transitions to Rejected with field check errors as the reason. + /// Returns 409 SEPARATION_OF_DUTIES_VIOLATION if the verifier is the same user + /// who entered the data. + /// + /// The batch ID to verify. + /// Verification request with field checks and pass/fail. + [HttpPost("{id:guid}/verify")] + [Authorize(Roles = "VERIFIER,CLINICAL_APPROVER,ADMINISTRATOR")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task Verify(Guid id, [FromBody] VerifyBatchRequest request) + { + var verifierUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + var batch = await _verification.VerifyAsync(id, request, verifierUserId); + + return Ok(ApiResponse.Ok(BatchDetailResponse.FromEntity(batch))); + } + + /// + /// Rejects a batch that is in PendingVerification or AwaitingClinicalApproval status. + /// Requires a rejection reason (minimum 10 characters). The batch returns to the + /// entry work queue for re-entry by a data entry clerk. + /// Returns 409 SEPARATION_OF_DUTIES_VIOLATION if the rejector is the same user + /// who entered the data (for PendingVerification status only). + /// + /// The batch ID to reject. + /// Rejection request with required reason. + [HttpPost("{id:guid}/reject")] + [Authorize(Roles = "VERIFIER,CLINICAL_APPROVER,ADMINISTRATOR")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task Reject(Guid id, [FromBody] RejectBatchRequest request) + { + var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + var batch = await _verification.RejectAsync(id, request, actorUserId); + + return Ok(ApiResponse.Ok(BatchDetailResponse.FromEntity(batch))); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Controllers/WorkQueueController.cs b/VigilCareRecordsAPI/Controllers/WorkQueueController.cs new file mode 100644 index 0000000..e0c315e --- /dev/null +++ b/VigilCareRecordsAPI/Controllers/WorkQueueController.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + + +/// +/// Work queue endpoints for each workflow stage. +/// Each queue returns batches filtered by status and sorted by submission time ASC +/// so the oldest pending item is always at the top. +/// +[ApiController] +[Route("api/v1/work-queue")] +[Produces("application/json")] +[Authorize] +public class WorkQueueController : ControllerBase +{ + private readonly IWorkQueueService _workQueue; + + public WorkQueueController(IWorkQueueService workQueue) + { + _workQueue = workQueue; + } + + /// + /// Returns batches in PendingVerification status, sorted by submittedAt ASC. + /// Verifiers use this queue to pick the next batch to verify. + /// + [HttpGet("verification")] + [Authorize(Roles = "VERIFIER,CLINICAL_APPROVER,ADMINISTRATOR")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task GetVerificationQueue( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + { + var result = await _workQueue.GetVerificationQueueAsync(page, pageSize); + return Ok(ApiResponse.Ok(result)); + } + + /// + /// Returns batches awaiting or currently in data entry. + /// Includes batches in Uploaded (awaiting assignment), InEntry (being entered), + /// and Rejected (returned for re-entry) statuses. + /// Data entry clerks use this queue to find their next assignment. + /// + [HttpGet("entry")] + [Authorize(Roles = "DATA_ENTRY_CLERK,ADMINISTRATOR")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task GetEntryQueue( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + { + var result = await _workQueue.GetEntryQueueAsync(page, pageSize); + return Ok(ApiResponse.Ok(result)); + } + + /// + /// Returns batches in AwaitingClinicalApproval status, sorted by submittedAt ASC. + /// Clinical approvers use this queue to find batches that need clinical sign-off + /// after verification. Only batch types configured to require clinical approval + /// in site config will appear here. + /// + [HttpGet("clinical-approval")] + [Authorize(Roles = "CLINICAL_APPROVER,ADMINISTRATOR")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task GetClinicalApprovalQueue( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + { + var result = await _workQueue.GetClinicalApprovalQueueAsync(page, pageSize); + return Ok(ApiResponse.Ok(result)); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Data/Seed/DataSeeder.cs b/VigilCareRecordsAPI/Data/Seed/DataSeeder.cs index 95976fd..2e02ec3 100644 --- a/VigilCareRecordsAPI/Data/Seed/DataSeeder.cs +++ b/VigilCareRecordsAPI/Data/Seed/DataSeeder.cs @@ -4,9 +4,10 @@ public static class DataSeeder { public static async Task SeedAsync(AppDbContext db) { - if (await db.Users.AnyAsync()) return; + var existing = await db.Users.Select(u => u.Username).ToListAsync(); + if (existing.Count >= 12) return; - var users = new[] + var all = new[] { CreateUser("intake1", "Intake Clerk 1", UserRole.IntakeClerk), CreateUser("intake2", "Intake Clerk 2", UserRole.IntakeClerk), @@ -22,7 +23,11 @@ public static class DataSeeder CreateUser("admin2", "Administrator 2", UserRole.Administrator), }; - db.Users.AddRange(users); + var existingSet = existing.ToHashSet(); + var missing = all.Where(u => !existingSet.Contains(u.Username)).ToArray(); + if (missing.Length == 0) return; + + db.Users.AddRange(missing); await db.SaveChangesAsync(); } diff --git a/VigilCareRecordsAPI/Models/Records/Batch/FieldCheck.cs b/VigilCareRecordsAPI/Models/Records/Batch/FieldCheck.cs new file mode 100644 index 0000000..6a064fa --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/Batch/FieldCheck.cs @@ -0,0 +1,11 @@ +/// +/// A single field-level verification check result. +/// FieldName identifies the field (e.g. "patient.fullName", "observation.heartRate"). +/// Status is "ok", "warning", or "error". +/// Note is an optional comment from the verifier. +/// +public record FieldCheck( + string FieldName, + string Status, + string? Note +); \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/Batch/RejectBatchRequest.cs b/VigilCareRecordsAPI/Models/Records/Batch/RejectBatchRequest.cs new file mode 100644 index 0000000..cb56f60 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/Batch/RejectBatchRequest.cs @@ -0,0 +1,5 @@ +/// +/// Request body for POST /api/v1/digitization-batches/:id/reject. +/// Reason is required — the verifier must explain why the batch was rejected. +/// +public record RejectBatchRequest(string Reason); \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/Batch/VerifyBatchRequest.cs b/VigilCareRecordsAPI/Models/Records/Batch/VerifyBatchRequest.cs new file mode 100644 index 0000000..ad352af --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/Batch/VerifyBatchRequest.cs @@ -0,0 +1,9 @@ +/// +/// Request body for POST /api/v1/digitization-batches/:id/verify. +/// FieldChecks contains the verifier's field-level review results. +/// Passed indicates whether the batch passed all verification checks. +/// +public record VerifyBatchRequest( + List FieldChecks, + bool Passed +); diff --git a/VigilCareRecordsAPI/Models/Records/WorkQueue/WorkQueueItemResponse.cs b/VigilCareRecordsAPI/Models/Records/WorkQueue/WorkQueueItemResponse.cs new file mode 100644 index 0000000..e479d26 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/WorkQueue/WorkQueueItemResponse.cs @@ -0,0 +1,17 @@ +/// +/// A single item in a work queue response. Contains enough information +/// for the user to pick a batch without loading the full detail. +/// +public record WorkQueueItemResponse( + Guid BatchId, + string Status, + string BatchType, + string Track, + Guid? PatientId, + Guid? EnteredByUserId, + string? EnteredByUserName, + string? RejectionReason, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + int EventCount +); diff --git a/VigilCareRecordsAPI/Models/Records/WorkQueue/WorkQueueResponse.cs b/VigilCareRecordsAPI/Models/Records/WorkQueue/WorkQueueResponse.cs new file mode 100644 index 0000000..d8a2c4f --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/WorkQueue/WorkQueueResponse.cs @@ -0,0 +1,11 @@ +/// +/// Paginated work queue response with total count and queue metadata. +/// +public record WorkQueueResponse( + string QueueName, + IReadOnlyList Items, + int Page, + int PageSize, + int TotalCount, + int TotalPages +); \ No newline at end of file diff --git a/VigilCareRecordsAPI/Program.cs b/VigilCareRecordsAPI/Program.cs index afafa7f..ba1f79b 100644 --- a/VigilCareRecordsAPI/Program.cs +++ b/VigilCareRecordsAPI/Program.cs @@ -33,6 +33,10 @@ try .WithSSL(minioOptions.UseSsl) .Build()); + // Site Configuration + builder.Services.Configure( + builder.Configuration.GetSection(SiteConfigOptions.Section)); + // JWT Authentication var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get()!; builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) @@ -56,6 +60,8 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); diff --git a/VigilCareRecordsAPI/Services/Interfaces/IVerificationService.cs b/VigilCareRecordsAPI/Services/Interfaces/IVerificationService.cs new file mode 100644 index 0000000..a6582d5 --- /dev/null +++ b/VigilCareRecordsAPI/Services/Interfaces/IVerificationService.cs @@ -0,0 +1,20 @@ +/// +/// Handles batch verification, rejection, and separation-of-duties enforcement. +/// +public interface IVerificationService +{ + /// + /// Verifies a batch that is in PendingVerification status. + /// Enforces separation of duties: the verifier cannot be the same user who entered the data. + /// On pass, transitions to Verified or AwaitingClinicalApproval based on site config. + /// On fail (Passed = false), transitions to Rejected with field check notes as the reason. + /// + Task VerifyAsync(Guid batchId, VerifyBatchRequest request, Guid verifierUserId); + + /// + /// Rejects a batch that is in PendingVerification or AwaitingClinicalApproval status. + /// Stores the rejection reason on the batch and transitions status to Rejected. + /// The batch returns to the entry work queue for re-entry by a data entry clerk. + /// + Task RejectAsync(Guid batchId, RejectBatchRequest request, Guid actorUserId); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/Interfaces/IWorkQueueService.cs b/VigilCareRecordsAPI/Services/Interfaces/IWorkQueueService.cs new file mode 100644 index 0000000..b160048 --- /dev/null +++ b/VigilCareRecordsAPI/Services/Interfaces/IWorkQueueService.cs @@ -0,0 +1,27 @@ +/// +/// Provides work queue views for each workflow stage. +/// Each queue returns batches filtered by status and sorted by submission time ASC. +/// +public interface IWorkQueueService +{ + /// + /// Returns batches in PendingVerification status, sorted by UpdatedAt ASC (oldest first). + /// This is the verifier's work queue — the next batch to verify is always at the top. + /// + Task GetVerificationQueueAsync(int page, int pageSize); + + /// + /// Returns batches that are awaiting or currently in data entry: + /// - Status = Uploaded (awaiting assignment and entry) + /// - Status = InEntry (currently being entered) + /// - Status = Rejected (returned for re-entry after verification rejection) + /// Sorted by UpdatedAt ASC so rejected batches surface for re-entry. + /// + Task GetEntryQueueAsync(int page, int pageSize); + + /// + /// Returns batches in AwaitingClinicalApproval status, sorted by UpdatedAt ASC. + /// This is the clinical approver's work queue. + /// + Task GetClinicalApprovalQueueAsync(int page, int pageSize); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/VerificationService.cs b/VigilCareRecordsAPI/Services/VerificationService.cs new file mode 100644 index 0000000..a03f05e --- /dev/null +++ b/VigilCareRecordsAPI/Services/VerificationService.cs @@ -0,0 +1,215 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + + +/// +/// Implements batch verification and rejection with separation-of-duties enforcement. +/// Every status transition writes a DigitizationEvent with the actor, timestamp, +/// and metadata including field-level checks where applicable. +/// +public class VerificationService : IVerificationService +{ + private readonly AppDbContext _db; + private readonly SiteConfigOptions _siteConfig; + private readonly ILogger _logger; + + public VerificationService( + AppDbContext db, + IOptions siteConfig, + ILogger logger) + { + _db = db; + _siteConfig = siteConfig.Value; + _logger = logger; + } + + public async Task VerifyAsync( + Guid batchId, VerifyBatchRequest request, Guid verifierUserId) + { + var batch = await _db.DigitizationBatches + .Include(b => b.Events) + .FirstOrDefaultAsync(b => b.Id == batchId); + + if (batch is null) + throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND"); + + // --- Status guard --- + if (batch.Status != BatchStatus.PendingVerification) + throw new ConflictException( + $"Batch is in '{batch.Status.ToDbString()}' status. " + + "Only batches in 'PENDING_VERIFICATION' can be verified.", + "ILLEGAL_STATUS_TRANSITION"); + + // --- Separation of duties --- + if (batch.EnteredByUserId == verifierUserId) + throw new ConflictException( + "The user who entered the data cannot verify the same batch. " + + "Assign a different verifier.", + "SEPARATION_OF_DUTIES_VIOLATION"); + + // --- Validate request --- + if (request.FieldChecks is null || request.FieldChecks.Count == 0) + throw new ValidationException( + "At least one field check is required.", + "FIELD_CHECKS_REQUIRED"); + + var invalidStatuses = request.FieldChecks + .Where(fc => fc.Status is not ("ok" or "warning" or "error")) + .Select(fc => fc.FieldName) + .ToList(); + + if (invalidStatuses.Count > 0) + throw new ValidationException( + $"Invalid status for fields: {string.Join(", ", invalidStatuses)}. " + + "Allowed values: ok, warning, error.", + "INVALID_FIELD_CHECK_STATUS"); + + // --- Build event metadata with field checks --- + var metadata = new + { + fieldChecks = request.FieldChecks.Select(fc => new + { + fieldName = fc.FieldName, + status = fc.Status, + note = fc.Note + }), + passed = request.Passed, + totalChecks = request.FieldChecks.Count, + errorCount = request.FieldChecks.Count(fc => fc.Status == "error"), + warningCount = request.FieldChecks.Count(fc => fc.Status == "warning") + }; + + if (request.Passed) + { + // --- Determine target status based on site config --- + var requiresClinical = _siteConfig.RequiresClinicalApproval(batch.BatchType); + var targetStatus = requiresClinical + ? BatchStatus.AwaitingClinicalApproval + : BatchStatus.Verified; + + batch.Status = targetStatus; + batch.VerifiedByUserId = verifierUserId; + batch.RejectionReason = null; // Clear any previous rejection reason + batch.UpdatedAt = DateTimeOffset.UtcNow; + + _db.DigitizationEvents.Add(new DigitizationEvent + { + Id = Guid.NewGuid(), + BatchId = batchId, + EventType = requiresClinical ? DigitizationEventType.VerifiedPendingClinical : DigitizationEventType.Verified, + ActorUserId = verifierUserId, + OccurredAt = DateTimeOffset.UtcNow, + MetadataJson = JsonSerializer.Serialize(metadata) + }); + + await _db.SaveChangesAsync(); + + _logger.LogInformation( + "Batch {BatchId} verified by {VerifierUserId} -> {TargetStatus}", + batchId, verifierUserId, targetStatus.ToDbString()); + } + else + { + // Verification failed — treat as rejection with field-level detail + var fieldErrors = request.FieldChecks + .Where(fc => fc.Status == "error") + .Select(fc => $"{fc.FieldName}: {fc.Note ?? "failed check"}") + .ToList(); + + var rejectionReason = fieldErrors.Count > 0 + ? $"Verification failed. Errors: {string.Join("; ", fieldErrors)}" + : "Verification failed. See field checks for details."; + + batch.Status = BatchStatus.Rejected; + batch.RejectionReason = rejectionReason; + batch.VerifiedByUserId = null; + batch.UpdatedAt = DateTimeOffset.UtcNow; + + _db.DigitizationEvents.Add(new DigitizationEvent + { + Id = Guid.NewGuid(), + BatchId = batchId, + EventType = DigitizationEventType.VerificationFailed, + ActorUserId = verifierUserId, + OccurredAt = DateTimeOffset.UtcNow, + MetadataJson = JsonSerializer.Serialize(metadata) + }); + + await _db.SaveChangesAsync(); + + _logger.LogInformation( + "Batch {BatchId} verification failed by {VerifierUserId}, rejected", + batchId, verifierUserId); + } + + return batch; + } + + public async Task RejectAsync( + Guid batchId, RejectBatchRequest request, Guid actorUserId) + { + var batch = await _db.DigitizationBatches + .FirstOrDefaultAsync(b => b.Id == batchId); + + if (batch is null) + throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND"); + + // --- Status guard: reject is allowed from PendingVerification or AwaitingClinicalApproval --- + if (batch.Status is not (BatchStatus.PendingVerification or BatchStatus.AwaitingClinicalApproval)) + throw new ConflictException( + $"Batch is in '{batch.Status.ToDbString()}' status. " + + "Only batches in 'PENDING_VERIFICATION' or 'AWAITING_CLINICAL_APPROVAL' can be rejected.", + "ILLEGAL_STATUS_TRANSITION"); + + // --- Separation of duties for rejection from PendingVerification --- + if (batch.Status == BatchStatus.PendingVerification && batch.EnteredByUserId == actorUserId) + throw new ConflictException( + "The user who entered the data cannot reject the same batch. " + + "Assign a different verifier.", + "SEPARATION_OF_DUTIES_VIOLATION"); + + // --- Validate reason --- + if (string.IsNullOrWhiteSpace(request.Reason)) + throw new ValidationException( + "Rejection reason is required.", + "REJECTION_REASON_REQUIRED"); + + if (request.Reason.Length < 10) + throw new ValidationException( + "Rejection reason must be at least 10 characters.", + "REJECTION_REASON_TOO_SHORT"); + + // --- Apply rejection --- + var previousStatus = batch.Status; + batch.Status = BatchStatus.Rejected; + batch.RejectionReason = request.Reason; + batch.VerifiedByUserId = null; + batch.UpdatedAt = DateTimeOffset.UtcNow; + + var metadata = new + { + reason = request.Reason, + previousStatus = previousStatus.ToDbString(), + rejectedByUserId = actorUserId + }; + + _db.DigitizationEvents.Add(new DigitizationEvent + { + Id = Guid.NewGuid(), + BatchId = batchId, + EventType = DigitizationEventType.Rejected, + ActorUserId = actorUserId, + OccurredAt = DateTimeOffset.UtcNow, + MetadataJson = JsonSerializer.Serialize(metadata) + }); + + await _db.SaveChangesAsync(); + + _logger.LogInformation( + "Batch {BatchId} rejected by {ActorUserId} from {PreviousStatus}: {Reason}", + batchId, actorUserId, previousStatus.ToDbString(), request.Reason); + + return batch; + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/WorkQueueService.cs b/VigilCareRecordsAPI/Services/WorkQueueService.cs new file mode 100644 index 0000000..fe0ef4d --- /dev/null +++ b/VigilCareRecordsAPI/Services/WorkQueueService.cs @@ -0,0 +1,83 @@ +using Microsoft.EntityFrameworkCore; + + +/// +/// Provides filtered, sorted work queue views for each workflow stage. +/// All queues sort by UpdatedAt ASC to enforce FIFO processing — the oldest +/// pending item is always at the top of the queue. +/// +public class WorkQueueService : IWorkQueueService +{ + private readonly AppDbContext _db; + + public WorkQueueService(AppDbContext db) + { + _db = db; + } + + public async Task GetVerificationQueueAsync(int page, int pageSize) + { + var query = _db.DigitizationBatches + .Where(b => b.Status == BatchStatus.PendingVerification) + .OrderBy(b => b.UpdatedAt); + + return await BuildQueueResponseAsync("verification", query, page, pageSize); + } + + public async Task GetEntryQueueAsync(int page, int pageSize) + { + var entryStatuses = new[] + { + BatchStatus.Uploaded, + BatchStatus.InEntry, + BatchStatus.Rejected + }; + + var query = _db.DigitizationBatches + .Where(b => entryStatuses.Contains(b.Status)) + .OrderBy(b => b.UpdatedAt); + + return await BuildQueueResponseAsync("entry", query, page, pageSize); + } + + public async Task GetClinicalApprovalQueueAsync(int page, int pageSize) + { + var query = _db.DigitizationBatches + .Where(b => b.Status == BatchStatus.AwaitingClinicalApproval) + .OrderBy(b => b.UpdatedAt); + + return await BuildQueueResponseAsync("clinical-approval", query, page, pageSize); + } + + private async Task BuildQueueResponseAsync( + string queueName, + IOrderedQueryable query, + int page, + int pageSize) + { + var totalCount = await query.CountAsync(); + var totalPages = (int)Math.Ceiling((double)totalCount / pageSize); + + var items = await query + .Include(b => b.EnteredByUser) + .Include(b => b.Events) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(b => new WorkQueueItemResponse( + b.Id, + b.Status.ToDbString(), + b.BatchType.ToDbString(), + b.Track.ToDbString(), + b.PatientId, + b.EnteredByUserId, + b.EnteredByUser != null ? b.EnteredByUser.FullName : null, + b.RejectionReason, + b.CreatedAt, + b.UpdatedAt, + b.Events.Count + )) + .ToListAsync(); + + return new WorkQueueResponse(queueName, items, page, pageSize, totalCount, totalPages); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/appsettings.json b/VigilCareRecordsAPI/appsettings.json index d85fca3..a1775e1 100644 --- a/VigilCareRecordsAPI/appsettings.json +++ b/VigilCareRecordsAPI/appsettings.json @@ -49,5 +49,16 @@ "Microsoft.AspNetCore": "Warning", "Microsoft.EntityFrameworkCore.Database.Command": "Information" } + }, + "SiteConfig": { + "ClinicalApprovalRequired": { + "PATIENT_REGISTRATION": false, + "ENCOUNTER_SUMMARY": true, + "VITALS_SHEET": true, + "LAB_RESULTS": true, + "MEDICATION_LIST": true, + "ALLERGY_UPDATE": false, + "MIXED": true + } } } \ No newline at end of file diff --git a/scripts/run-vigilcare-records-phase-3-verification.sh b/scripts/run-vigilcare-records-phase-3-verification.sh new file mode 100755 index 0000000..e3a99aa --- /dev/null +++ b/scripts/run-vigilcare-records-phase-3-verification.sh @@ -0,0 +1,654 @@ +#!/usr/bin/env bash +# Runs Phase 3 verification checks from docs/plans/phase-3-plan.md. +# +# Covers verification, rejection, separation of duties, clinical approval routing, +# work queues, and digitization event trails. +# +# Prerequisites: +# docker compose up -d (PostgreSQL + Redis; psql via docker compose exec) +# dotnet run --project VigilCareRecordsAPI +# Phase 1 seed data (entry1, entry2, verifier1, verifier2, intake1 users) +# Phase 2 draft entry API (submit-for-verification, draft CRUD) +# +# 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/2 scripts): +# VIGILCARE_API_URL default: http://localhost:5217 +# VIGILCARE_COMPOSE_FILE default: /docker-compose.yml +# VIGILCARE_PG_HOST default: localhost +# VIGILCARE_PG_PORT default: 5437 +# VIGILCARE_PG_DB default: vigilcare_records +# VIGILCARE_PG_USER default: postgres +# VIGILCARE_PG_PASSWORD default: password +# VIGILCARE_SKIP_DB_CHECKS set to 1 to skip PostgreSQL assertions +# VIGILCARE_RECORDED_AT default: 2025-01-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}" + +RECORDED_AT="${VIGILCARE_RECORDED_AT:-2025-01-01T10:00:00Z}" + +PASS_COUNT=0 +FAIL_COUNT=0 +FAILED_TESTS=() + +log() { + printf '%s\n' "$*" +} + +section() { + log "" + log "== $1 ==" +} + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + log " PASS: $1" +} + +fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) + FAILED_TESTS+=("$1") + log " FAIL: $1" +} + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + log "ERROR: required command not found: $cmd" + exit 1 + fi +} + +compose_service_running() { + local service="$1" + "${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -qx "$service" +} + +psql_available() { + [[ "$SKIP_DB_CHECKS" == "1" ]] && return 1 + 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 + # Prefer docker compose postgres — matches local dev setup and avoids host psql gaps. + 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 +} + +http_code() { + curl -sS -o /dev/null -w '%{http_code}' "$@" +} + +json_post() { + local url="$1" + local body="$2" + local token="${3:-}" + if [[ -n "$token" ]]; then + curl -sS -X POST "$url" \ + -H "Authorization: Bearer $token" \ + -H 'Content-Type: application/json' \ + -d "$body" + else + curl -sS -X POST "$url" \ + -H 'Content-Type: application/json' \ + -d "$body" + fi +} + +json_put() { + local url="$1" + local body="$2" + local token="$3" + curl -sS -X PUT "$url" \ + -H "Authorization: Bearer $token" \ + -H 'Content-Type: application/json' \ + -d "$body" +} + +login() { + local username="$1" + local password="${2:-password}" + json_post "$API_URL/api/v1/auth/login" \ + "{\"username\":\"$username\",\"password\":\"$password\"}" +} + +extract_data_field() { + local json="$1" + local field="$2" + jq -er ".data.$field // empty" <<<"$json" +} + +extract_error_code() { + local json="$1" + jq -er '.error.code // empty' <<<"$json" +} + +upload_batch() { + local token="$1" + local batch_type="${2:-VITALS_SHEET}" + local file_path="${3:-$FIXTURE_PDF}" + + curl -sS -X POST "$API_URL/api/v1/digitization-batches" \ + -H "Authorization: Bearer $token" \ + -F "file=@${file_path};type=application/pdf" \ + -F "batchType=$batch_type" +} + +assign_batch() { + local token="$1" + local batch_id="$2" + local entry_clerk_id="$3" + + curl -sS -X PATCH "$API_URL/api/v1/digitization-batches/$batch_id/assign" \ + -H "Authorization: Bearer $token" \ + -H 'Content-Type: application/json' \ + -d "{\"entryClerkUserId\":\"$entry_clerk_id\"}" +} + +verify_batch() { + local token="$1" + local batch_id="$2" + local body="$3" + + json_post "$API_URL/api/v1/digitization-batches/$batch_id/verify" "$body" "$token" +} + +reject_batch() { + local token="$1" + local batch_id="$2" + local reason="$3" + + json_post "$API_URL/api/v1/digitization-batches/$batch_id/reject" \ + "{\"reason\":\"$reason\"}" "$token" +} + +assert_api_reachable() { + local code + code="$(http_code "$API_URL/swagger/index.html" || true)" + if [[ "$code" != "200" ]]; then + log "ERROR: API not reachable at $API_URL (HTTP $code)." + log "Start infrastructure with: docker compose up -d" + log "Start API with: dotnet run --project VigilCareRecordsAPI" + exit 1 + fi +} + +# Returns 0 if batch_id appears anywhere in the paginated work queue. +queue_contains_batch() { + local token="$1" + local queue_name="$2" + local batch_id="$3" + local page=1 page_size=100 total_pages=1 + + while (( page <= total_pages )); do + local json found + json="$(curl -sS "$API_URL/api/v1/work-queue/$queue_name?page=$page&pageSize=$page_size" \ + -H "Authorization: Bearer $token")" + found="$(jq -er --arg id "$batch_id" \ + 'if (.data.items | map(.batchId) | index($id)) != null then "true" else "false" end' \ + <<<"$json")" + if [[ "$found" == "true" ]]; then + return 0 + fi + total_pages="$(jq -er '.data.totalPages // 1' <<<"$json")" + page=$((page + 1)) + done + + return 1 +} + +# Upload a batch, assign to entry1. Prints batch id to stdout. +create_assigned_batch() { + local intake_token="$1" + local batch_type="${2:-VITALS_SHEET}" + local upload_json batch_id entry_json entry_id + + upload_json="$(upload_batch "$intake_token" "$batch_type")" + if [[ "$(jq -er '.success' <<<"$upload_json")" != "true" ]]; then + log "ERROR: failed to upload $batch_type batch for test setup" + return 1 + fi + + batch_id="$(extract_data_field "$upload_json" id)" + entry_json="$(login entry1)" + entry_id="$(extract_data_field "$entry_json" userId)" + assign_batch "$intake_token" "$batch_id" "$entry_id" >/dev/null + + printf '%s' "$batch_id" +} + +# Drive a VITALS_SHEET batch through data entry and submit to PENDING_VERIFICATION. +# Prints batch id to stdout. +create_pending_vitals_batch() { + local intake_token="$1" + local entry_token batch_id + + batch_id="$(create_assigned_batch "$intake_token" "VITALS_SHEET")" || return 1 + entry_token="$(extract_data_field "$(login entry1)" token)" + + json_put \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \ + '{"fullName":"Chen Wei-Lin","dateOfBirth":"1985-03-15","sex":"M","noKnownAllergies":true,"noActiveMedications":true}' \ + "$entry_token" >/dev/null + + json_put \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/encounter" \ + '{"admissionDate":"2025-01-01T08:00:00Z","department":"ICU","admissionReason":"Chest pain"}' \ + "$entry_token" >/dev/null + + json_post \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ + "{\"observationCode\":\"HEART_RATE\",\"value\":88,\"unit\":\"bpm\",\"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 + + printf '%s' "$batch_id" +} + +# Drive a PATIENT_REGISTRATION batch to PENDING_VERIFICATION. +create_pending_patient_registration_batch() { + local intake_token="$1" + local entry_token batch_id + + batch_id="$(create_assigned_batch "$intake_token" "PATIENT_REGISTRATION")" || return 1 + entry_token="$(extract_data_field "$(login entry1)" token)" + + json_put \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \ + '{"fullName":"Maria Santos","dateOfBirth":"1992-07-20","sex":"F","noKnownAllergies":true,"noActiveMedications":true}' \ + "$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: patient registration submit failed (HTTP $submit_code)" + return 1 + fi + + printf '%s' "$batch_id" +} + +test_separation_of_duties() { + section "1. Separation of duties — same user cannot verify own batch (409)" + + if ! psql_available; then + if [[ "$SKIP_DB_CHECKS" == "1" ]]; then + log " SKIP: separation of duties (VIGILCARE_SKIP_DB_CHECKS=1)" + else + log " SKIP: separation of duties (postgres not reachable)" + fi + return + fi + + local intake_json intake_token batch_id verifier_json verifier_id + local verify_json verify_code error_code + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + batch_id="$(create_pending_vitals_batch "$intake_token")" || return + + verifier_json="$(login verifier1)" + verifier_id="$(extract_data_field "$verifier_json" userId)" + + # Simulate a batch entered by verifier1 — they must not verify their own work. + psql_query "UPDATE digitization_batches SET entered_by_user_id = '$verifier_id' WHERE id = '$batch_id';" >/dev/null + + verify_json="$(verify_batch "$(extract_data_field "$verifier_json" token)" "$batch_id" \ + '{"fieldChecks":[{"fieldName":"patient.fullName","status":"ok","note":null}],"passed":true}')" + verify_code="$(http_code -X POST \ + "$API_URL/api/v1/digitization-batches/$batch_id/verify" \ + -H "Authorization: Bearer $(extract_data_field "$verifier_json" token)" \ + -H 'Content-Type: application/json' \ + -d '{"fieldChecks":[{"fieldName":"patient.fullName","status":"ok","note":null}],"passed":true}')" + error_code="$(extract_error_code "$verify_json")" + + if [[ "$verify_code" == "409" && "$error_code" == "SEPARATION_OF_DUTIES_VIOLATION" ]]; then + pass "verifier cannot verify batch they entered (409 SEPARATION_OF_DUTIES_VIOLATION)" + else + fail "verifier cannot verify batch they entered (409 SEPARATION_OF_DUTIES_VIOLATION) (http=$verify_code code=${error_code:-})" + fi +} + +test_different_verifier_succeeds() { + section "2. Different verifier succeeds — vitals batch transitions to AWAITING_CLINICAL_APPROVAL" + + local intake_json intake_token batch_id verifier_json verifier_token + local verify_json status + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + batch_id="$(create_pending_vitals_batch "$intake_token")" || return + + verifier_json="$(login verifier1)" + verifier_token="$(extract_data_field "$verifier_json" token)" + + verify_json="$(verify_batch "$verifier_token" "$batch_id" \ + '{"fieldChecks":[{"fieldName":"patient.fullName","status":"ok","note":null},{"fieldName":"observation.heartRate","status":"ok","note":"Within range"}],"passed":true}')" + status="$(extract_data_field "$verify_json" status)" + + if [[ "$(jq -er '.success' <<<"$verify_json")" == "true" && "$status" == "AWAITING_CLINICAL_APPROVAL" ]]; then + pass "verifier1 verifies entry1 batch (AWAITING_CLINICAL_APPROVAL for VITALS_SHEET)" + else + fail "verifier1 verifies entry1 batch (AWAITING_CLINICAL_APPROVAL for VITALS_SHEET) (status=${status:-})" + fi +} + +test_clinical_approval_routing_patient_registration() { + section "3. Clinical approval routing — PATIENT_REGISTRATION goes directly to VERIFIED" + + local intake_json intake_token batch_id verifier_token verify_json status + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + batch_id="$(create_pending_patient_registration_batch "$intake_token")" || return + + verifier_token="$(extract_data_field "$(login verifier1)" token)" + + verify_json="$(verify_batch "$verifier_token" "$batch_id" \ + '{"fieldChecks":[{"fieldName":"patient.fullName","status":"ok","note":null},{"fieldName":"patient.dateOfBirth","status":"ok","note":null}],"passed":true}')" + status="$(extract_data_field "$verify_json" status)" + + if [[ "$(jq -er '.success' <<<"$verify_json")" == "true" && "$status" == "VERIFIED" ]]; then + pass "PATIENT_REGISTRATION verification skips clinical approval (VERIFIED)" + else + fail "PATIENT_REGISTRATION verification skips clinical approval (VERIFIED) (status=${status:-})" + fi +} + +test_reject_with_valid_reason() { + section "4. Rejection with reason — batch becomes REJECTED and appears in entry queue" + + local intake_json intake_token batch_id verifier_token entry_token + local reject_json status queue_json queue_contains + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + batch_id="$(create_pending_vitals_batch "$intake_token")" || return + + verifier_token="$(extract_data_field "$(login verifier1)" token)" + entry_token="$(extract_data_field "$(login entry1)" token)" + + reject_json="$(reject_batch "$verifier_token" "$batch_id" \ + "Patient name does not match the scanned registration form. Please re-enter from chart.")" + status="$(extract_data_field "$reject_json" status)" + + if [[ "$(jq -er '.success' <<<"$reject_json")" == "true" && "$status" == "REJECTED" ]]; then + pass "reject with valid reason transitions to REJECTED" + else + fail "reject with valid reason transitions to REJECTED (status=${status:-})" + return + fi + + if psql_available; then + local db_status + db_status="$(psql_query "SELECT status FROM digitization_batches WHERE id = '$batch_id';")" + if [[ "$db_status" == "REJECTED" ]]; then + pass "rejected batch status confirmed in PostgreSQL" + else + fail "rejected batch status confirmed in PostgreSQL (got: ${db_status:-})" + fi + fi + + if queue_contains_batch "$entry_token" "entry" "$batch_id"; then + pass "rejected batch appears in entry work queue" + else + fail "rejected batch appears in entry work queue" + fi +} + +test_reject_validation() { + section "5. Rejection validation — missing or short reason returns 422" + + local intake_json intake_token batch_id verifier_token + local empty_json empty_code empty_error short_json short_error + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + batch_id="$(create_pending_vitals_batch "$intake_token")" || return + + verifier_token="$(extract_data_field "$(login verifier1)" token)" + + empty_json="$(reject_batch "$verifier_token" "$batch_id" "")" + empty_code="$(http_code -X POST \ + "$API_URL/api/v1/digitization-batches/$batch_id/reject" \ + -H "Authorization: Bearer $verifier_token" \ + -H 'Content-Type: application/json' \ + -d '{"reason":""}')" + empty_error="$(extract_error_code "$empty_json")" + + if [[ "$empty_code" == "422" && "$empty_error" == "REJECTION_REASON_REQUIRED" ]]; then + pass "reject without reason returns 422 REJECTION_REASON_REQUIRED" + else + fail "reject without reason returns 422 REJECTION_REASON_REQUIRED (http=$empty_code code=${empty_error:-})" + fi + + short_json="$(reject_batch "$verifier_token" "$batch_id" "Bad data")" + short_error="$(extract_error_code "$short_json")" + if [[ "$(http_code -X POST \ + "$API_URL/api/v1/digitization-batches/$batch_id/reject" \ + -H "Authorization: Bearer $verifier_token" \ + -H 'Content-Type: application/json' \ + -d '{"reason":"Bad data"}')" == "422" && + "$short_error" == "REJECTION_REASON_TOO_SHORT" ]]; then + pass "reject with short reason returns 422 REJECTION_REASON_TOO_SHORT" + else + fail "reject with short reason returns 422 REJECTION_REASON_TOO_SHORT (code=${short_error:-})" + fi +} + +test_verify_wrong_status() { + section "6. Status guard — verify on non-pending batch returns 409" + + if ! psql_available; then + if [[ "$SKIP_DB_CHECKS" == "1" ]]; then + log " SKIP: verify wrong status (VIGILCARE_SKIP_DB_CHECKS=1)" + else + log " SKIP: verify wrong status (postgres not reachable)" + fi + return + fi + + local intake_json intake_token batch_id verifier_token verify_json error_code + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + batch_id="$(create_pending_vitals_batch "$intake_token")" || return + + psql_query "UPDATE digitization_batches SET status = 'REJECTED' WHERE id = '$batch_id';" >/dev/null + + verifier_token="$(extract_data_field "$(login verifier1)" token)" + verify_json="$(verify_batch "$verifier_token" "$batch_id" \ + '{"fieldChecks":[{"fieldName":"patient.fullName","status":"ok","note":null}],"passed":true}')" + error_code="$(extract_error_code "$verify_json")" + + if [[ "$(http_code -X POST \ + "$API_URL/api/v1/digitization-batches/$batch_id/verify" \ + -H "Authorization: Bearer $verifier_token" \ + -H 'Content-Type: application/json' \ + -d '{"fieldChecks":[{"fieldName":"patient.fullName","status":"ok","note":null}],"passed":true}')" == "409" && + "$error_code" == "ILLEGAL_STATUS_TRANSITION" ]]; then + pass "verify on REJECTED batch returns 409 ILLEGAL_STATUS_TRANSITION" + else + fail "verify on REJECTED batch returns 409 ILLEGAL_STATUS_TRANSITION (code=${error_code:-})" + fi +} + +test_verify_passed_false() { + section "7. Verification failed — passed=false transitions to REJECTED with field errors" + + local intake_json intake_token batch_id verifier_token verify_json status reason + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + batch_id="$(create_pending_vitals_batch "$intake_token")" || return + + verifier_token="$(extract_data_field "$(login verifier1)" token)" + + verify_json="$(verify_batch "$verifier_token" "$batch_id" \ + '{"fieldChecks":[{"fieldName":"patient.fullName","status":"ok","note":null},{"fieldName":"observation.heartRate","status":"error","note":"Value 350 is not physiologically possible"}],"passed":false}')" + status="$(extract_data_field "$verify_json" status)" + reason="$(jq -er '.data.rejectionReason // empty' <<<"$verify_json")" + + if [[ "$(jq -er '.success' <<<"$verify_json")" == "true" && "$status" == "REJECTED" && + "$reason" == *"350 is not physiologically possible"* ]]; then + pass "passed=false verification rejects batch with field error details" + else + fail "passed=false verification rejects batch with field error details (status=${status:-})" + fi +} + +test_verification_queue() { + section "8. Work queues — verification queue lists PENDING_VERIFICATION batches only" + + local intake_json intake_token batch_id verifier_token queue_json + local item_count all_pending + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + batch_id="$(create_pending_vitals_batch "$intake_token")" || return + + verifier_token="$(extract_data_field "$(login verifier1)" token)" + + if queue_contains_batch "$verifier_token" "verification" "$batch_id"; then + pass "verification queue includes test batch" + else + fail "verification queue includes test batch" + return + fi + + queue_json="$(curl -sS "$API_URL/api/v1/work-queue/verification?page=1&pageSize=100" \ + -H "Authorization: Bearer $verifier_token")" + item_count="$(jq -er '.data.items | length' <<<"$queue_json")" + all_pending="$(jq -er 'if (.data.items | length) == 0 then true else ([.data.items[].status] | all(. == "PENDING_VERIFICATION")) end' <<<"$queue_json")" + + if [[ "$item_count" -ge 1 && "$all_pending" == "true" ]]; then + pass "verification queue returns only PENDING_VERIFICATION batches" + else + fail "verification queue returns only PENDING_VERIFICATION batches (count=$item_count)" + fi +} + +test_event_trail() { + section "9. Event trail — digitization_events recorded for verify/reject" + + if ! psql_available; then + if [[ "$SKIP_DB_CHECKS" == "1" ]]; then + log " SKIP: event trail (VIGILCARE_SKIP_DB_CHECKS=1)" + else + log " SKIP: event trail (postgres not reachable)" + fi + return + fi + + local intake_json intake_token batch_id verifier_token verify_json event_types + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + batch_id="$(create_pending_vitals_batch "$intake_token")" || return + + verifier_token="$(extract_data_field "$(login verifier1)" token)" + + verify_json="$(verify_batch "$verifier_token" "$batch_id" \ + '{"fieldChecks":[{"fieldName":"patient.fullName","status":"ok","note":null}],"passed":true}')" + if [[ "$(jq -er '.success' <<<"$verify_json")" != "true" ]]; then + fail "event trail setup — verify succeeded" + return + fi + + event_types="$(psql_query "SELECT event_type FROM digitization_events WHERE batch_id = '$batch_id' ORDER BY occurred_at;")" + if grep -q 'submitted_for_verification' <<<"$event_types" && + grep -q 'verified_pending_clinical' <<<"$event_types"; then + pass "digitization_events include submitted_for_verification and verified_pending_clinical" + else + fail "digitization_events include submitted_for_verification and verified_pending_clinical" + log " events: $(tr '\n' ' ' <<<"$event_types")" + fi + + local metadata + metadata="$(psql_query "SELECT metadata_json FROM digitization_events WHERE batch_id = '$batch_id' AND event_type = 'verified_pending_clinical' LIMIT 1;")" + if [[ "$metadata" == *"fieldChecks"* ]]; then + pass "verify event metadata_json contains fieldChecks" + else + fail "verify event metadata_json contains fieldChecks" + 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 3 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_separation_of_duties + test_different_verifier_succeeds + test_clinical_approval_routing_patient_registration + test_reject_with_valid_reason + test_reject_validation + test_verify_wrong_status + test_verify_passed_false + test_verification_queue + test_event_trail + + 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 3 verification checks passed." +} + +main "$@"