Files
vigilcare-records/README.md
T

1222 lines
75 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VigilCare Records
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:** Phases 110 are complete. Phase 7 delivers the digitization workstation UI (`vigilcare-records-web`) with role-specific views for intake, entry, verification, clinical approval, live capture, patient history, and supervisor dashboard. Phase 8 adds Prometheus metrics, supervisor work-queue overview, cursor-paginated batch audit trails, promotion deferral with exponential backoff retry, and a Docker monitoring stack (Prometheus on **9095**, Grafana on **3013**). Phase 9 adds extended demo seed data (10 batches across all types, tracks, and statuses), the E2E verification script (`scripts/run-vigilcare-records-verification-p9.sh`), and clinical scenario documentation ([digitization-workstation-guide.md](docs/digitization-workstation-guide.md)). Phase 10 adds barcode/QR cover sheets for high-volume backfill: generate printable cover pages with encoded batch type, track, optional patient, and optional entry-clerk pre-assignment; barcode-assisted upload auto-creates batches and skips manual classification. Post-phase hardening includes health check endpoints, user management APIs, auth rate limiting, document access audit events, batch cancellation, list/queue sorting (`sortBy`/`sortDirection`), unified promotion retry logic, normalized patient deduplication, and assignment-time `IN_ENTRY` transitions. Phase 11 (HL7 FHIR R4 read API) is planned next — see [docs/plans/phase-11-plan.md](docs/plans/phase-11-plan.md). See [Implemented Phases](#implemented-phases) and the [gap analysis](docs/vigilcare-records-gap-analysis.md) for remaining work.
## 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; optional `supersedesBatchId` creates a correction batch linked to a promoted batch; batch created in `UPLOADED` status with `DigitizationEvent` audit trail; optional `coverSheetCode` looks up a cover sheet barcode, applies encoded batch type/track/patient, redeems the cover sheet on success (`409 COVER_SHEET_ALREADY_USED` on reuse), and auto-assigns the batch when the cover sheet has `assignToUserId` set
- **Cover Sheet System** — `POST /cover-sheets/generate` creates 1100 cover sheets with unique `VCR-CS-{8-hex}` codes encoding batch type, track, optional patient, and optional entry-clerk pre-assignment; `GET /cover-sheets/lookup/{code}` resolves a barcode for intake auto-fill; `GET /cover-sheets` lists sheets with `isUsed`/`patientId` filters; `POST /cover-sheets/{id}/pdf` and `POST /cover-sheets/batch-pdf` produce printable PDFs with QR codes (QRCoder); cover sheets are single-use and linked to the batch they create via `batchId`
- **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; transitions `UPLOADED → IN_ENTRY` immediately and writes an `entry_started` audit event; only `UPLOADED` batches can be assigned; `409 BATCH_ALREADY_ASSIGNED` on conflict
- **Batch Cancellation** — `POST /digitization-batches/:id/cancel` (administrator only) permanently cancels batches in `UPLOADED`, `IN_ENTRY`, or `REJECTED` status with a mandatory reason (min 5 characters); releases the Redis assignment lock; `CANCELLED` is terminal
- **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); `DraftService` retains a fallback `UPLOADED`/`REJECTED → `IN_ENTRY` transition when entry begins without prior assignment; draft save requires the acting user to match `enteredByUserId` or hold `Administrator` role (`409 BATCH_NOT_ASSIGNED`)
- **Submit for Verification** — validates completeness per batch type (e.g. vitals sheet requires linked patient, encounter context, and at least one observation with `recordedAt`); transitions `IN_ENTRY → PENDING_VERIFICATION`; returns `422` with missing fields if incomplete
- **Verification and Rejection** — verifier reviews entry against the scan with field-level checks (`fieldName`, `status: ok|warning|error`, optional `note`); verify pass transitions to `VERIFIED` or `AWAITING_CLINICAL_APPROVAL` based on site configuration for the batch type; verify fail transitions to `REJECTED` with mandatory reason; **separation of duties** enforced: entry clerk cannot verify their own batch (`409 SEPARATION_OF_DUTIES_VIOLATION`)
- **Clinical Approval Routing** — site-configurable per batch type (`SiteConfig.ClinicalApprovalRequired`); high-stakes batch types (encounter summary, vitals sheet, lab results, medication list, mixed) route to `AWAITING_CLINICAL_APPROVAL` after verification for physician sign-off; low-stakes types (patient registration, allergy update) go directly to `VERIFIED`
- **Approval and Promotion** — `POST /digitization-batches/:id/approve` (on `ApprovalController` only) atomically promotes draft data to live VigilCareClinical tables (`patients`, `encounters`, `observations`) in a single PostgreSQL transaction via shared `ExecutePromotionCoreAsync`; generates MRN via PostgreSQL sequence (`VCR-000001`); patient deduplication by case-insensitive normalized name + DOB with fuzzy-match warnings (Levenshtein distance ≤ 3) logged when a near-duplicate exists; encounter matching by patient + department + active status; writes `observation.created` outbox events for downstream consumers; separation of duties enforced (approver cannot be entry clerk or verifier); `Idempotency-Key` header required for safe retries with 24-hour TTL; retroactive alert policy (`enableRetroactiveAlerts`) controls whether backfill observations emit outbox events; on transient infrastructure failure returns **202** with `PROMOTION_DEFERRED` — batch stays `APPROVED` and `PromotionRetryService` retries via `POST /digitization-batches/:id/promote` using the same core promotion path
- **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
- **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`); `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); all queue and batch list endpoints support `sortBy` and `sortDirection`; role-restricted access
- **Batch Audit Trail API** — `GET /digitization-batches/:id/events` returns cursor-paginated digitization events with actor username and full name; accessible by administrator, verifier, and clinical approver roles
- **User Directory and Management** — `GET /users?role=` lists active users for batch assignment; administrators can `POST /users` (create), `PATCH /users/:id` (update name, role, active flag), `POST /users/:id/reset-password`, and any authenticated user can `POST /users/me/change-password` with current-password verification
- **Document Access Audit** — `GET /digitization-batches/:id` writes a `document_accessed` digitization event (deduplicated per user within 5 minutes) whenever a presigned scan URL is issued
- **Digitization Workstation UI** — Vue 3 SPA at `vigilcare-records-web`: role-based routing and nav (intake, cover sheets, entry, verification, clinical approval, live capture, patient history, supervisor dashboard); split-pane scan viewer with zoom/pan/rotate; cover sheet management view (generate, list, print PDF); barcode-assisted intake (scan/type cover sheet code to auto-fill batch type, track, patient, then upload); draft entry with auto-save and batch-type-aware fields (allergies, medications, discharge diagnosis); field-level verification checkboxes; clinical approval queue with scan review, approve/reject, and retroactive alert toggle; live capture form for new or existing encounters with attestation and password confirm; patient history timeline with correction chain and audit trail; presigned URL refresh for long sessions; JWT refresh interceptor and toast notifications
- **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` and `CANCELLED` are 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; login and refresh rate-limited to 10 requests per 5 minutes per client (`429`)
- **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; Prometheus metrics at `GET /metrics` (HTTP request histograms, .NET runtime stats, custom gauges for batch counts by status and queue age, promotion duration histogram, rejection counter by reason category); `MetricsCollectorService` refreshes DB-backed gauges every 30 seconds; Prometheus scrapes the API via `prometheus.yml`; Grafana available at `http://localhost:3013` (dashboard panels configured manually); health probes at `GET /health/live`, `GET /health/ready` (PostgreSQL, Redis, MinIO), and `GET /health/startup`
- **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)
├── PromotionService (approve + atomic promote to live tables, patient dedup, encounter matching, outbox events, supersession on correction promotion)
├── DigitizationHistoryService (patient digitization history with correction chain and audit trails)
├── IdempotencyService (Idempotency-Key storage + replay for safe promotion retries)
├── MrnGenerator (PostgreSQL sequence-backed MRN generation: VCR-000001)
├── WorkQueueService (verification, entry, clinical approval queues, supervisor overview metrics)
├── BatchEventService (cursor-paginated batch audit trail)
├── MetricsCollectorService (periodic DB gauge refresh for Prometheus)
├── PromotionRetryService (exponential backoff retry for deferred promotions)
├── PatientRegistryService (live patient search by MRN or name)
├── UserDirectoryService (active user listing, create/update/deactivate, password reset/change)
├── AttestationService (clinician role + password re-confirm for live capture)
├── LiveCaptureService (Track B synchronous promotion + critical alert evaluation)
├── CoverSheetService (generate, lookup, redeem, list cover sheets)
├── CoverSheetPdfGenerator (printable PDF with QR codes)
├── DocumentStorageService (MinIO upload, SHA-256, presigned URLs)
├── PlausibilityValidator (per-code numeric range guard)
├── PostgreSQL (EF Core — writes, keyed reads, clinical schema for live tables)
├── Redis (batch assignment locks)
├── MinIO (scanned document storage)
└── HealthChecks (PostgreSQL, Redis, MinioHealthCheck)
```
**Relationship to VigilCareClinical:** VigilCare Records is the precursor that creates the fuel — patient identity, encounters, and observations — in a governed, auditable way. Draft observations are invisible to VigilCareClinical's alert engine, Elasticsearch ward projection, and scoring consumers. Only batches that pass approval are promoted to live tables. Promotion writes directly to VigilCareClinical's `patients`, `encounters`, `observations`, and `outbox_events` tables in a single atomic transaction with idempotency protection.
```
┌─────────────────────────────────────────────────────────────────────┐
│ VigilCare Records (this project) │
│ │
│ Scan → Entry → Verify → Approve → Promote │
│ ↓ │
│ Draft tables (never alert) │
│ ↓ on approval │
│ PromotionService (atomic txn + idempotency) ────────────────────┐ │
└──────────────────────────────────────────────────────────────────│──┘
┌──────────────────────────────────────────────────────────────────▼──┐
│ VigilCareClinicalAPI │
│ │
│ Patient → Encounter → Observation → Outbox → Kafka → Alerts │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Tech Stack
| 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) |
| Authentication | JWT Bearer (Microsoft.AspNetCore.Authentication.JwtBearer) |
| Password hashing | BCrypt.Net-Next |
| Logging | Serilog + Seq sink |
| Metrics | Prometheus (`prometheus-net`) + Grafana |
| Docs | Swagger / OpenAPI (Swashbuckle) |
| Barcode / PDF | QRCoder (cover sheet QR codes; raw PDF generation) |
| Testing | xUnit + FluentAssertions + WebApplicationFactory |
---
## Project Structure
```
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, promotion deferral (202), promotion to live tables
│ │ ├── AuthController.cs # JWT login, token refresh, logout, authenticated user profile
│ │ ├── CoverSheetController.cs # Cover sheet generate, lookup, list, PDF export
│ │ ├── DigitizationBatchesController.cs # Batch CRUD, document upload, assignment, events, promote
│ │ ├── DraftController.cs # Draft data entry: patient, encounter, observations, submit
│ │ ├── PatientsController.cs # Patient search and digitization history
│ │ ├── UsersController.cs # User directory and admin user management
│ │ ├── 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
│ ├── HealthChecks/MinioHealthCheck.cs # MinIO bucket-exists readiness probe
│ ├── 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, batches, liveCapture
│ │ ├── router/index.ts # Role-based routes and navigation guards
│ │ ├── views/ # Login, Intake, CoverSheets, Entry, Verification, Approval, LiveCapture, PatientHistory, QueueDashboard
│ │ ├── components/ # ScanViewer, EntryForm, VerificationForm, BatchList, PatientSearch, AppHeader, ToastContainer, AssignClerkDialog
│ │ ├── composables/ # usePresignedUrl (URL refresh), useToast (notifications)
│ │ └── 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
├── VigilCareRecordsAPI.Tests/ # Integration tests (Phases 110)
├── prometheus.yml # Prometheus scrape config (API on host.docker.internal:5217)
├── docker-compose.yml # PostgreSQL, Redis, Seq, MinIO, Prometheus, Grafana
├── 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-phase-8-verification.sh # Prometheus metrics, overview, events, retry
│ ├── run-vigilcare-records-verification-p9.sh # E2E workflow + work queue overview smoke test
│ └── run-vigilcare-records-phase-10-verification.sh # Cover sheets, barcode upload, PDF, auto-assign
│ └── run-vigilcare-records-phase-11-verification.sh # FHIR metadata, read/search, $everything, integration tests
└── docs/
├── plans/ # Phase 111 implementation guides
├── digitization-workstation-guide.md # Clinical scenarios and clerk workflow reference
├── vigilcare-records-gap-analysis.md # Known gaps and hardening backlog
└── vigilcare-records-prd.md # Product requirements and phase roadmap
```
---
## Batch Status State Machine
```
┌──────────────┐
│ UPLOADED │──── cancel ────┐
└──────┬───────┘ │
│ assign │
▼ │
┌──────────────┐ │
┌──────────│ IN_ENTRY │◄─────────┐ │
│ cancel └──────┬───────┘ │ │
│ │ submit │ reject
│ ▼ │ │
│ ┌──────────────┐ │ │
│ │ PENDING │─────────┘ │
│ │ VERIFICATION │ │
│ └──────┬───────┘ │
│ │ │
│ verify fail │ verify pass │
│ ─────────┤ │
│ │ │
│ site config │ site config │
│ = false │ = true │
│ ┌────────┴────────┐ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────────────┐
│ │ VERIFIED │ │ AWAITING_CLINICAL │
│ └──────┬───────┘ │ _APPROVAL │
│ │ └──────────┬─────────────┘
│ └──────────┬──────────┘
│ │ approve
│ ▼
│ ┌──────────────┐
└────────────►│ APPROVED │
└──────┬───────┘
│ promotion
┌──────────────┐
│ PROMOTED │ (terminal — live records exist)
└──────┬───────┘
│ correction batch promoted
original observations marked superseded;
correction observations become active
┌──────────────┐
│ CANCELLED │ (terminal — from UPLOADED, IN_ENTRY, or REJECTED)
└──────────────┘
```
**Correction flow (Phase 5):** A `PROMOTED` batch cannot be edited in place. To fix an erroneous live value, intake uploads a new batch with `supersedesBatchId` pointing at the promoted batch. The correction goes through entry → verification → approval like any other batch. On promotion, the original batch's `live_observations` rows are soft-flagged (`is_superseded = true`, `superseded_by_batch_id` set) — never deleted.
**Allowed transitions:**
| From | To |
|---|---|
| `UPLOADED` | `IN_ENTRY`, `CANCELLED` |
| `IN_ENTRY` | `PENDING_VERIFICATION`, `CANCELLED` |
| `PENDING_VERIFICATION` | `VERIFIED`, `AWAITING_CLINICAL_APPROVAL`, `REJECTED` |
| `REJECTED` | `IN_ENTRY`, `CANCELLED` |
| `VERIFIED` | `APPROVED` |
| `AWAITING_CLINICAL_APPROVAL` | `APPROVED`, `REJECTED` |
| `APPROVED` | `PROMOTED` |
| `PROMOTED` | *(none — terminal)* |
| `CANCELLED` | *(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 via `POST /live-capture/encounters/{encounterId}/observations` or `POST /live-capture/encounters` — clinician attestation + password re-confirm replaces the dual-human gate, and observations promote synchronously with critical alerting before the response returns. Both tracks create `DigitizationBatch` records with full audit trails, keeping metrics and coverage stats consistent.
### Redis for Batch Assignment Locking and Alert Threshold Cache
Redis serves two purposes in this project: (1) preventing double-assignment of batches to entry clerks via `SET batch:assign:{id} NX EX 3600`, and (2) caching alert threshold definitions for synchronous critical evaluation during live capture (`threshold:{observationCode}`). 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.
### Corrections — Append-Only Supersession
Approved live observations are never mutated or deleted. When a transcription error is discovered after promotion, a correction batch (`supersedesBatchId`) goes through the full human workflow. On promotion, `PromotionService` marks the original `live_observations` as superseded and inserts the corrected values as new active rows. Clinical queries filter `is_superseded = false` by default; audit queries retain the full chain. No API endpoint exists to PATCH live observations directly.
---
## Getting Started
### Prerequisites
- .NET 8 SDK
- Node.js 20+ and npm (for the workstation UI)
- 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`, login: `admin` / `seqadmin` |
| MinIO | 9012 (S3 API), 9013 (console) | login: `minioadmin` / `minioadmin` |
| Prometheus | 9095 | Scrapes API at `host.docker.internal:5217/metrics`; UI at `http://localhost:9095` |
| Grafana | 3013 | UI at `http://localhost:3013`; add Prometheus data source `http://prometheus:9090` |
### 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) and ten demo batches spanning all batch types, both tracks, and every workflow status (including a correction batch, a deferred-promotion candidate, and live-capture vitals)
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, barcode-assisted cover sheet upload; `/cover-sheets` — generate and print cover sheets |
| `entry1` | `password` | `/entry` — data entry queue and split-pane form |
| `verifier1` | `password` | `/verification` — field-level verification |
| `approver1` | `password` | `/approval` — clinical sign-off before promotion |
| `clinician1` | `password` | `/live-capture` — bedside vitals with attestation |
| `admin1` | `password` | `/dashboard` — supervisor queue overview |
All roles can access `/patients` for patient search and digitization history.
See [docs/digitization-workstation-guide.md](docs/digitization-workstation-guide.md) for clinical scenarios (backfill, live capture, corrections) and the full clerk workflow.
**Paper originals:** The scanned document is the working reference for entry and verification. The physical chart remains the legal original until jurisdiction-specific retention rules apply. Scans are never deleted on batch rejection.
Production build:
```bash
cd vigilcare-records-web
npm run build # output in dist/
```
For production deployment where the UI and API are on different origins, add the frontend URL to `Cors:AllowedOrigins` in `appsettings.json` (default: `http://localhost:3028` for local dev).
### 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 |
| `PromotionTests` | 4 | Approval, atomic promotion to live tables, idempotency replay, separation of duties, retroactive alert policy, patient deduplication, MRN generation |
| `CorrectionSupersessionTests` | 5 | Correction batch supersession, validation guards (non-promoted, already superseded), patient digitization history, unknown patient 404 |
| `LiveCaptureIntegrationTests` | 6 | Attestation and password confirm, synchronous promotion, critical low/high potassium alerts, mixed-batch alerting, open encounter + vitals, role and validation guards, audit events |
| `UserManagementTests` | 9 | User create/update/deactivate, admin password reset, self-service password change, duplicate username guard, weak password validation |
| `CoverSheetBatchTests` | 10 | Cover sheet redeem on upload, reuse prevention, auto-assign from pre-assigned cover sheet |
| `CoverSheetPdfTests` | 10 | Single and batch PDF generation, valid PDF structure with QR metadata |
| `BatchOperationsTests` | — | Batch cancellation (status guards, Redis lock release), list/queue `sortBy`/`sortDirection` validation |
### 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
./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-phase-8-verification.sh # Phase 8 — Prometheus metrics, work-queue overview, batch events, promotion retry
./scripts/run-vigilcare-records-verification-p9.sh # E2E workflow + work queue overview smoke test
./scripts/run-vigilcare-records-phase-10-verification.sh # Phase 10 — cover sheets, barcode upload, PDF, auto-assign
./scripts/run-vigilcare-records-phase-11-verification.sh # Phase 11 — FHIR R4 read/search, $everything, LOINC, integration tests
```
---
## 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 (rate-limited: 10/5 min) |
| POST | `/auth/refresh` | Anonymous | Exchange a valid refresh token for new access + refresh token pair (rate-limited: 10/5 min) |
| 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 |
|---|---|---|
| `token` | string | JWT bearer token (15 min) |
| `refreshToken` | string | Opaque refresh token (7 days) |
| `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 and sortable (`sortBy`, `sortDirection`; default `createdAt desc`) |
| GET | `/digitization-batches/{id}` | Batch detail with presigned document URL (15-minute expiry); audits `document_accessed` |
| PATCH | `/digitization-batches/{id}/assign` | Assign batch to an entry clerk (Redis lock; transitions to `IN_ENTRY`) |
| POST | `/digitization-batches/{id}/cancel` | Cancel batch permanently (administrator only; `UPLOADED`, `IN_ENTRY`, or `REJECTED`) |
**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` — required unless `coverSheetCode` is provided; cover sheet values override when both are sent |
| `track` | string | no | `BACKFILL` (default) or `LIVE_CAPTURE` — overridden by cover sheet when `coverSheetCode` is set |
| `patientId` | Guid | no | Link to existing patient (enables duplicate detection); inherited from cover sheet when set |
| `supersedesBatchId` | Guid | no | Links a correction batch to the promoted batch it will supersede on promotion |
| `coverSheetCode` | string | no | Cover sheet barcode (e.g. `VCR-CS-A3F7B2D1`); auto-applies batch type, track, and patient; redeems on success; auto-assigns when cover sheet has `assignToUserId` |
**Status codes:**
| Code | Meaning |
|---|---|
| 201 | Batch created |
| 400 | Empty file or invalid MIME type |
| 404 | Superseded batch not found (`SUPERSEDED_BATCH_NOT_FOUND`); cover sheet not found (`COVER_SHEET_NOT_FOUND`) |
| 409 | Duplicate document (same SHA-256 for same patient within 24 hours); batch already superseded (`BATCH_ALREADY_SUPERSEDED`); cover sheet already used (`COVER_SHEET_ALREADY_USED`) |
| 422 | Superseded batch not in `PROMOTED` status (`SUPERSEDED_BATCH_NOT_PROMOTED`) |
**PATCH `/digitization-batches/{id}/assign` body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `entryClerkUserId` | Guid | yes | User ID of the entry clerk to assign |
**POST `/digitization-batches/{id}/cancel` body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `reason` | string | yes | Cancellation reason (minimum 5 characters) |
**Status codes:** `409 ILLEGAL_STATUS_TRANSITION` when the batch is not in a cancellable status.
### Cover Sheets
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | `/cover-sheets/generate` | Intake Clerk, Administrator | Generate 1100 cover sheets with unique barcode codes |
| GET | `/cover-sheets/lookup/{code}` | Any authenticated | Look up a cover sheet by barcode for intake auto-fill |
| GET | `/cover-sheets` | Intake Clerk, Administrator | List cover sheets; optional `isUsed`, `patientId` filters; paginated |
| POST | `/cover-sheets/{id}/pdf` | Intake Clerk, Administrator | Download a printable PDF with QR code for one cover sheet |
| POST | `/cover-sheets/batch-pdf` | Intake Clerk, Administrator | Download a multi-page PDF for a list of cover sheet IDs |
**POST `/cover-sheets/generate` body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `count` | int | yes | Number of cover sheets to generate (1100) |
| `batchType` | string | yes | Batch type encoded in the barcode |
| `track` | string | no | `BACKFILL` (default) or `LIVE_CAPTURE` |
| `patientId` | Guid | no | Pre-link to an existing patient |
| `assignToUserId` | Guid | no | Pre-assign batches created with this cover sheet to an entry clerk |
**Cover sheet response fields:** `id`, `code` (`VCR-CS-{8-hex}`), `batchType`, `track`, `patientId`, `patientName`, `patientMrn`, `assignToUserId`, `assignToUserName`, `isUsed`, `batchId`, `createdAt`, `usedAt`.
**Status codes:** `404 PATIENT_NOT_FOUND`, `404 USER_NOT_FOUND`, `404 COVER_SHEET_NOT_FOUND`, `409 COVER_SHEET_ALREADY_USED`.
### 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` (correction batches with `supersedesBatchId` require observations only — patient and encounter are inherited) |
| `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) |
### Approval and Promotion
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | `/digitization-batches/{id}/approve` | Clinical Approver, Administrator | Approve and atomically promote draft data to live clinical tables |
| POST | `/digitization-batches/{id}/promote` | Clinical Approver, Administrator | Manually promote an `APPROVED` batch (used after deferred promotion or by operators) |
| GET | `/digitization-batches/{id}/promotion-result` | Any authenticated | Retrieve live entity IDs created during promotion |
| GET | `/digitization-batches/{id}/events` | Administrator, Verifier, Clinical Approver | Cursor-paginated batch audit trail with actor username and full name |
**POST `/approve` headers:**
| Header | Required | Description |
|---|---|---|
| `Idempotency-Key` | yes | Unique key (max 100 chars) for safe retries; replays return the original response within 24 hours |
**POST `/approve` body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `enableRetroactiveAlerts` | bool | no | Default `false`. When `true`, backfill observations emit outbox events for downstream alerting. Live capture batches always emit outbox events regardless of this flag. |
**Promotion response (`PromotionResultResponse`):**
| Field | Type | Description |
|---|---|---|
| `batchId` | Guid | The promoted batch |
| `status` | string | `promoted` |
| `patientId` | Guid | Live patient ID (created or matched) |
| `mrn` | string | Medical Record Number (e.g. `VCR-000001`) |
| `encounterId` | Guid | Live encounter ID (created or matched) |
| `observationIds` | Guid[] | Live observation IDs created |
| `promotedAt` | DateTimeOffset | Promotion timestamp |
| `outboxEventsWritten` | int | Number of outbox events emitted for downstream consumers |
**Status codes:**
| Code | Meaning |
|---|---|
| 200 | Batch approved and promoted (or idempotent replay) |
| 202 | Promotion deferred — batch stays `APPROVED`; `PromotionRetryService` retries automatically (`PROMOTION_DEFERRED`) |
| 400 | Missing or invalid `Idempotency-Key` header |
| 404 | Batch not found |
| 409 | Illegal status transition or separation of duties violation |
| 422 | Missing draft patient data |
**Separation of duties:** The approver cannot be the entry clerk (`enteredByUserId`) or the verifier (`verifiedByUserId`) of the same batch. Both checks return `409 SEPARATION_OF_DUTIES_VIOLATION`.
**Patient deduplication:** On promotion, the service matches existing patients by case-insensitive normalized `fullName` + `dateOfBirth`. Near-matches (Levenshtein distance ≤ 3 on normalized name with same DOB) are logged as warnings. If an exact normalized match is found, the existing patient record is updated with any new fields from the draft. Otherwise, a new patient is created with a sequence-generated MRN (`VCR-NNNNNN`).
**Encounter matching:** Active encounters for the same patient and department are reused. Otherwise, a new encounter is created. Encounters with a discharge diagnosis are created with `discharged` status.
### Work Queues
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | `/work-queue/verification` | Verifier, Clinical Approver, Administrator | Batches in `PENDING_VERIFICATION` |
| 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` and sorting via `?sortBy=updatedAt&sortDirection=asc` (except `overview`). Allowed sort fields: `createdAt`, `updatedAt`, `status`, `batchType`, `track`. Invalid `sortBy` returns `422 INVALID_SORT_FIELD`.
### Batch Audit Trail
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | `/digitization-batches/{id}/events` | Administrator, Verifier, Clinical Approver | Cursor-paginated digitization events for a batch |
Query parameters: `after` (ISO-8601 cursor from previous page's `nextCursor`), `pageSize` (default 50, max 200). Events are ordered chronologically (oldest first).
**Event object (`BatchEventResponse`):**
| Field | Type | Description |
|---|---|---|
| `id` | Guid | Event ID |
| `batchId` | Guid | Batch ID |
| `eventType` | string | e.g. `uploaded`, `verified`, `promoted`, `document_accessed`, `cancelled`, `promotion_retry_failed` |
| `actorUserId` | Guid | User who performed the action |
| `actorUsername` | string | Actor username |
| `actorFullName` | string | Actor display name |
| `occurredAt` | DateTimeOffset | Event timestamp |
| `metadataJson` | string? | Optional JSON (field checks, rejection reason, etc.) |
**GET `/work-queue/overview` response:**
| Field | Type | Description |
|---|---|---|
| `statusCounts` | object | Batch count per status (all 9 statuses present) |
| `averageTimeInQueueMinutes` | number | Average age of batches in `PENDING_VERIFICATION` |
| `rejectRate` | number | Rejections / (rejections + verifications) over the last 24 hours (0.01.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 and Management
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | `/users?role=` | Intake Clerk, Administrator | List active users; optional `role` filter (e.g. `DATA_ENTRY_CLERK`) |
| POST | `/users` | Administrator | Create a new user |
| PATCH | `/users/{id}` | Administrator | Update `fullName`, `role`, and/or `isActive` |
| POST | `/users/{id}/reset-password` | Administrator | Set a new password for any user |
| POST | `/users/me/change-password` | Any authenticated | Self-service password change (requires current password) |
Used by the intake workstation assign-clerk dialog. Admin endpoints support operator provisioning without database access.
**POST `/users` body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | string | yes | Unique username |
| `password` | string | yes | Password (minimum strength enforced) |
| `fullName` | string | yes | Display name |
| `role` | string | yes | One of the six user roles |
**PATCH `/users/{id}` body:** any of `fullName`, `role`, `isActive` (set `isActive: false` to deactivate).
**Status codes:** `409 USERNAME_TAKEN` on duplicate username; `422` on weak password.
### Patient Digitization History
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | `/patients/{patientId}/digitization-history` | Any authenticated | Full digitization history for a patient: batches, correction chain, observation counts, audit trails |
**Response summary fields:**
| Field | Type | Description |
|---|---|---|
| `patientId` | Guid | Patient ID |
| `totalBatches` | int | All batches linked to this patient |
| `promotedBatches` | int | Batches in `PROMOTED` status |
| `supersededBatches` | int | Promoted batches that have been superseded by a correction |
| `pendingBatches` | int | Batches not yet promoted or rejected |
| `entries` | array | Per-batch detail with `isCorrection`, `hasBeenSuperseded`, `supersededByBatchId`, live/superseded observation counts, and `auditTrail` |
**Status codes:**
| Code | Meaning |
|---|---|
| 200 | History returned |
| 404 | No digitization batches for patient (`PATIENT_HISTORY_NOT_FOUND`) |
### Live Capture (Track B)
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | `/live-capture/encounters/{encounterId}/observations` | Clinician | Record observations against an existing active encounter; promotes synchronously with inline critical alerts |
| POST | `/live-capture/encounters` | Clinician | Open a new encounter and record initial vitals in one request (outpatient workflow) |
**Request body (both endpoints):**
| Field | Type | Required | Description |
|---|---|---|---|
| `observations` | array | yes | One or more observation objects (see below) |
| `clinicianAttestation` | bool | yes | Must be `true` — clinician attests values are accurate |
| `passwordConfirm` | string | yes | Re-enter password to confirm identity |
**Open encounter only — additional fields:**
| Field | Type | Required | Description |
|---|---|---|---|
| `patientId` | Guid | yes | Existing patient ID |
| `department` | string | yes | e.g. `Outpatient Clinic`, `Internal Medicine` |
| `roomBed` | string | no | Ward/bed assignment |
| `admissionReason` | string | no | Reason for visit or admission |
**Observation object:**
| Field | Type | Required | Description |
|---|---|---|---|
| `observationCode` | string | yes | e.g. `HEART_RATE`, `POTASSIUM_MEQ_L`, `TEMP_C` |
| `value` | decimal | yes | Numeric measurement |
| `unit` | string | yes | Unit of measure |
| `recordedAt` | DateTimeOffset | yes | When the measurement was taken |
| `note` | string | no | Optional note |
**Response (`LiveCaptureResponse`):**
| Field | Type | Description |
|---|---|---|
| `batchId` | Guid | `DigitizationBatch` created in `PROMOTED` status with `track = LIVE_CAPTURE` |
| `encounterId` | Guid | Live encounter ID |
| `observations` | array | Promoted observations with `liveObservationId` and optional inline `criticalAlert` |
| `criticalAlertCount` | int | Number of synchronous critical alerts generated |
| `promotedAt` | DateTimeOffset | Promotion timestamp |
**Inline critical alert object (`criticalAlert` on each observation):**
| Field | Type | Description |
|---|---|---|
| `alertId` | Guid | Committed `ClinicalAlert` row ID |
| `severity` | string | `CRITICAL` |
| `thresholdBound` | string | `CRITICAL_LOW` or `CRITICAL_HIGH` |
| `thresholdValue` | decimal | Breached threshold value |
| `message` | string | Human-readable breach description |
**Status codes:**
| Code | Meaning |
|---|---|
| 201 | Observations promoted; critical alerts (if any) committed before response |
| 403 | Caller lacks `CLINICIAN` role |
| 404 | Patient or encounter not found |
| 409 | Encounter not active (`ENCOUNTER_NOT_ACTIVE`); patient already has active encounter (`ACTIVE_ENCOUNTER_EXISTS`) |
| 422 | Attestation false (`ATTESTATION_REQUIRED`); wrong password (`PASSWORD_CONFIRM_INVALID`); empty observations list (`EMPTY_OBSERVATIONS`) |
Track B still creates a full audit trail: each submission writes a `DigitizationBatch` (status `PROMOTED`, `documentRef = "live-capture"`), draft observation rows, `live_capture_attested` and `promoted` digitization events, live `Observation` rows with `source = live_capture`, and outbox events for downstream alerting.
### Health Checks
Unauthenticated probe endpoints for orchestrators and load balancers:
| Method | Path | Description |
|---|---|---|
| GET | `/health/live` | Process liveness (always 200 if the app is running) |
| GET | `/health/ready` | Readiness — PostgreSQL, Redis, and MinIO must be reachable |
| GET | `/health/startup` | Startup — PostgreSQL reachable (post-migration) |
Returns `503` when a required dependency is unhealthy.
---
## Data Models
### CoverSheet
Single-use barcode label that encodes batch metadata for high-volume backfill intake. Redeemed atomically when a batch is created with `coverSheetCode`.
```
id Guid PK
code string required, unique — VCR-CS-{8-hex} encoded in QR barcode
patientId Guid? optional pre-link to patient
batchType string encoded batch type
track string BACKFILL | LIVE_CAPTURE
assignToUserId Guid? optional entry clerk pre-assignment
generatedByUserId Guid FK → User who generated the cover sheet
isUsed bool default false — set true on batch creation
batchId Guid? FK → DigitizationBatch created from this cover sheet
createdAt DateTimeOffset
usedAt DateTimeOffset? set when redeemed
```
### DigitizationBatch
```
id Guid PK
status string UPLOADED | IN_ENTRY | PENDING_VERIFICATION | REJECTED | VERIFIED | AWAITING_CLINICAL_APPROVAL | APPROVED | PROMOTED | CANCELLED
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 | live_capture_attested | correction_uploaded | correction_promoted | superseded | document_accessed | cancelled | ...
actorUserId Guid FK → User
occurredAt DateTimeOffset
metadataJson string? optional JSON (field checks, rejection reason, assigned-to)
```
### Patient (Clinical — Live)
```
id Guid PK
mrn string required, unique — generated from PostgreSQL sequence (VCR-000001)
fullName string required
dateOfBirth DateOnly?
sex string?
bloodType BloodType?
emergencyContact string?
allergiesJson string?
noKnownAllergies bool
createdAt DateTimeOffset
updatedAt DateTimeOffset
```
### Encounter (Clinical — Live)
```
id Guid PK
patientId Guid FK → Patient
admissionDate DateTimeOffset?
department Department?
roomBed string?
admissionReason string?
dischargeDiagnosis string?
status string active | discharged
sourceBatchId Guid? FK → DigitizationBatch (traceability)
createdAt DateTimeOffset
updatedAt DateTimeOffset
```
### LiveObservation
Append-only mirror of promoted observations used for supersession tracking and digitization history queries. Rows are never deleted; corrections mark prior rows as superseded.
```
id Guid PK
encounterId Guid FK → LiveEncounter
patientId Guid? FK → Patient
sourceBatchId Guid FK → DigitizationBatch
observationCode string e.g. K, Na
value decimal
unit string
recordedAt DateTimeOffset
note string?
isSuperseded bool default false — set true when a correction batch promotes
supersededByBatchId Guid? correction batch that replaced this observation
supersededAt DateTimeOffset? when supersession occurred
createdAt DateTimeOffset
```
### Observation (Clinical — Live)
```
id Guid PK
encounterId Guid FK → Encounter
patientId Guid FK → Patient
observationCode string required (e.g. HEART_RATE, TEMP_C)
value decimal required
unit string required
recordedAt DateTimeOffset required
note string?
source string digitization_backfill | live_capture
sourceDraftObservationId Guid? FK → DraftObservation (traceability)
sourceBatchId Guid? FK → DigitizationBatch (traceability)
createdAt DateTimeOffset
```
### OutboxEvent
```
id Guid PK
eventType string e.g. observation.created | observation.recorded | alert.generated
aggregateType string e.g. Observation | ClinicalAlert
aggregateId Guid FK → the created entity
payloadJson string full event payload for downstream consumers
createdAt DateTimeOffset
processedAt DateTimeOffset? set when consumed
retryCount int default 0
```
### AlertThreshold
```
id Guid PK
observationCode string required, unique (e.g. POTASSIUM_MEQ_L)
displayName string required
unit string required
criticalLow decimal?
warningLow decimal?
warningHigh decimal?
criticalHigh decimal?
suppressionWindowMinutes int?
createdAt DateTimeOffset
```
### ClinicalAlert
```
id Guid PK
encounterId Guid FK → Encounter
patientId Guid FK → Patient
observationId Guid? FK → Observation (triggering value)
alertType string e.g. CRITICAL_POTASSIUM_MEQ_L
severity string WARNING | CRITICAL
details string human-readable breach message
observationCode string?
status string OPEN | ACKNOWLEDGED | RESOLVED | ESCALATED
triggeredAt DateTimeOffset
```
### IdempotencyRecord
```
id Guid PK
idempotencyKey string required, unique (from Idempotency-Key header)
operationName string e.g. batch_promote
resourceId Guid the batch ID
httpStatusCode int original response code
responseBodyJson string serialized original response
createdAt DateTimeOffset
expiresAt DateTimeOffset 24-hour TTL
```
### PromotionAttempt
Tracks each promotion attempt for deferred-retry batches. `PromotionRetryService` polls rows where `nextRetryAt <= now` and the batch is still `APPROVED`.
```
id Guid PK
batchId Guid FK → DigitizationBatch
attemptNumber int 1-based attempt counter
succeeded bool whether this attempt completed promotion
errorMessage string? failure reason when succeeded = false
attemptedAt DateTimeOffset
nextRetryAt DateTimeOffset? scheduled retry time (null on success)
```
### 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
### Offset pagination
List endpoints (batch list, work queues) use offset pagination:
| Param | Default | Description |
|---|---|---|
| `page` | 1 | Page number (1-based) |
| `pageSize` | 20 | Items per page |
| `sortBy` | `createdAt` (batch list) or `updatedAt` (work queues) | Sort field: `createdAt`, `updatedAt`, `status`, `batchType`, `track` |
| `sortDirection` | `desc` (batch list) or `asc` (work queues) | `asc` or `desc` |
Invalid `sortBy` values return `422` with code `INVALID_SORT_FIELD`.
Response shape:
```json
{
"items": [],
"page": 1,
"pageSize": 20,
"totalCount": 42,
"totalPages": 3
}
```
### Cursor pagination
`GET /digitization-batches/{id}/events` uses cursor pagination on `occurredAt`:
| Param | Default | Description |
|---|---|---|
| `after` | — | ISO-8601 timestamp cursor from the previous page's `nextCursor` |
| `pageSize` | 50 | Events per page (max 200) |
Response shape:
```json
{
"items": [],
"pageSize": 50,
"nextCursor": "2026-06-27T12:00:00Z",
"hasMore": true
}
```
---
## Implemented Phases
Phases 110 are fully implemented and verified via integration tests and per-phase scripts. Post-phase hardening (health checks, user management, promotion retry unification, patient dedup normalization, assignment-time status transitions, document access audit, batch cancellation) is also in place. Phase 11 (HL7 FHIR R4 read API) is planned — see [docs/plans/phase-11-plan.md](docs/plans/phase-11-plan.md). See [docs/vigilcare-records-gap-analysis.md](docs/vigilcare-records-gap-analysis.md) for the remaining backlog.
| 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, assignment-time and fallback `UPLOADED`/`REJECTED → `IN_ENTRY` transitions, 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 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 normalized 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 (`vigilcare-records-web`): Vue 3 + Pinia + Tailwind, role-based routing and nav, split-pane scan viewer, batch-type-aware draft entry (allergies, medications, discharge diagnosis), verification checkboxes, clinical approval view, live capture view (new/existing encounter), patient history timeline, patient search, assign-clerk dialog, presigned URL refresh, supervisor dashboard, toast notifications | Done |
| 8 | Prometheus metrics (`GET /metrics`), custom digitization gauges/histograms/counters, `MetricsCollectorService`, supervisor `GET /work-queue/overview`, cursor-paginated `GET /digitization-batches/:id/events`, promotion deferral (202 / `PROMOTION_DEFERRED`), `PromotionRetryService` with exponential backoff, Docker Prometheus (9095) + Grafana (3013), Phase 8 verification script | Done |
| 9 | Extended seed data (10 demo batches across all types/tracks/statuses), E2E verification script (`run-vigilcare-records-verification-p9.sh`), clinical scenario docs (`docs/digitization-workstation-guide.md`), `UserManagementTests` | Done |
| 10 | Barcode/QR cover sheet system: `CoverSheet` entity, generate/lookup/list/PDF APIs, `coverSheetCode` on batch upload with redeem and auto-assign, printable PDF with QRCoder, `/cover-sheets` and barcode-assisted `/intake` UI views, `CoverSheetBatchTests`, `CoverSheetPdfTests`, Phase 10 verification script | Done |
| — | Health probes (`/health/live`, `/health/ready`, `/health/startup`), user management CRUD + password endpoints, auth rate limiting, document access audit, batch cancellation, list/queue sorting, unified `ExecutePromotionCoreAsync` for approve + retry promote, assignment-time `IN_ENTRY` transition, CORS policy for production frontend origins | Done |
| 11 | HL7 FHIR R4 read-only API for promoted clinical data (Patient, Encounter, Observation) | Planned |