diff --git a/README.md b/README.md
index 217a30b..a5af5a9 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
-# VigilCare Records API
+# VigilCare Records
-A clinical records intake backend built with ASP.NET Core 8, PostgreSQL, MinIO, and Redis. The domain models the scan-to-approved lifecycle at the center of any paper chart digitization system: scanned document upload, human data entry, dual-human verification with separation of duties, site-configurable clinical approval routing, atomic promotion to live clinical tables, and governed correction via supersession.
+A clinical records digitization platform built with ASP.NET Core 8, PostgreSQL, MinIO, Redis, and a Vue 3 workstation UI. The domain models the scan-to-approved lifecycle at the center of any paper chart digitization system: scanned document upload, human data entry, dual-human verification with separation of duties, site-configurable clinical approval routing, atomic promotion to live clinical tables, and governed correction via supersession.
-**Implementation status:** Six planned phases are complete through Phase 6 — from schema, authentication, and batch CRUD through draft data entry, verification/rejection with separation of duties, approval with atomic promotion to VigilCareClinical live tables, correction batches that supersede erroneous promoted observations without silent edits, and Track B live capture with clinician attestation and synchronous critical alerting. See [Implemented Phases](#implemented-phases) for the full breakdown.
+**Implementation status:** Phases 1–6 are complete (API core through Track B live capture). Phase 7 delivers the digitization workstation UI (`vigilcare-records-web`). Phase 8 is partially implemented — work queue overview endpoint and supervisor dashboard UI are in place; Prometheus metrics and promotion retry remain planned. See [Implemented Phases](#implemented-phases) for the full breakdown.
## Domain Model — How It Maps to a Real Clinical System
@@ -55,7 +55,10 @@ Append-only audit log entry for every state transition, field-level correction,
- **Promotion Result Query** — `GET /digitization-batches/:id/promotion-result` returns live entity IDs (patient, MRN, encounter, observations) created during promotion
- **Corrections and Supersession** — approved live observations are never silently edited; a correction uploads a new batch with `supersedesBatchId`, goes through the full entry → verify → approve cycle, and on promotion marks the original batch's `live_observations` rows as `is_superseded` (append-only — never deleted); `422 SUPERSEDED_BATCH_NOT_PROMOTED`, `409 BATCH_ALREADY_SUPERSEDED`, and `404 SUPERSEDED_BATCH_NOT_FOUND` guard invalid supersession targets; correction batches inherit patient context and reuse the original promotion encounter
- **Patient Digitization History** — `GET /patients/:id/digitization-history` returns all batches for a patient with correction chain metadata (`isCorrection`, `hasBeenSuperseded`, `supersededByBatchId`), live vs superseded observation counts, summary totals, and per-batch audit trails; `404 PATIENT_HISTORY_NOT_FOUND` when no batches exist for the patient
-- **Work Queues** — `GET /work-queue/verification` (batches in `PENDING_VERIFICATION`, sorted by submission time ASC); `GET /work-queue/entry` (batches in `UPLOADED`, `IN_ENTRY`, or `REJECTED`); `GET /work-queue/clinical-approval` (batches in `AWAITING_CLINICAL_APPROVAL`); role-restricted access
+- **Patient Registry Search** — `GET /patients/search?q=` searches live patients by MRN or full name (minimum 2 characters); used by the intake workstation to link uploads to existing patients
+- **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`); `GET /work-queue/overview` (aggregate status counts, average queue age, reject rate, oldest pending verification — administrator only); role-restricted access
+- **User Directory** — `GET /users?role=` lists active users for batch assignment (intake clerks assign entry clerks via the workstation UI)
+- **Digitization Workstation UI** — Vue 3 SPA at `vigilcare-records-web`: role-based routing (intake, entry, verification, supervisor dashboard), split-pane scan viewer with zoom/pan/rotate, draft entry with auto-save, field-level verification checkboxes, presigned URL refresh for long sessions, JWT refresh interceptor
- **Live Capture (Track B)** — `POST /live-capture/encounters/{encounterId}/observations` and `POST /live-capture/encounters` for credentialed clinicians entering vitals at bedside; clinician attestation + password re-confirm replaces the dual-human verification gate; observations promote synchronously to live tables in a single transaction with `source = live_capture`; critical threshold evaluation runs before the response returns, with inline `criticalAlert` per observation, committed `ClinicalAlert` rows, and `observation.recorded` / `alert.generated` outbox events; `422 ATTESTATION_REQUIRED`, `422 PASSWORD_CONFIRM_INVALID`, `422 EMPTY_OBSERVATIONS`, `409 ENCOUNTER_NOT_ACTIVE`, `409 ACTIVE_ENCOUNTER_EXISTS`
- **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
@@ -85,7 +88,9 @@ HTTP request
├── DigitizationHistoryService (patient digitization history with correction chain and audit trails)
├── IdempotencyService (Idempotency-Key storage + replay for safe promotion retries)
├── MrnGenerator (PostgreSQL sequence-backed MRN generation: VCR-000001)
- ├── WorkQueueService (verification, entry, clinical approval queues)
+ ├── WorkQueueService (verification, entry, clinical approval queues, supervisor overview metrics)
+ ├── PatientRegistryService (live patient search by MRN or name)
+ ├── UserDirectoryService (active user listing for batch assignment)
├── AttestationService (clinician role + password re-confirm for live capture)
├── LiveCaptureService (Track B synchronous promotion + critical alert evaluation)
├── DocumentStorageService (MinIO upload, SHA-256, presigned URLs)
@@ -122,6 +127,7 @@ HTTP request
| Layer | Technology |
|---|---|
| Server | ASP.NET Core 8 (.NET 8.0) |
+| Frontend | Vue 3, Vite, Pinia, Vue Router, Axios, Tailwind CSS, VueUse |
| Database | PostgreSQL 16 with EF Core 8 (code-first migrations) |
| Cache / locking | Redis 7 (batch assignment locks, alert threshold cache for live capture) |
| Object storage | MinIO (scanned documents — PDF, JPEG, PNG) |
@@ -136,134 +142,50 @@ HTTP request
## Project Structure
```
-VigilCareRecordsAPI/
-├── Program.cs # Service registration, middleware, seed on startup
-├── appsettings.json # Connection strings, Redis, MinIO, Seq, JWT, site config
-├── Controllers/
-│ ├── ApprovalController.cs # Batch approval and promotion to live clinical tables
-│ ├── AuthController.cs # JWT login, token refresh, logout, authenticated user profile
-│ ├── DigitizationBatchesController.cs # Batch CRUD, document upload, assignment
-│ ├── DraftController.cs # Draft data entry: patient, encounter, observations, submit
-│ ├── PatientsController.cs # Patient digitization history with correction chain
-│ ├── LiveCaptureController.cs # Track B live capture: record vitals, open encounter + vitals
-│ ├── VerificationController.cs # Batch verification and rejection with separation of duties
-│ └── WorkQueueController.cs # Work queues: verification, entry, clinical approval
-├── Domain/
-│ ├── Entities/
-│ │ ├── Clinical/
-│ │ │ ├── Patient.cs # Live patient record with MRN (promoted from draft)
-│ │ │ ├── Encounter.cs # Live encounter (promoted from draft)
-│ │ │ ├── Observation.cs # Live observation with source traceability (batchId, draftObsId)
-│ │ │ ├── OutboxEvent.cs # Transactional outbox for downstream consumers (eventType, aggregateType, payloadJson)
-│ │ │ ├── AlertThreshold.cs # Critical/warning bounds per observation code (live capture alerting)
-│ │ │ ├── ClinicalAlert.cs # Synchronous critical alerts (AlertType, Severity, Details)
-│ │ │ └── IdempotencyRecord.cs # Idempotency-Key storage for safe promotion retries
-│ │ ├── Draft/
-│ │ │ ├── DraftPatient.cs # Structured patient demographics from paper chart
-│ │ │ ├── DraftEncounter.cs # Encounter context: admission, department, room/bed
-│ │ │ └── DraftObservation.cs # Single measurement: code, value, unit, recordedAt
-│ │ ├── DigitizationBatch.cs # Central workflow entity with status machine
-│ │ ├── LiveEncounter.cs # Live encounter mirror for supersession / history queries
-│ │ ├── LiveObservation.cs # Live observation with supersession flags (is_superseded, superseded_by_batch_id)
-│ │ ├── 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
-│ ├── AlertType.cs # Critical/warning alert types (ported from VigilCareClinical)
-│ ├── AlertSeverity.cs # WARNING, CRITICAL
-│ └── AlertStatus.cs # OPEN, ACKNOWLEDGED, RESOLVED, ESCALATED
-├── Services/
-│ ├── Interfaces/ # IAuthService, IBatchService, IDraftService, IVerificationService, IWorkQueueService, IDocumentStorageService, IPromotionService, IIdempotencyService, IMrnGenerator, IDigitizationHistoryService, ILiveCaptureService, IAttestationService
-│ ├── AuthService.cs # Login (BCrypt verify), JWT generation, refresh rotation, logout, audit logging
-│ ├── BatchService.cs # Batch CRUD, status machine transitions, Redis assignment lock, duplicate detection, supersession validation on create
-│ ├── DraftService.cs # Draft CRUD with plausibility validation, batch-type completeness on submit (relaxed for correction batches)
-│ ├── VerificationService.cs # Verify/reject with separation of duties, site-config clinical approval routing
-│ ├── PromotionService.cs # Atomic approve + promote: draft → live tables in single transaction; supersede original observations on correction promotion
-│ ├── DigitizationHistoryService.cs # Patient history with correction chain, observation counts, audit trails
-│ ├── IdempotencyService.cs # Idempotency-Key record storage + replay (24h TTL)
-│ ├── MrnGenerator.cs # PostgreSQL sequence-backed MRN generation (VCR-000001)
-│ ├── WorkQueueService.cs # Verification, entry, and clinical approval work queues
-│ ├── DocumentStorageService.cs # MinIO upload with SHA-256, presigned URL generation
-│ ├── AttestationService.cs # Clinician role + password re-confirm for live capture
-│ ├── Interfaces/LiveCaptureService.cs # Track B synchronous promotion + critical alert evaluation
-│ └── 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, SupersessionInfo, PatientDigitizationHistoryResponse, DigitizationHistoryEntry, ...
-│ ├── Promotion/ # ApproveRequest, PromotionResultResponse
-│ ├── DraftPatient/ # DraftPatientDto, UpsertDraftPatientRequest
-│ ├── DraftEncounter/ # DraftEncounterDto, UpsertDraftEncounterRequest
-│ ├── DraftObservation/ # DraftObservationDto, CreateDraftObservationRequest, UpdateDraftObservationRequest
-│ ├── WorkQueue/ # WorkQueueResponse, WorkQueueItemResponse
-│ ├── LiveCapture/ # LiveCaptureResponse, RecordObservationsRequest, ThresholdCacheEntry, ...
-│ └── Common/ # PagedResult
-├── Data/
-│ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints
-│ ├── Configurations/
-│ │ ├── Clinical/ # PatientConfiguration, EncounterConfiguration, ObservationConfiguration, OutboxEventConfiguration, AlertThresholdConfiguration, ClinicalAlertConfiguration, IdempotencyRecordConfiguration
-│ │ ├── Draft/ # DraftPatientConfiguration, DraftEncounterConfiguration, DraftObservationConfiguration
-│ │ ├── LiveEncounterConfiguration.cs # live_encounters table mapping
-│ │ ├── LiveObservationConfiguration.cs # live_observations with supersession columns and partial index
-│ │ └── ... # DigitizationBatch, DigitizationEvent, ScannedDocument, User, RefreshToken, AuthAuditEvent configs
-│ ├── Seed/
-│ │ └── DataSeeder.cs # Seeds 12 demo users (two per role) on startup
-│ └── Migrations/ # InitialCreate through AddAlertThresholdsAndClinicalAlerts
-├── 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
- ├── PromotionTests.cs # Approval, atomic promotion, idempotency, separation of duties, retroactive alerts, patient dedup
- ├── CorrectionSupersessionTests.cs # Correction batch supersession, validation guards, patient digitization history
- ├── LiveCaptureIntegrationTests.cs # Track B attestation, synchronous promotion, critical alerts, open encounter workflow
- ├── Fixtures/
- │ ├── ApiFixture.cs # WebApplicationFactory with PostgreSQL, Redis, MinIO containers
- │ └── DatabaseCollection.cs # Shared test collection
- └── Helpers/
- ├── AuthHelper.cs # JWT token generation for test users
- ├── BatchSeedHelper.cs # Creates seeded batches at various lifecycle stages
- ├── BatchPipelineHelper.cs # End-to-end batch pipeline: upload → entry → verify → ready for approval
- ├── CorrectionPipelineHelper.cs # Lab correction pipeline: promote original → promote correction batch
- └── DbResetHelper.cs # Database cleanup between tests
-
-scripts/
-├── run-vigilcare-records-verification.sh # Phase 1 — schema, auth, roles, batch CRUD, MinIO, status machine
-├── run-vigilcare-records-phase-2-verification.sh # Phase 2 — draft entry, plausibility, submit-for-verification
-├── run-vigilcare-records-phase-3-verification.sh # Phase 3 — verification, rejection, separation of duties, work queues
-├── run-vigilcare-records-phase-4-verification.sh # Phase 4 — approval, atomic promotion, idempotency, patient dedup, outbox events
-├── run-vigilcare-records-phase-5-verification.sh # Phase 5 — corrections, supersession, digitization history, audit trail
-└── run-vigilcare-records-phase-6-verification.sh # Phase 6 — live capture, attestation, synchronous alerts, outbox events
-
-docs/
-├── plans/ # Phase 1–9 implementation and verification guides
-└── vigilcare-records-prd.md # Product requirements and phase roadmap
+VigilCareRecords/
+├── VigilCareRecordsAPI/
+│ ├── Program.cs # Service registration, middleware, seed on startup
+│ ├── appsettings.json # Connection strings, Redis, MinIO, Seq, JWT, site config
+│ ├── Controllers/
+│ │ ├── ApprovalController.cs # Batch approval and promotion to live clinical tables
+│ │ ├── AuthController.cs # JWT login, token refresh, logout, authenticated user profile
+│ │ ├── DigitizationBatchesController.cs # Batch CRUD, document upload, assignment
+│ │ ├── DraftController.cs # Draft data entry: patient, encounter, observations, submit
+│ │ ├── PatientsController.cs # Patient search and digitization history
+│ │ ├── UsersController.cs # User directory for batch assignment
+│ │ ├── LiveCaptureController.cs # Track B live capture: record vitals, open encounter + vitals
+│ │ ├── VerificationController.cs # Batch verification and rejection with separation of duties
+│ │ └── WorkQueueController.cs # Work queues and supervisor overview
+│ ├── Domain/ … # Entities, enums (batch, draft, clinical, user)
+│ ├── Services/ … # Auth, batch, draft, verification, promotion, work queue, patient registry, user directory, live capture
+│ ├── Models/Records/ … # Request/response DTOs
+│ ├── Data/ … # EF Core context, configurations, migrations, seed
+│ └── … # Middleware, Common, Infrastructure
+├── vigilcare-records-web/ # Vue 3 digitization workstation UI
+│ ├── src/
+│ │ ├── api/client.ts # Axios client with JWT interceptor and proactive refresh
+│ │ ├── stores/ # Pinia: auth (login, roles, routing), batches (CRUD, draft, verify)
+│ │ ├── router/index.ts # Role-based routes and navigation guards
+│ │ ├── views/ # Login, Intake, Entry, Verification, QueueDashboard
+│ │ ├── components/ # ScanViewer, EntryForm, VerificationForm, BatchList, PatientSearch, AppHeader, AssignClerkDialog
+│ │ ├── composables/usePresignedUrl.ts # Refreshes presigned document URLs before 15-minute expiry
+│ │ └── types/index.ts # TypeScript interfaces matching API response shapes
+│ ├── vite.config.ts # Dev server on port 3028; proxies /api → localhost:5217
+│ └── tailwind.config.js # Clinical color palette and layout component classes
+├── tests/
+│ └── VigilCareRecordsAPI.Tests/ # Integration tests (Phases 1–6)
+├── scripts/
+│ ├── run-vigilcare-records-verification.sh # Phase 1
+│ ├── run-vigilcare-records-phase-2-verification.sh
+│ ├── run-vigilcare-records-phase-3-verification.sh
+│ ├── run-vigilcare-records-phase-4-verification.sh
+│ ├── run-vigilcare-records-phase-5-verification.sh
+│ ├── run-vigilcare-records-phase-6-verification.sh
+│ └── run-vigilcare-records-verification-p9.sh # E2E workflow + work queue overview smoke test
+└── docs/
+ ├── plans/ # Phase 1–9 implementation guides
+ ├── digitization-workstation-guide.md # Clerk workflow and UI reference
+ └── vigilcare-records-prd.md # Product requirements and phase roadmap
```
---
@@ -378,6 +300,7 @@ Approved live observations are never mutated or deleted. When a transcription er
### Prerequisites
- .NET 8 SDK
+- Node.js 20+ and npm (for the workstation UI)
- Docker and Docker Compose
### Start Infrastructure
@@ -407,6 +330,34 @@ On startup the application:
Swagger UI is available at `http://localhost:5217/swagger` in Development.
+### Run the Workstation UI
+
+With the API running:
+
+```bash
+cd vigilcare-records-web
+npm install
+npm run dev
+```
+
+Open `http://localhost:3028`. The Vite dev server proxies `/api` requests to the API on port 5217.
+
+| Username | Password | Default route |
+|---|---|---|
+| `intake1` | `password` | `/intake` — upload scans, assign entry clerks |
+| `entry1` | `password` | `/entry` — data entry queue and split-pane form |
+| `verifier1` | `password` | `/verification` — field-level verification |
+| `admin1` | `password` | `/dashboard` — supervisor queue overview |
+
+See [docs/digitization-workstation-guide.md](docs/digitization-workstation-guide.md) for the full clerk workflow.
+
+Production build:
+
+```bash
+cd vigilcare-records-web
+npm run build # output in dist/
+```
+
### Run Tests
```bash
@@ -434,6 +385,7 @@ With the API running (`dotnet run`) and Docker Compose up:
./scripts/run-vigilcare-records-phase-4-verification.sh # Phase 4 — approval, promotion, idempotency, patient dedup
./scripts/run-vigilcare-records-phase-5-verification.sh # Phase 5 — corrections, supersession, digitization history
./scripts/run-vigilcare-records-phase-6-verification.sh # Phase 6 — live capture, attestation, critical alerts
+./scripts/run-vigilcare-records-verification-p9.sh # E2E workflow + work queue overview smoke test
```
---
@@ -657,8 +609,40 @@ Error response:
| 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` |
+| GET | `/work-queue/overview` | Administrator | Aggregate metrics: status counts, average queue age, 24h reject rate, oldest pending verification |
-All work queue endpoints support pagination via `?page=1&pageSize=20`.
+All work queue endpoints support pagination via `?page=1&pageSize=20` (except `overview`).
+
+**GET `/work-queue/overview` response:**
+
+| Field | Type | Description |
+|---|---|---|
+| `statusCounts` | object | Batch count per status (all 8 statuses present) |
+| `averageTimeInQueueMinutes` | number | Average age of batches in `PENDING_VERIFICATION` |
+| `rejectRate` | number | Rejections / (rejections + verifications) over the last 24 hours (0.0–1.0) |
+| `oldestPendingVerificationMinutes` | number | Age of the oldest batch in `PENDING_VERIFICATION` |
+
+### Patient Registry
+
+| Method | Path | Auth | Description |
+|---|---|---|---|
+| GET | `/patients/search?q=` | Intake Clerk, Administrator | Search live patients by MRN or full name (min 2 characters); returns up to 20 matches |
+
+**Search result object:**
+
+| Field | Type | Description |
+|---|---|---|
+| `id` | Guid | Patient ID |
+| `fullName` | string | Patient full name |
+| `mrn` | string | Medical record number |
+
+### User Directory
+
+| Method | Path | Auth | Description |
+|---|---|---|---|
+| GET | `/users?role=` | Intake Clerk, Administrator | List active users; optional `role` filter (e.g. `DATA_ENTRY_CLERK`) |
+
+Used by the intake workstation assign-clerk dialog.
### Patient Digitization History
@@ -1034,7 +1018,7 @@ Response shape:
## Implemented Phases
-Six phases from the project roadmap are implemented and verified. Integration tests (`dotnet test`) and per-phase verification scripts cover Phases 1–6.
+Phases 1–6 are fully implemented and verified via integration tests and per-phase scripts. Phase 7 (workstation UI) and parts of Phase 8 (supervisor overview) are implemented.
| Phase | Feature | Status |
|---|---|---|
@@ -1044,6 +1028,6 @@ Six phases from the project roadmap are implemented and verified. Integration te
| 4 | Approval with three-person separation of duties (entry ≠ verifier ≠ approver), atomic promotion to live `patients`/`encounters`/`observations` tables in a single PostgreSQL transaction, PostgreSQL sequence-backed MRN generation (`VCR-NNNNNN`), patient dedup by name + DOB, encounter matching by patient + department + active status, transactional outbox (`observation.created` events), retroactive alert policy per batch, `Idempotency-Key` header with 24h TTL for safe retries, `PromotionTests` integration tests, Phase 4 verification script | Done |
| 5 | Correction batches via `supersedesBatchId`, supersession validation on create (`422`/`404`/`409`), append-only `live_observations` supersession flags (`is_superseded`, `superseded_by_batch_id`, `superseded_at`), correction promotion reuses original encounter and linked patient, `correction_uploaded`/`correction_promoted`/`superseded` audit events, `GET /patients/:id/digitization-history` with correction chain and per-batch audit trails, `CorrectionSupersessionTests` integration tests, Phase 5 verification script | Done |
| 6 | Track B live capture: `LiveCaptureController` with clinician-only endpoints, `AttestationService` (role + password re-confirm), synchronous promotion via `LiveCaptureService`, Redis-backed critical threshold evaluation, inline critical alerts + committed `ClinicalAlert` rows, `observation.recorded` and `alert.generated` outbox events, open-encounter + vitals outpatient workflow, `LiveCaptureIntegrationTests`, Phase 6 verification script | Done |
-| 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 |
+| 7 | Digitization workstation UI (`vigilcare-records-web`): Vue 3 + Pinia + Tailwind, role-based routing, split-pane scan viewer, draft entry with auto-save, verification checkboxes, patient search, assign-clerk dialog, presigned URL refresh, supervisor dashboard shell | Done |
+| 8 | `GET /work-queue/overview`, supervisor dashboard UI, patient search API, user directory API | Partial — Prometheus metrics and promotion retry job planned |
+| 9 | E2E verification script (`run-vigilcare-records-verification-p9.sh`), clinical scenario documentation | Partial |
diff --git a/VigilCareRecordsAPI/Controllers/PatientsController.cs b/VigilCareRecordsAPI/Controllers/PatientsController.cs
index 8a007c1..1533a9f 100644
--- a/VigilCareRecordsAPI/Controllers/PatientsController.cs
+++ b/VigilCareRecordsAPI/Controllers/PatientsController.cs
@@ -1,9 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
-
///
-/// Patient-scoped endpoints for digitization history and audit trail.
+/// Patient registry search and digitization history.
///
[ApiController]
[Route("api/v1/patients")]
@@ -12,9 +11,27 @@ using Microsoft.AspNetCore.Mvc;
public class PatientsController : ControllerBase
{
private readonly IDigitizationHistoryService _history;
+ private readonly IPatientRegistryService _patients;
- public PatientsController(IDigitizationHistoryService history) =>
+ public PatientsController(
+ IDigitizationHistoryService history,
+ IPatientRegistryService patients)
+ {
_history = history;
+ _patients = patients;
+ }
+
+ ///
+ /// Searches live patients by MRN or full name (minimum 2 characters).
+ ///
+ [HttpGet("search")]
+ [Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task Search([FromQuery] string q)
+ {
+ var results = await _patients.SearchAsync(q ?? string.Empty);
+ return Ok(ApiResponse>.Ok(results));
+ }
///
/// Returns the complete digitization history for a patient, including all
@@ -29,4 +46,4 @@ public class PatientsController : ControllerBase
var history = await _history.GetPatientHistoryAsync(patientId);
return Ok(ApiResponse.Ok(history));
}
-}
\ No newline at end of file
+}
diff --git a/VigilCareRecordsAPI/Controllers/UsersController.cs b/VigilCareRecordsAPI/Controllers/UsersController.cs
new file mode 100644
index 0000000..40c6c20
--- /dev/null
+++ b/VigilCareRecordsAPI/Controllers/UsersController.cs
@@ -0,0 +1,32 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+///
+/// User directory for batch assignment and operational lookups.
+///
+[ApiController]
+[Route("api/v1/users")]
+[Produces("application/json")]
+[Authorize]
+public class UsersController : ControllerBase
+{
+ private readonly IUserDirectoryService _users;
+
+ public UsersController(IUserDirectoryService users) => _users = users;
+
+ ///
+ /// Lists active users, optionally filtered by role.
+ ///
+ [HttpGet]
+ [Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task List([FromQuery] string? role)
+ {
+ UserRole? parsedRole = null;
+ if (!string.IsNullOrWhiteSpace(role))
+ parsedRole = UserRoleExtensions.FromDbString(role);
+
+ var results = await _users.ListByRoleAsync(parsedRole);
+ return Ok(ApiResponse>.Ok(results));
+ }
+}
diff --git a/VigilCareRecordsAPI/Controllers/WorkQueueController.cs b/VigilCareRecordsAPI/Controllers/WorkQueueController.cs
index e0c315e..dc7365e 100644
--- a/VigilCareRecordsAPI/Controllers/WorkQueueController.cs
+++ b/VigilCareRecordsAPI/Controllers/WorkQueueController.cs
@@ -68,4 +68,17 @@ public class WorkQueueController : ControllerBase
var result = await _workQueue.GetClinicalApprovalQueueAsync(page, pageSize);
return Ok(ApiResponse.Ok(result));
}
+
+ ///
+ /// Returns aggregate work queue health metrics for the supervisor dashboard.
+ ///
+ [HttpGet("overview")]
+ [Authorize(Roles = "ADMINISTRATOR")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse
Task GetClinicalApprovalQueueAsync(int page, int pageSize);
+
+ ///
+ /// Returns aggregate work queue health metrics for the supervisor dashboard.
+ ///
+ Task GetOverviewAsync();
}
\ No newline at end of file
diff --git a/VigilCareRecordsAPI/Services/PatientRegistryService.cs b/VigilCareRecordsAPI/Services/PatientRegistryService.cs
new file mode 100644
index 0000000..efdfa19
--- /dev/null
+++ b/VigilCareRecordsAPI/Services/PatientRegistryService.cs
@@ -0,0 +1,27 @@
+using Microsoft.EntityFrameworkCore;
+
+public class PatientRegistryService : IPatientRegistryService
+{
+ private readonly AppDbContext _db;
+
+ public PatientRegistryService(AppDbContext db) => _db = db;
+
+ public async Task> SearchAsync(string query, int limit = 20)
+ {
+ var trimmed = query.Trim();
+ if (trimmed.Length < 2)
+ return Array.Empty();
+
+ var pattern = $"%{trimmed}%";
+
+ return await _db.Patients
+ .AsNoTracking()
+ .Where(p =>
+ EF.Functions.ILike(p.FullName, pattern) ||
+ EF.Functions.ILike(p.Mrn, pattern))
+ .OrderBy(p => p.FullName)
+ .Take(limit)
+ .Select(p => new PatientSearchResult(p.Id, p.FullName, p.Mrn))
+ .ToListAsync();
+ }
+}
diff --git a/VigilCareRecordsAPI/Services/UserDirectoryService.cs b/VigilCareRecordsAPI/Services/UserDirectoryService.cs
new file mode 100644
index 0000000..2cf51be
--- /dev/null
+++ b/VigilCareRecordsAPI/Services/UserDirectoryService.cs
@@ -0,0 +1,25 @@
+using Microsoft.EntityFrameworkCore;
+
+public class UserDirectoryService : IUserDirectoryService
+{
+ private readonly AppDbContext _db;
+
+ public UserDirectoryService(AppDbContext db) => _db = db;
+
+ public async Task> ListByRoleAsync(UserRole? role)
+ {
+ var query = _db.Users.AsNoTracking().Where(u => u.IsActive);
+
+ if (role.HasValue)
+ query = query.Where(u => u.Role == role.Value);
+
+ return await query
+ .OrderBy(u => u.FullName)
+ .Select(u => new UserSummaryResponse(
+ u.Id,
+ u.Username,
+ u.FullName,
+ u.Role.ToDbString()))
+ .ToListAsync();
+ }
+}
diff --git a/VigilCareRecordsAPI/Services/WorkQueueService.cs b/VigilCareRecordsAPI/Services/WorkQueueService.cs
index fe0ef4d..f021062 100644
--- a/VigilCareRecordsAPI/Services/WorkQueueService.cs
+++ b/VigilCareRecordsAPI/Services/WorkQueueService.cs
@@ -9,10 +9,12 @@ using Microsoft.EntityFrameworkCore;
public class WorkQueueService : IWorkQueueService
{
private readonly AppDbContext _db;
+ private readonly ILogger _logger;
- public WorkQueueService(AppDbContext db)
+ public WorkQueueService(AppDbContext db, ILogger logger)
{
_db = db;
+ _logger = logger;
}
public async Task GetVerificationQueueAsync(int page, int pageSize)
@@ -80,4 +82,77 @@ public class WorkQueueService : IWorkQueueService
return new WorkQueueResponse(queueName, items, page, pageSize, totalCount, totalPages);
}
+
+ public async Task GetOverviewAsync()
+ {
+ var now = DateTimeOffset.UtcNow;
+
+ var statusGroups = await _db.DigitizationBatches
+ .AsNoTracking()
+ .GroupBy(b => b.Status)
+ .Select(g => new { Status = g.Key, Count = g.Count() })
+ .ToListAsync();
+
+ var statusCounts = new Dictionary();
+ foreach (var status in Enum.GetValues())
+ {
+ var count = statusGroups.FirstOrDefault(g => g.Status == status)?.Count ?? 0;
+ statusCounts[status.ToDbString()] = count;
+ }
+
+ var pendingBatches = await _db.DigitizationBatches
+ .AsNoTracking()
+ .Where(b => b.Status == BatchStatus.PendingVerification)
+ .Select(b => b.UpdatedAt)
+ .ToListAsync();
+
+ double avgTimeInQueueMinutes = 0;
+ double oldestPendingMinutes = 0;
+
+ if (pendingBatches.Count > 0)
+ {
+ var ages = pendingBatches
+ .Select(updatedAt => (now - updatedAt).TotalMinutes)
+ .ToList();
+
+ avgTimeInQueueMinutes = ages.Average();
+ oldestPendingMinutes = ages.Max();
+ }
+
+ var cutoff = now.AddHours(-24);
+
+ var recentEvents = await _db.DigitizationEvents
+ .AsNoTracking()
+ .Where(e => e.OccurredAt >= cutoff)
+ .Where(e => e.EventType == DigitizationEventType.Rejected
+ || e.EventType == DigitizationEventType.Verified
+ || e.EventType == DigitizationEventType.VerifiedPendingClinical)
+ .GroupBy(e => e.EventType)
+ .Select(g => new { EventType = g.Key, Count = g.Count() })
+ .ToListAsync();
+
+ var rejections = recentEvents
+ .Where(e => e.EventType == DigitizationEventType.Rejected)
+ .Sum(e => e.Count);
+
+ var verifications = recentEvents
+ .Where(e => e.EventType == DigitizationEventType.Verified
+ || e.EventType == DigitizationEventType.VerifiedPendingClinical)
+ .Sum(e => e.Count);
+
+ var totalDecisions = rejections + verifications;
+ var rejectRate = totalDecisions > 0
+ ? (double)rejections / totalDecisions
+ : 0.0;
+
+ _logger.LogDebug(
+ "Work queue overview: {PendingCount} pending, avg queue {AvgMinutes:F1}m, reject rate {RejectRate:P1}",
+ pendingBatches.Count, avgTimeInQueueMinutes, rejectRate);
+
+ return new WorkQueueOverviewResponse(
+ StatusCounts: statusCounts,
+ AverageTimeInQueueMinutes: Math.Round(avgTimeInQueueMinutes, 1),
+ RejectRate: Math.Round(rejectRate, 4),
+ OldestPendingVerificationMinutes: Math.Round(oldestPendingMinutes, 1));
+ }
}
\ No newline at end of file
diff --git a/docs/digitization-workstation-guide.md b/docs/digitization-workstation-guide.md
new file mode 100644
index 0000000..27053b2
--- /dev/null
+++ b/docs/digitization-workstation-guide.md
@@ -0,0 +1,205 @@
+```markdown
+# VigilCare Records — Digitization Workstation Guide
+
+## Overview
+
+This guide describes three primary clinical workflows supported by the VigilCare
+Records digitization platform: historical backfill, live bedside capture, and
+corrections. Each workflow maps to a real operational scenario in a paper-based
+hospital or clinic.
+
+---
+
+## Workflow 1: Historical Backfill (Track A)
+
+**Scenario:** District General Hospital has 200 active patients with paper charts.
+The facility is deploying VigilCareClinical for real-time alerting. Before alerts
+can fire, historical vital signs and lab results must be digitized into the system.
+
+**Actors:** Intake Clerk, Data Entry Clerk, Verifier, Clinical Approver (for
+high-stakes batch types)
+
+**Steps:**
+
+### 1. Intake (Intake Clerk)
+
+The intake clerk receives a stack of patient charts from the ward. For each chart
+section:
+
+1. Scan the page(s) using a flatbed scanner or document camera
+2. Log in to the workstation as `intake1`
+3. Navigate to **Intake** view
+4. Upload the scan (PDF, JPEG, or PNG; max 25 MB)
+5. Select the batch type:
+ - **Vitals Sheet** — vital signs from nursing observation charts
+ - **Lab Results** — laboratory test result reports
+ - **Patient Registration** — face sheet with demographics
+ - **Encounter Summary** — admission/discharge summaries
+ - **Allergy Update** — allergy documentation
+ - **Medication List** — current medication records
+6. Set track to **Backfill** (default)
+7. Optionally link to an existing patient by searching MRN or name
+8. Click **Upload and Create Batch**
+
+The batch is created in `uploaded` status. The SHA-256 hash prevents duplicate
+uploads of the same document for the same patient within 24 hours.
+
+### 2. Data Entry (Data Entry Clerk)
+
+The entry clerk opens the batch from their **Entry** queue:
+
+1. Log in as `entry1`
+2. Click the batch to open the split-pane workstation
+3. **Left pane:** The scanned document is displayed with zoom, pan, and rotate
+ controls. The clerk reads the handwritten or printed values from the scan.
+4. **Right pane:** Structured form fields for:
+ - Patient demographics (name, DOB, sex, blood type, emergency contact)
+ - Encounter context (admission date, department, room/bed, admission reason)
+ - Observations (one row per vital sign or lab result)
+
+**Observation entry rules:**
+- Each observation requires a code (e.g., `HEART_RATE`), numeric value, unit, and
+ `recordedAt` timestamp taken from the chart (not scan time)
+- Plausibility validation fires on save:
+ - Heart rate: 20-300 bpm
+ - Temperature: 25-45 C
+ - SpO2: 0-100 %
+ - Potassium: 1.5-10.0 mEq/L
+ - Glucose: 20-800 mg/dL
+- Out-of-range values return `422 OBSERVATION_OUT_OF_PLAUSIBLE_RANGE`
+- This catches the most common digitization error: decimal misplacement (5.2 vs 52)
+
+5. Click **Submit for Verification** when all fields are complete
+6. The batch transitions to `pending_verification`
+
+### 3. Verification (Verifier)
+
+The verifier reviews the entry against the original scan:
+
+1. Log in as `verifier1` (must be a different person than the entry clerk)
+2. Open the batch from the **Verification** queue
+3. **Left pane:** Same scan viewer
+4. **Right pane:** Each field has a verification checkbox
+ - Compare each entered value against the scan
+ - Check the checkbox when the value matches
+ - Progress bar shows completion percentage
+5. If all fields match: click **Approve - Verified**
+6. If any field is wrong: click **Reject** with a mandatory reason
+
+**Separation of duties:** The system enforces that `enteredByUserId !== verifiedByUserId`.
+If the entry clerk tries to verify their own batch, the API returns `409
+SEPARATION_OF_DUTIES_VIOLATION`.
+
+**On rejection:** The batch returns to `rejected` status. The entry clerk sees the
+rejection reason and can correct the draft, then resubmit for verification.
+
+### 4. Approval and Promotion
+
+Track A has **two human gates** before live records exist:
+
+1. **Verifier** — scan comparison (`verify` / `reject` on `pending_verification`)
+2. **Clinical approver** — promotion authorization (`approve` on `verified` or `awaiting_clinical_approval`)
+
+After verification passes, site configuration routes the batch:
+
+| batchType | Default: next status after verify |
+|---|---|
+| `patient_registration`, `allergy_update` | `verified` → clinical approver calls `approve` |
+| `encounter_summary`, `vitals_sheet`, `lab_results`, `medication_list`, `mixed` | `awaiting_clinical_approval` → clinical approver reviews in **Clinical Approval** queue, then calls `approve` |
+
+Log in as `approver1` for the approval step. Verifiers (`verifier1`) never call `approve`.
+
+On approval:
+1. The promotion service runs atomically:
+ - Create or update Patient in VigilCareClinical
+ - Create or match Encounter
+ - Insert each DraftObservation as a live Observation
+ - Write outbox events (alerting suppressed for backfill unless
+ `enableRetroactiveAlerts: true`)
+2. Batch status transitions to `promoted`
+3. All observations are now visible in VigilCareClinical's ward dashboard
+
+**Alert suppression for backfill:** By default, backfilled observations do not
+trigger real-time alerts. A historical potassium of 6.2 mEq/L from three days ago
+should not page the on-call physician today. The facility can override this per
+batch by setting `enableRetroactiveAlerts: true`.
+
+---
+
+## Workflow 2: Live Bedside Capture (Track B)
+
+**Scenario:** A nurse or physician enters vital signs at the bedside using a tablet.
+The observation needs to reach VigilCareClinical's alert pipeline immediately.
+
+**Actor:** Clinician
+
+**Steps:**
+
+1. Log in as `clinician1`
+2. Navigate to the live capture endpoint
+3. Select or create the encounter
+4. Enter observation values with `clinicianAttestation: true`
+5. Confirm with password re-entry or PIN
+
+**What happens:**
+- A `DigitizationBatch` is created with `track: live_capture` and lands in `promoted` immediately (workflow states skipped, audit unit retained)
+- `DraftObservation` rows and `DigitizationEvent` entries (`live_capture_attested`, `promoted`) are written in the same transaction as live observations
+- No verification queue — the clinician's attestation replaces verify + approve for that batch only
+- The batch is promoted synchronously
+- If a critical value is entered (e.g., potassium 6.8 mEq/L), the synchronous
+ alert fires before the response returns
+- The observation appears in VigilCareClinical's ward dashboard immediately
+
+**When to use Track B vs Track A:**
+- Track B: Current patient encounter, values just measured, clinician is at bedside
+- Track A: Historical charts, bulk digitization, values from past encounters
+
+---
+
+## Workflow 3: Corrections
+
+**Scenario:** After promotion, a reviewer discovers that the SpO2 value was entered
+as 94% but the chart actually shows 95%. The promoted observation must be corrected.
+
+**Rule:** Approved records are never silently edited. Corrections go through the
+full pipeline.
+
+**Steps:**
+
+1. Create a new batch with `supersedesBatchId` pointing to the original batch
+2. Re-enter the corrected values
+3. Submit for verification (new entry, new verifier review)
+4. On approval and promotion:
+ - The corrected observations are inserted as new live records
+ - The original observations are marked `superseded` (soft flag, not deleted)
+ - The audit trail shows: original values, correction request, new values,
+ who made each change and when
+
+**Why not just edit the original?** Clinical audit integrity. A regulator must be
+able to see what was originally entered, when it was corrected, and by whom. Silent
+edits destroy this chain.
+
+---
+
+## Staffing Considerations
+
+- In a small facility, one person may serve as both intake clerk and data entry clerk
+- The system **never** allows one person to both enter and verify the same batch,
+ even if they hold both roles
+- Minimum staff for full workflow: 2 people (one enters, one verifies)
+- The system tracks clerk throughput via the work queue overview endpoint and
+ Prometheus metrics
+
+---
+
+## Common Issues and Resolution
+
+| Issue | Cause | Resolution |
+|---|---|---|
+| `409 DUPLICATE_DOCUMENT` | Same PDF uploaded for same patient within 24h | Check if batch already exists; use a different scan if needed |
+| `409 SEPARATION_OF_DUTIES_VIOLATION` | Entry clerk trying to verify own batch | Assign to a different verifier |
+| `422 OBSERVATION_OUT_OF_PLAUSIBLE_RANGE` | Value outside allowed range | Check for decimal misplacement (52 vs 5.2) |
+| `409 ILLEGAL_STATUS_TRANSITION` | Trying to skip a workflow step | Follow the status machine: uploaded -> in_entry -> pending_verification -> verified -> approved -> promoted |
+| High rejection rate (>15%) | Scan quality or training issues | Review rejection reasons; improve scanner resolution or provide entry clerk training |
+| Batch stuck in `approved` | VigilCareClinical unreachable | Promotion retry worker handles this automatically with exponential backoff |
+```
\ No newline at end of file
diff --git a/scripts/run-vigilcare-records-verification-p9.sh b/scripts/run-vigilcare-records-verification-p9.sh
new file mode 100644
index 0000000..cd3df94
--- /dev/null
+++ b/scripts/run-vigilcare-records-verification-p9.sh
@@ -0,0 +1,477 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# VigilCare Records — End-to-End Verification Script
+# Tests the full digitization workflow: upload → entry → verify → approve → promote
+#
+# Prerequisites:
+# - API running on http://localhost:5217
+# - docker compose up -d (PostgreSQL, Redis, MinIO, Seq)
+# - Seed data applied (dotnet run applies seed on startup)
+# - A test PDF file at ./scripts/test-scan.pdf (create with: echo "test" | enscript -o - | ps2pdf - scripts/test-scan.pdf)
+#
+# Usage:
+# chmod +x scripts/run-vigilcare-records-verification.sh
+# ./scripts/run-vigilcare-records-verification.sh
+
+API="http://localhost:5217/api/v1"
+PASS=0
+FAIL=0
+TEST_FILE="${1:-scripts/test-scan.pdf}"
+
+# Colors for terminal output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+log_pass() { PASS=$((PASS + 1)); echo -e "${GREEN} PASS${NC} $1"; }
+log_fail() { FAIL=$((FAIL + 1)); echo -e "${RED} FAIL${NC} $1"; }
+log_step() { echo -e "\n${YELLOW}── $1 ──${NC}"; }
+
+# Create a minimal test PDF if it doesn't exist
+if [ ! -f "$TEST_FILE" ]; then
+ echo "Creating test PDF at $TEST_FILE..."
+ mkdir -p "$(dirname "$TEST_FILE")"
+ echo "%PDF-1.0
+1 0 obj<>endobj 2 0 obj<>endobj 3 0 obj<>>>endobj
+xref
+0 4
+0000000000 65535 f
+0000000009 00000 n
+0000000058 00000 n
+0000000115 00000 n
+trailer<>
+startxref
+206
+%%EOF" > "$TEST_FILE"
+fi
+
+# ============================================================================
+# Phase 1: Authentication
+# ============================================================================
+log_step "1. Authentication"
+
+# Login as intake clerk
+INTAKE_LOGIN=$(curl -s -X POST "$API/auth/login" \
+ -H "Content-Type: application/json" \
+ -d '{"username":"intake1","password":"password"}')
+INTAKE_TOKEN=$(echo "$INTAKE_LOGIN" | jq -r '.data.token')
+INTAKE_REFRESH=$(echo "$INTAKE_LOGIN" | jq -r '.data.refreshToken')
+
+if [ "$INTAKE_TOKEN" != "null" ] && [ -n "$INTAKE_TOKEN" ]; then
+ log_pass "Intake clerk login returned JWT"
+else
+ log_fail "Intake clerk login failed"
+ exit 1
+fi
+
+if [ "$INTAKE_REFRESH" != "null" ] && [ -n "$INTAKE_REFRESH" ]; then
+ log_pass "Intake clerk login returned refresh token"
+else
+ log_fail "Intake clerk login missing refresh token"
+fi
+
+# Login as entry clerk
+ENTRY_TOKEN=$(curl -s -X POST "$API/auth/login" \
+ -H "Content-Type: application/json" \
+ -d '{"username":"entry1","password":"password"}' | jq -r '.data.token')
+
+if [ "$ENTRY_TOKEN" != "null" ] && [ -n "$ENTRY_TOKEN" ]; then
+ log_pass "Entry clerk login returned JWT"
+else
+ log_fail "Entry clerk login failed"
+fi
+
+# Login as verifier (must be different from entry clerk for separation of duties)
+VERIFIER_TOKEN=$(curl -s -X POST "$API/auth/login" \
+ -H "Content-Type: application/json" \
+ -d '{"username":"verifier1","password":"password"}' | jq -r '.data.token')
+
+if [ "$VERIFIER_TOKEN" != "null" ] && [ -n "$VERIFIER_TOKEN" ]; then
+ log_pass "Verifier login returned JWT"
+else
+ log_fail "Verifier login failed"
+fi
+
+# Login as approver
+APPROVER_TOKEN=$(curl -s -X POST "$API/auth/login" \
+ -H "Content-Type: application/json" \
+ -d '{"username":"approver1","password":"password"}' | jq -r '.data.token')
+
+if [ "$APPROVER_TOKEN" != "null" ] && [ -n "$APPROVER_TOKEN" ]; then
+ log_pass "Approver login returned JWT"
+else
+ log_fail "Approver login failed"
+fi
+
+# Verify /me endpoint
+ME_ROLE=$(curl -s "$API/auth/me" \
+ -H "Authorization: Bearer $INTAKE_TOKEN" | jq -r '.data.role')
+
+if [ "$ME_ROLE" = "INTAKE_CLERK" ]; then
+ log_pass "/me returns correct role: INTAKE_CLERK"
+else
+ log_fail "/me returned unexpected role: $ME_ROLE"
+fi
+
+# Verify 401 without token
+HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$API/auth/me")
+if [ "$HTTP_CODE" = "401" ]; then
+ log_pass "Unauthenticated /me returns 401"
+else
+ log_fail "Unauthenticated /me returned $HTTP_CODE (expected 401)"
+fi
+
+# Refresh token rotation
+REFRESH_RESPONSE=$(curl -s -X POST "$API/auth/refresh" \
+ -H "Content-Type: application/json" \
+ -d "{\"refreshToken\":\"$INTAKE_REFRESH\"}")
+NEW_TOKEN=$(echo "$REFRESH_RESPONSE" | jq -r '.data.token')
+NEW_REFRESH=$(echo "$REFRESH_RESPONSE" | jq -r '.data.refreshToken')
+
+if [ "$NEW_TOKEN" != "null" ] && [ -n "$NEW_TOKEN" ] && [ "$NEW_REFRESH" != "$INTAKE_REFRESH" ]; then
+ log_pass "Refresh rotated tokens successfully"
+ INTAKE_TOKEN="$NEW_TOKEN"
+ INTAKE_REFRESH="$NEW_REFRESH"
+else
+ log_fail "Token refresh failed: $REFRESH_RESPONSE"
+fi
+
+# Logout revokes refresh token
+LOGOUT_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$API/auth/logout" \
+ -H "Content-Type: application/json" \
+ -d "{\"refreshToken\":\"$INTAKE_REFRESH\"}")
+if [ "$LOGOUT_CODE" = "204" ]; then
+ log_pass "Logout revoked refresh token (204)"
+else
+ log_fail "Logout returned $LOGOUT_CODE (expected 204)"
+fi
+
+# Re-login for subsequent test phases
+INTAKE_LOGIN=$(curl -s -X POST "$API/auth/login" \
+ -H "Content-Type: application/json" \
+ -d '{"username":"intake1","password":"password"}')
+INTAKE_TOKEN=$(echo "$INTAKE_LOGIN" | jq -r '.data.token')
+
+# ============================================================================
+# Phase 2: Upload and Batch Creation
+# ============================================================================
+log_step "2. Upload and Batch Creation"
+
+UPLOAD_RESPONSE=$(curl -s -X POST "$API/digitization-batches" \
+ -H "Authorization: Bearer $INTAKE_TOKEN" \
+ -F "file=@$TEST_FILE" \
+ -F "batchType=VITALS_SHEET" \
+ -F "track=BACKFILL")
+
+BATCH_ID=$(echo "$UPLOAD_RESPONSE" | jq -r '.data.id')
+BATCH_STATUS=$(echo "$UPLOAD_RESPONSE" | jq -r '.data.status')
+
+if [ "$BATCH_ID" != "null" ] && [ -n "$BATCH_ID" ]; then
+ log_pass "Batch created: $BATCH_ID"
+else
+ log_fail "Batch creation failed: $UPLOAD_RESPONSE"
+ exit 1
+fi
+
+if [ "$BATCH_STATUS" = "UPLOADED" ]; then
+ log_pass "Initial status is UPLOADED"
+else
+ log_fail "Initial status is $BATCH_STATUS (expected UPLOADED)"
+fi
+
+# Verify presigned URL
+DOC_URL=$(curl -s "$API/digitization-batches/$BATCH_ID" \
+ -H "Authorization: Bearer $INTAKE_TOKEN" | jq -r '.data.documentUrl')
+
+if [ "$DOC_URL" != "null" ] && [ -n "$DOC_URL" ]; then
+ log_pass "Presigned URL returned for document"
+else
+ log_fail "No presigned URL returned"
+fi
+
+# ============================================================================
+# Phase 3: Assign and Begin Entry
+# ============================================================================
+log_step "3. Assign and Begin Entry"
+
+# Get entry clerk user ID
+ENTRY_USER_ID=$(curl -s "$API/auth/me" \
+ -H "Authorization: Bearer $ENTRY_TOKEN" | jq -r '.data.id')
+
+# Assign batch to entry clerk
+ASSIGN_RESPONSE=$(curl -s -X PATCH "$API/digitization-batches/$BATCH_ID/assign" \
+ -H "Authorization: Bearer $INTAKE_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "{\"entryClerkUserId\":\"$ENTRY_USER_ID\"}")
+
+ASSIGN_SUCCESS=$(echo "$ASSIGN_RESPONSE" | jq -r '.success')
+if [ "$ASSIGN_SUCCESS" = "true" ]; then
+ log_pass "Batch assigned to entry clerk"
+else
+ log_fail "Batch assignment failed: $(echo "$ASSIGN_RESPONSE" | jq -r '.error.message')"
+fi
+
+# ============================================================================
+# Phase 4: Draft Data Entry
+# ============================================================================
+log_step "4. Draft Data Entry"
+
+# Save draft patient
+PATIENT_RESPONSE=$(curl -s -X PUT "$API/digitization-batches/$BATCH_ID/draft/patient" \
+ -H "Authorization: Bearer $ENTRY_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "fullName": "E2E Test Patient",
+ "dateOfBirth": "1985-06-15",
+ "sex": "male",
+ "bloodType": "A+",
+ "emergencyContact": "Test Contact - 555-9999",
+ "noKnownAllergies": true
+ }')
+
+if [ "$(echo "$PATIENT_RESPONSE" | jq -r '.success')" = "true" ]; then
+ log_pass "Draft patient saved"
+else
+ log_fail "Draft patient save failed: $(echo "$PATIENT_RESPONSE" | jq -r '.error.message')"
+fi
+
+# Save draft encounter
+ENCOUNTER_RESPONSE=$(curl -s -X PUT "$API/digitization-batches/$BATCH_ID/draft/encounter" \
+ -H "Authorization: Bearer $ENTRY_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "admissionDate": "2026-06-20T08:00:00Z",
+ "department": "Emergency Department",
+ "roomBed": "ED-12",
+ "admissionReason": "Chest pain, rule out MI"
+ }')
+
+if [ "$(echo "$ENCOUNTER_RESPONSE" | jq -r '.success')" = "true" ]; then
+ log_pass "Draft encounter saved"
+else
+ log_fail "Draft encounter save failed"
+fi
+
+# Add observations
+for OBS in \
+ '{"observationCode":"HEART_RATE","value":95,"unit":"bpm","recordedAt":"2026-06-20T08:15:00Z"}' \
+ '{"observationCode":"TEMP_C","value":37.1,"unit":"C","recordedAt":"2026-06-20T08:15:00Z"}' \
+ '{"observationCode":"BP_SYSTOLIC","value":142,"unit":"mmHg","recordedAt":"2026-06-20T08:15:00Z"}' \
+ '{"observationCode":"BP_DIASTOLIC","value":88,"unit":"mmHg","recordedAt":"2026-06-20T08:15:00Z"}' \
+ '{"observationCode":"RESP_RATE","value":20,"unit":"breaths/min","recordedAt":"2026-06-20T08:15:00Z"}' \
+ '{"observationCode":"SPO2","value":97,"unit":"%","recordedAt":"2026-06-20T08:15:00Z"}'
+do
+ OBS_RESULT=$(curl -s -X POST "$API/digitization-batches/$BATCH_ID/draft/observations" \
+ -H "Authorization: Bearer $ENTRY_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "$OBS")
+ OBS_CODE=$(echo "$OBS" | jq -r '.observationCode')
+ if [ "$(echo "$OBS_RESULT" | jq -r '.success')" = "true" ]; then
+ log_pass "Observation $OBS_CODE added"
+ else
+ log_fail "Observation $OBS_CODE failed: $(echo "$OBS_RESULT" | jq -r '.error.message')"
+ fi
+done
+
+# Verify draft is complete
+DRAFT_RESPONSE=$(curl -s "$API/digitization-batches/$BATCH_ID/draft" \
+ -H "Authorization: Bearer $ENTRY_TOKEN")
+
+OBS_COUNT=$(echo "$DRAFT_RESPONSE" | jq -r '.data.observations | length')
+if [ "$OBS_COUNT" = "6" ]; then
+ log_pass "Draft has 6 observations"
+else
+ log_fail "Draft has $OBS_COUNT observations (expected 6)"
+fi
+
+# Submit for verification
+SUBMIT_RESPONSE=$(curl -s -X POST "$API/digitization-batches/$BATCH_ID/submit-for-verification" \
+ -H "Authorization: Bearer $ENTRY_TOKEN")
+
+if [ "$(echo "$SUBMIT_RESPONSE" | jq -r '.success')" = "true" ]; then
+ log_pass "Submitted for verification"
+else
+ log_fail "Submit failed: $(echo "$SUBMIT_RESPONSE" | jq -r '.error.message')"
+fi
+
+# Verify status is now PENDING_VERIFICATION
+CURRENT_STATUS=$(curl -s "$API/digitization-batches/$BATCH_ID" \
+ -H "Authorization: Bearer $ENTRY_TOKEN" | jq -r '.data.batch.status')
+
+if [ "$CURRENT_STATUS" = "PENDING_VERIFICATION" ]; then
+ log_pass "Status is PENDING_VERIFICATION"
+else
+ log_fail "Status is $CURRENT_STATUS (expected PENDING_VERIFICATION)"
+fi
+
+# ============================================================================
+# Phase 5: Separation of Duties Check
+# ============================================================================
+log_step "5. Separation of Duties"
+
+# Entry clerk tries to verify their own batch — should get 409
+SOD_RESPONSE=$(curl -s -X POST "$API/digitization-batches/$BATCH_ID/verify" \
+ -H "Authorization: Bearer $ENTRY_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"fieldChecks":[],"passed":true}')
+
+SOD_CODE=$(echo "$SOD_RESPONSE" | jq -r '.error.code')
+if [ "$SOD_CODE" = "SEPARATION_OF_DUTIES_VIOLATION" ]; then
+ log_pass "Entry clerk cannot verify own batch (409 SEPARATION_OF_DUTIES_VIOLATION)"
+else
+ log_fail "Separation of duties not enforced: $SOD_CODE"
+fi
+
+# ============================================================================
+# Phase 6: Verification
+# ============================================================================
+log_step "6. Verification (by different user)"
+
+VERIFY_RESPONSE=$(curl -s -X POST "$API/digitization-batches/$BATCH_ID/verify" \
+ -H "Authorization: Bearer $VERIFIER_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "fieldChecks": [
+ {"fieldPath": "patient.fullName", "passed": true},
+ {"fieldPath": "patient.dateOfBirth", "passed": true},
+ {"fieldPath": "observations[0].value", "passed": true},
+ {"fieldPath": "observations[1].value", "passed": true},
+ {"fieldPath": "observations[2].value", "passed": true},
+ {"fieldPath": "observations[3].value", "passed": true},
+ {"fieldPath": "observations[4].value", "passed": true},
+ {"fieldPath": "observations[5].value", "passed": true}
+ ],
+ "passed": true
+ }')
+
+if [ "$(echo "$VERIFY_RESPONSE" | jq -r '.success')" = "true" ]; then
+ log_pass "Verification passed"
+else
+ log_fail "Verification failed: $(echo "$VERIFY_RESPONSE" | jq -r '.error.message')"
+fi
+
+# Check status
+VERIFIED_STATUS=$(curl -s "$API/digitization-batches/$BATCH_ID" \
+ -H "Authorization: Bearer $VERIFIER_TOKEN" | jq -r '.data.batch.status')
+
+if [ "$VERIFIED_STATUS" = "VERIFIED" ] || [ "$VERIFIED_STATUS" = "AWAITING_CLINICAL_APPROVAL" ]; then
+ log_pass "Status after verification: $VERIFIED_STATUS"
+else
+ log_fail "Status after verification: $VERIFIED_STATUS (expected VERIFIED or AWAITING_CLINICAL_APPROVAL)"
+fi
+
+# ============================================================================
+# Phase 7: Approval and Promotion
+# ============================================================================
+log_step "7. Approval and Promotion"
+
+APPROVE_RESPONSE=$(curl -s -X POST "$API/digitization-batches/$BATCH_ID/approve" \
+ -H "Authorization: Bearer $APPROVER_TOKEN" \
+ -H "Content-Type: application/json" \
+ -H "Idempotency-Key: e2e-test-$(date +%s)")
+
+if [ "$(echo "$APPROVE_RESPONSE" | jq -r '.success')" = "true" ]; then
+ log_pass "Batch approved and promoted"
+else
+ log_fail "Approval failed: $(echo "$APPROVE_RESPONSE" | jq -r '.error.message')"
+fi
+
+# Verify final status is PROMOTED
+FINAL_STATUS=$(curl -s "$API/digitization-batches/$BATCH_ID" \
+ -H "Authorization: Bearer $APPROVER_TOKEN" | jq -r '.data.batch.status')
+
+if [ "$FINAL_STATUS" = "PROMOTED" ]; then
+ log_pass "Final status is PROMOTED"
+else
+ log_fail "Final status is $FINAL_STATUS (expected PROMOTED)"
+fi
+
+# Verify promotedAt is set
+PROMOTED_AT=$(curl -s "$API/digitization-batches/$BATCH_ID" \
+ -H "Authorization: Bearer $APPROVER_TOKEN" | jq -r '.data.batch.promotedAt')
+
+if [ "$PROMOTED_AT" != "null" ] && [ -n "$PROMOTED_AT" ]; then
+ log_pass "promotedAt timestamp set: $PROMOTED_AT"
+else
+ log_fail "promotedAt is null"
+fi
+
+# ============================================================================
+# Phase 8: Illegal Transition Check
+# ============================================================================
+log_step "8. Illegal Transitions"
+
+# Try to re-approve a promoted batch — should get 409
+ILLEGAL_RESPONSE=$(curl -s -X POST "$API/digitization-batches/$BATCH_ID/approve" \
+ -H "Authorization: Bearer $APPROVER_TOKEN" \
+ -H "Content-Type: application/json")
+
+ILLEGAL_CODE=$(echo "$ILLEGAL_RESPONSE" | jq -r '.error.code')
+if [ "$ILLEGAL_CODE" = "ILLEGAL_STATUS_TRANSITION" ]; then
+ log_pass "Cannot approve promoted batch (409 ILLEGAL_STATUS_TRANSITION)"
+else
+ log_fail "Illegal transition not blocked: $ILLEGAL_CODE"
+fi
+
+# ============================================================================
+# Phase 9: Work Queue Overview
+# ============================================================================
+log_step "9. Work Queue Overview"
+
+OVERVIEW_RESPONSE=$(curl -s "$API/work-queue/overview" \
+ -H "Authorization: Bearer $APPROVER_TOKEN")
+
+OVERVIEW_SUCCESS=$(echo "$OVERVIEW_RESPONSE" | jq -r '.success')
+if [ "$OVERVIEW_SUCCESS" = "true" ]; then
+ log_pass "Work queue overview returned successfully"
+ echo " Status counts: $(echo "$OVERVIEW_RESPONSE" | jq -c '.data.statusCounts')"
+ echo " Reject rate: $(echo "$OVERVIEW_RESPONSE" | jq -r '.data.rejectRate')"
+else
+ log_fail "Work queue overview failed"
+fi
+
+# ============================================================================
+# Phase 10: Metrics Endpoint
+# ============================================================================
+log_step "10. Prometheus Metrics"
+
+METRICS=$(curl -s http://localhost:5217/metrics)
+
+if echo "$METRICS" | grep -q "digitization_batches_by_status"; then
+ log_pass "digitization_batches_by_status metric present"
+else
+ log_fail "digitization_batches_by_status metric missing"
+fi
+
+if echo "$METRICS" | grep -q "digitization_promotion_duration_seconds"; then
+ log_pass "digitization_promotion_duration_seconds metric present"
+else
+ log_fail "digitization_promotion_duration_seconds metric missing"
+fi
+
+if echo "$METRICS" | grep -q "digitization_rejection_total"; then
+ log_pass "digitization_rejection_total metric present"
+else
+ log_fail "digitization_rejection_total metric missing"
+fi
+
+# ============================================================================
+# Summary
+# ============================================================================
+echo ""
+echo "============================================"
+echo " E2E Verification Complete"
+echo "============================================"
+echo -e " ${GREEN}Passed: $PASS${NC}"
+echo -e " ${RED}Failed: $FAIL${NC}"
+echo ""
+
+if [ "$FAIL" -eq 0 ]; then
+ echo -e " ${GREEN}ALL TESTS PASSED${NC}"
+ exit 0
+else
+ echo -e " ${RED}$FAIL TEST(S) FAILED${NC}"
+ exit 1
+fi
\ No newline at end of file
diff --git a/vigilcare-records-web/.gitignore b/vigilcare-records-web/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/vigilcare-records-web/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/vigilcare-records-web/.vscode/extensions.json b/vigilcare-records-web/.vscode/extensions.json
new file mode 100644
index 0000000..a7cea0b
--- /dev/null
+++ b/vigilcare-records-web/.vscode/extensions.json
@@ -0,0 +1,3 @@
+{
+ "recommendations": ["Vue.volar"]
+}
diff --git a/vigilcare-records-web/README.md b/vigilcare-records-web/README.md
new file mode 100644
index 0000000..33895ab
--- /dev/null
+++ b/vigilcare-records-web/README.md
@@ -0,0 +1,5 @@
+# Vue 3 + TypeScript + Vite
+
+This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `
+