938 lines
55 KiB
Markdown
938 lines
55 KiB
Markdown
# VigilCare Records API
|
||
|
||
A clinical records intake backend built with ASP.NET Core 8, PostgreSQL, MinIO, and Redis. The domain models the scan-to-approved lifecycle at the center of any paper chart digitization system: scanned document upload, human data entry, dual-human verification with separation of duties, site-configurable clinical approval routing, atomic promotion to live clinical tables, and governed correction via supersession.
|
||
|
||
**Implementation status:** Five planned phases are complete through Phase 5 — from schema, authentication, and batch CRUD through draft data entry, verification/rejection with separation of duties, approval with atomic promotion to VigilCareClinical live tables, and correction batches that supersede erroneous promoted observations without silent edits. See [Implemented Phases](#implemented-phases) for the full breakdown.
|
||
|
||
## Domain Model — How It Maps to a Real Clinical System
|
||
|
||
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
|
||
- **Batch Assignment** — `PATCH /digitization-batches/:id/assign` assigns an entry clerk with a Redis lock (`SET batch:assign:{id} NX EX 3600`) to prevent double-assignment; `409 BATCH_ALREADY_ASSIGNED` on conflict
|
||
- **Draft Data Entry** — full CRUD on draft payload: upsert patient demographics, upsert encounter context, add/edit/delete observation rows; plausibility validation on observation values at draft save time (catches decimal errors before verification); automatic `UPLOADED → IN_ENTRY` transition on first save; draft save requires the acting user to match `enteredByUserId` or hold `Administrator` role (`409 BATCH_NOT_ASSIGNED`)
|
||
- **Submit for Verification** — validates completeness per batch type (e.g. vitals sheet requires linked patient, encounter context, and at least one observation with `recordedAt`); transitions `IN_ENTRY → PENDING_VERIFICATION`; returns `422` with missing fields if incomplete
|
||
- **Verification and Rejection** — verifier reviews entry against the scan with field-level checks (`fieldName`, `status: ok|warning|error`, optional `note`); verify pass transitions to `VERIFIED` or `AWAITING_CLINICAL_APPROVAL` based on site configuration for the batch type; verify fail transitions to `REJECTED` with mandatory reason; **separation of duties** enforced: entry clerk cannot verify their own batch (`409 SEPARATION_OF_DUTIES_VIOLATION`)
|
||
- **Clinical Approval Routing** — site-configurable per batch type (`SiteConfig.ClinicalApprovalRequired`); high-stakes batch types (encounter summary, vitals sheet, lab results, medication list, mixed) route to `AWAITING_CLINICAL_APPROVAL` after verification for physician sign-off; low-stakes types (patient registration, allergy update) go directly to `VERIFIED`
|
||
- **Approval and Promotion** — `POST /digitization-batches/:id/approve` atomically promotes draft data to live VigilCareClinical tables (`patients`, `encounters`, `observations`) in a single PostgreSQL transaction; generates MRN via PostgreSQL sequence (`VCR-000001`); patient deduplication by name + DOB; encounter matching by patient + department + active status; writes `observation.created` outbox events for downstream consumers; separation of duties enforced (approver cannot be entry clerk or verifier); `Idempotency-Key` header required for safe retries with 24-hour TTL; retroactive alert policy (`enableRetroactiveAlerts`) controls whether backfill observations emit outbox events
|
||
- **Promotion Result Query** — `GET /digitization-batches/:id/promotion-result` returns live entity IDs (patient, MRN, encounter, observations) created during promotion
|
||
- **Corrections and Supersession** — approved live observations are never silently edited; a correction uploads a new batch with `supersedesBatchId`, goes through the full entry → verify → approve cycle, and on promotion marks the original batch's `live_observations` rows as `is_superseded` (append-only — never deleted); `422 SUPERSEDED_BATCH_NOT_PROMOTED`, `409 BATCH_ALREADY_SUPERSEDED`, and `404 SUPERSEDED_BATCH_NOT_FOUND` guard invalid supersession targets; correction batches inherit patient context and reuse the original promotion encounter
|
||
- **Patient Digitization History** — `GET /patients/:id/digitization-history` returns all batches for a patient with correction chain metadata (`isCorrection`, `hasBeenSuperseded`, `supersededByBatchId`), live vs superseded observation counts, summary totals, and per-batch audit trails; `404 PATIENT_HISTORY_NOT_FOUND` when no batches exist for the patient
|
||
- **Work Queues** — `GET /work-queue/verification` (batches in `PENDING_VERIFICATION`, sorted by submission time ASC); `GET /work-queue/entry` (batches in `UPLOADED`, `IN_ENTRY`, or `REJECTED`); `GET /work-queue/clinical-approval` (batches in `AWAITING_CLINICAL_APPROVAL`); role-restricted access
|
||
- **Batch Status Machine** — explicit transition matrix enforced in the service layer; illegal transitions return `409`; `PROMOTED` is terminal — corrections require a new batch with `supersedesBatchId`
|
||
- **JWT Authentication** — `POST /auth/login` returns access token (15 min) and refresh token (7 days); `POST /auth/refresh` rotates tokens; `POST /auth/logout` revokes server-side; `GET /auth/me` returns authenticated user profile; BCrypt password hashing
|
||
- **Role-Based Access** — six roles (`INTAKE_CLERK`, `DATA_ENTRY_CLERK`, `VERIFIER`, `CLINICAL_APPROVER`, `CLINICIAN`, `ADMINISTRATOR`) with role-based endpoint authorization; twelve seeded demo users (two per role)
|
||
- **Auth Audit Events** — append-only `auth_audit_events` table records login, logout, token refresh, and failed login attempts with user ID, IP address, and timestamp
|
||
- **Standard Envelope** — all responses use `{ success, statusCode, data, error }` wrapper; validation errors use the same shape with stable error codes
|
||
- **Observability** — Serilog structured logging with Seq sink; correlation IDs via `CorrelationIdMiddleware`; `ExceptionHandlerMiddleware` for consistent error responses
|
||
- **Swagger UI** — OpenAPI spec via Swashbuckle (Development only) at `http://localhost:5217/swagger`
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
```
|
||
HTTP request
|
||
→ CorrelationIdMiddleware
|
||
→ ExceptionHandlerMiddleware
|
||
→ JWT Authentication (Bearer)
|
||
→ Role-based Authorization ([Authorize(Roles = "...")])
|
||
→ Controllers (REST API)
|
||
→ Services
|
||
├── AuthService (login, refresh token rotation, logout, BCrypt verify)
|
||
├── BatchService (batch CRUD, status machine, Redis assignment lock, duplicate detection)
|
||
├── DraftService (draft patient/encounter/observation CRUD, plausibility validation, submit-for-verification)
|
||
├── VerificationService (verify/reject with separation of duties, site-config approval routing)
|
||
├── PromotionService (approve + atomic promote to live tables, patient dedup, encounter matching, outbox events, supersession on correction promotion)
|
||
├── DigitizationHistoryService (patient digitization history with correction chain and audit trails)
|
||
├── IdempotencyService (Idempotency-Key storage + replay for safe promotion retries)
|
||
├── MrnGenerator (PostgreSQL sequence-backed MRN generation: VCR-000001)
|
||
├── WorkQueueService (verification, entry, clinical approval queues)
|
||
├── DocumentStorageService (MinIO upload, SHA-256, presigned URLs)
|
||
├── PlausibilityValidator (per-code numeric range guard)
|
||
├── PostgreSQL (EF Core — writes, keyed reads, clinical schema for live tables)
|
||
├── Redis (batch assignment locks)
|
||
└── MinIO (scanned document storage)
|
||
```
|
||
|
||
**Relationship to VigilCareClinical:** VigilCare Records is the precursor that creates the fuel — patient identity, encounters, and observations — in a governed, auditable way. Draft observations are invisible to VigilCareClinical's alert engine, Elasticsearch ward projection, and scoring consumers. Only batches that pass approval are promoted to live tables. Promotion 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) |
|
||
| Database | PostgreSQL 16 with EF Core 8 (code-first migrations) |
|
||
| Cache / locking | Redis 7 (batch assignment locks) |
|
||
| Object storage | MinIO (scanned documents — PDF, JPEG, PNG) |
|
||
| Authentication | JWT Bearer (Microsoft.AspNetCore.Authentication.JwtBearer) |
|
||
| Password hashing | BCrypt.Net-Next |
|
||
| Logging | Serilog + Seq sink |
|
||
| Docs | Swagger / OpenAPI (Swashbuckle) |
|
||
| Testing | xUnit + FluentAssertions + WebApplicationFactory |
|
||
|
||
---
|
||
|
||
## Project Structure
|
||
|
||
```
|
||
VigilCareRecordsAPI/
|
||
├── Program.cs # Service registration, middleware, seed on startup
|
||
├── appsettings.json # Connection strings, Redis, MinIO, Seq, JWT, site config
|
||
├── Controllers/
|
||
│ ├── 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
|
||
│ ├── 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 (Kafka)
|
||
│ │ │ └── IdempotencyRecord.cs # Idempotency-Key storage for safe promotion retries
|
||
│ │ ├── Draft/
|
||
│ │ │ ├── DraftPatient.cs # Structured patient demographics from paper chart
|
||
│ │ │ ├── DraftEncounter.cs # Encounter context: admission, department, room/bed
|
||
│ │ │ └── DraftObservation.cs # Single measurement: code, value, unit, recordedAt
|
||
│ │ ├── DigitizationBatch.cs # Central workflow entity with status machine
|
||
│ │ ├── 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
|
||
├── Services/
|
||
│ ├── Interfaces/ # IAuthService, IBatchService, IDraftService, IVerificationService, IWorkQueueService, IDocumentStorageService, IPromotionService, IIdempotencyService, IMrnGenerator, IDigitizationHistoryService
|
||
│ ├── 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
|
||
│ └── 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
|
||
│ └── Common/ # PagedResult
|
||
├── Data/
|
||
│ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints
|
||
│ ├── Configurations/
|
||
│ │ ├── Clinical/ # PatientConfiguration, EncounterConfiguration, ObservationConfiguration, OutboxEventConfiguration, 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 AddLiveObservationAndLiveEncounter
|
||
├── 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
|
||
├── 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
|
||
|
||
docs/
|
||
├── plans/ # Phase 1–9 implementation and verification guides
|
||
└── vigilcare-records-prd.md # Product requirements and phase roadmap
|
||
```
|
||
|
||
---
|
||
|
||
## Batch Status State Machine
|
||
|
||
```
|
||
┌──────────────┐
|
||
│ UPLOADED │
|
||
└──────┬───────┘
|
||
│ assign / first save
|
||
▼
|
||
┌──────────────┐
|
||
┌──────────│ IN_ENTRY │◄─────────┐
|
||
│ └──────┬───────┘ │
|
||
│ │ submit │ reject
|
||
│ ▼ │
|
||
│ ┌──────────────┐ │
|
||
│ │ PENDING │─────────┘
|
||
│ │ VERIFICATION │
|
||
│ └──────┬───────┘
|
||
│ │
|
||
│ verify fail │ verify pass
|
||
│ ─────────┤
|
||
│ │
|
||
│ site config │ site config
|
||
│ = false │ = true
|
||
│ ┌────────┴────────┐
|
||
│ ▼ ▼
|
||
│ ┌──────────────┐ ┌──────────────────────┐
|
||
│ │ VERIFIED │ │ AWAITING_CLINICAL │
|
||
│ └──────┬───────┘ │ _APPROVAL │
|
||
│ │ └──────────┬─────────────┘
|
||
│ └──────────┬──────────┘
|
||
│ │ approve
|
||
│ ▼
|
||
│ ┌──────────────┐
|
||
└────────────►│ APPROVED │
|
||
└──────┬───────┘
|
||
│ promotion (Phase 4)
|
||
▼
|
||
┌──────────────┐
|
||
│ PROMOTED │ (terminal — live records exist)
|
||
└──────┬───────┘
|
||
│ correction batch promoted (Phase 5)
|
||
▼
|
||
original observations marked superseded;
|
||
correction observations become active
|
||
```
|
||
|
||
**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` |
|
||
| `IN_ENTRY` | `PENDING_VERIFICATION` |
|
||
| `PENDING_VERIFICATION` | `VERIFIED`, `AWAITING_CLINICAL_APPROVAL`, `REJECTED` |
|
||
| `REJECTED` | `IN_ENTRY` |
|
||
| `VERIFIED` | `APPROVED` |
|
||
| `AWAITING_CLINICAL_APPROVAL` | `APPROVED`, `REJECTED` |
|
||
| `APPROVED` | `PROMOTED` |
|
||
| `PROMOTED` | *(none — terminal)* |
|
||
|
||
Illegal transitions return `409` with a stable error code. A `PROMOTED` batch cannot return to any earlier state. Corrections require a **new** batch referencing `supersedesBatchId`.
|
||
|
||
**Site-configurable clinical approval routing:**
|
||
|
||
| batchType | Clinical sign-off after verify? |
|
||
|---|---|
|
||
| `PATIENT_REGISTRATION` | No |
|
||
| `ALLERGY_UPDATE` | No |
|
||
| `ENCOUNTER_SUMMARY` | Yes |
|
||
| `VITALS_SHEET` | Yes |
|
||
| `LAB_RESULTS` | Yes |
|
||
| `MEDICATION_LIST` | Yes |
|
||
| `MIXED` | Yes |
|
||
|
||
---
|
||
|
||
## Architecture Decisions
|
||
|
||
### Draft Isolation — The Core Safety Invariant
|
||
|
||
Draft observations are invisible to VigilCareClinical's alert engine, Elasticsearch ward projection, and scoring consumers. Only batches that have passed approval are promoted to live tables. This is the architectural decision that separates a digitization system from a data entry form. If an entry clerk misreads a potassium of 5.2 mEq/L as 52 mEq/L, the plausibility validator catches it at draft save. If the plausibility range allows it but the value is wrong, the verifier catches it against the scan. If neither catches it, the clinical approver is the last gate for high-stakes batch types. At no point does the incorrect value enter the alerting pipeline until all human gates have cleared.
|
||
|
||
### Separation of Duties — Who Cannot Do What
|
||
|
||
The person who enters data cannot verify their own entry. This is enforced in the service layer, not only in the UI. `enteredByUserId === currentUserId` blocks verify and approve actions with `409 SEPARATION_OF_DUTIES_VIOLATION`. In a small island clinic with limited staff, the same person may hold intake and entry roles — but **never** entry and verifier on the same batch.
|
||
|
||
### Two-Track Workflow (Backfill vs Live Capture)
|
||
|
||
Track A (backfill) is the full pipeline for historical charts: scan → entry → verification → clinical approval → promotion. Track B (live capture) is for credentialed clinicians entering vitals at bedside — attestation replaces the dual-human gate. Both tracks create `DigitizationBatch` records with full audit trails, keeping metrics and coverage stats consistent.
|
||
|
||
### Redis for Batch Assignment Locking
|
||
|
||
Redis serves one purpose in this project: preventing double-assignment of batches to entry clerks. `SET batch:assign:{id} NX EX 3600` acquires an exclusive lock with a 1-hour TTL. Work-queue counters are derived from PostgreSQL queries, not Redis counters.
|
||
|
||
### Integrated Database Deployment
|
||
|
||
VigilCare Records draft tables and VigilCareClinical live tables share one PostgreSQL instance (separate logical concerns). Promotion runs in a single local transaction — no distributed saga required. Split deployment with HTTP + saga retry is documented as a future deployment option.
|
||
|
||
### 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
|
||
- Docker and Docker Compose
|
||
|
||
### Start Infrastructure
|
||
|
||
```bash
|
||
docker compose up -d
|
||
```
|
||
|
||
| Service | Host Port | Notes |
|
||
|---|---|---|
|
||
| PostgreSQL 16 | 5437 | Database: `vigilcare_records`, user: `postgres`, password: `password` |
|
||
| Redis 7 | 6383 | No auth |
|
||
| Seq | 5346 | UI at `http://localhost:5346` |
|
||
| MinIO | 9012 (S3 API), 9013 (console) | login: `minioadmin` / `minioadmin` |
|
||
|
||
### Install and Run
|
||
|
||
```bash
|
||
cd VigilCareRecordsAPI
|
||
dotnet restore
|
||
dotnet run
|
||
```
|
||
|
||
On startup the application:
|
||
1. Runs EF Core migrations
|
||
2. Seeds twelve demo users (two per role: intake clerk, data entry clerk, verifier, clinical approver, clinician, administrator)
|
||
|
||
Swagger UI is available at `http://localhost:5217/swagger` in Development.
|
||
|
||
### Run Tests
|
||
|
||
```bash
|
||
dotnet test
|
||
```
|
||
|
||
Integration tests use `WebApplicationFactory` with PostgreSQL, Redis, and MinIO containers. No manual infrastructure setup is required for `dotnet test`.
|
||
|
||
| Test class | Phase | Coverage |
|
||
|---|---|---|
|
||
| `DraftEntryTests` | 2 | Draft CRUD, plausibility validation, submit-for-verification completeness checks |
|
||
| `VerificationTests` | 3 | Verification, rejection, separation of duties enforcement, clinical approval routing |
|
||
| `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 |
|
||
|
||
### 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
|
||
```
|
||
|
||
---
|
||
|
||
## API Reference
|
||
|
||
All endpoints are prefixed `/api/v1`. Responses follow the standard envelope:
|
||
|
||
```json
|
||
{ "success": true, "statusCode": 200, "data": {}, "error": null }
|
||
```
|
||
|
||
Error response:
|
||
|
||
```json
|
||
{
|
||
"success": false,
|
||
"statusCode": 409,
|
||
"data": null,
|
||
"error": {
|
||
"message": "Verifier cannot approve a batch they entered.",
|
||
"code": "SEPARATION_OF_DUTIES_VIOLATION"
|
||
}
|
||
}
|
||
```
|
||
|
||
### Authentication
|
||
|
||
| Method | Path | Auth | Description |
|
||
|---|---|---|---|
|
||
| POST | `/auth/login` | Anonymous | Authenticate with username/password; returns access + refresh tokens |
|
||
| POST | `/auth/refresh` | Anonymous | Exchange a valid refresh token for new access + refresh token pair |
|
||
| POST | `/auth/logout` | Anonymous | Revoke the refresh token server-side |
|
||
| GET | `/auth/me` | JWT | Returns the authenticated user's profile |
|
||
|
||
**POST `/auth/login` body:**
|
||
|
||
| Field | Type | Required | Description |
|
||
|---|---|---|---|
|
||
| `username` | string | yes | Username |
|
||
| `password` | string | yes | Password |
|
||
|
||
**Login response:**
|
||
|
||
| Field | Type | Description |
|
||
|---|---|---|
|
||
| `accessToken` | string | JWT bearer token (15 min) |
|
||
| `refreshToken` | string | Opaque refresh token (7 days) |
|
||
| `expiresAt` | DateTimeOffset | Access token expiration |
|
||
| `userId` | Guid | User ID |
|
||
| `username` | string | Username |
|
||
| `displayName` | string | Display name |
|
||
| `role` | string | `INTAKE_CLERK`, `DATA_ENTRY_CLERK`, `VERIFIER`, `CLINICAL_APPROVER`, `CLINICIAN`, `ADMINISTRATOR` |
|
||
|
||
**Seeded demo users:**
|
||
|
||
| Username | Password | Role |
|
||
|---|---|---|
|
||
| `intake1` | `password` | Intake Clerk |
|
||
| `intake2` | `password` | Intake Clerk |
|
||
| `entry1` | `password` | Data Entry Clerk |
|
||
| `entry2` | `password` | Data Entry Clerk |
|
||
| `verifier1` | `password` | Verifier |
|
||
| `verifier2` | `password` | Verifier |
|
||
| `approver1` | `password` | Clinical Approver |
|
||
| `approver2` | `password` | Clinical Approver |
|
||
| `clinician1` | `password` | Clinician |
|
||
| `clinician2` | `password` | Clinician |
|
||
| `admin1` | `password` | Administrator |
|
||
| `admin2` | `password` | Administrator |
|
||
|
||
### Digitization Batches
|
||
|
||
| Method | Path | Description |
|
||
|---|---|---|
|
||
| POST | `/digitization-batches` | Upload a scanned document and create a batch (multipart/form-data) |
|
||
| GET | `/digitization-batches` | List batches; optional `status`, `batchType`, `assignedTo`, `track` filters; paginated |
|
||
| GET | `/digitization-batches/{id}` | Batch detail with presigned document URL (15-minute expiry) |
|
||
| PATCH | `/digitization-batches/{id}/assign` | Assign batch to an entry clerk (Redis lock) |
|
||
|
||
**POST body (multipart/form-data):**
|
||
|
||
| Field | Type | Required | Description |
|
||
|---|---|---|---|
|
||
| `file` | binary | yes | PDF, JPEG, or PNG (max 25 MB) |
|
||
| `batchType` | string | yes | `PATIENT_REGISTRATION`, `ENCOUNTER_SUMMARY`, `VITALS_SHEET`, `LAB_RESULTS`, `MEDICATION_LIST`, `ALLERGY_UPDATE`, `MIXED` |
|
||
| `track` | string | no | `BACKFILL` (default) or `LIVE_CAPTURE` |
|
||
| `patientId` | Guid | no | Link to existing patient (enables duplicate detection) |
|
||
| `supersedesBatchId` | Guid | no | Links a correction batch to the promoted batch it will supersede on promotion |
|
||
|
||
**Status codes:**
|
||
|
||
| Code | Meaning |
|
||
|---|---|
|
||
| 201 | Batch created |
|
||
| 400 | Empty file or invalid MIME type |
|
||
| 404 | Superseded batch not found (`SUPERSEDED_BATCH_NOT_FOUND`) |
|
||
| 409 | Duplicate document (same SHA-256 for same patient within 24 hours); batch already superseded (`BATCH_ALREADY_SUPERSEDED`) |
|
||
| 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 |
|
||
|
||
### 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 |
|
||
| GET | `/digitization-batches/{id}/promotion-result` | Any authenticated | Retrieve live entity IDs created during promotion |
|
||
|
||
**POST `/approve` headers:**
|
||
|
||
| Header | Required | Description |
|
||
|---|---|---|
|
||
| `Idempotency-Key` | yes | Unique key (max 100 chars) for safe retries; replays return the original response within 24 hours |
|
||
|
||
**POST `/approve` body:**
|
||
|
||
| Field | Type | Required | Description |
|
||
|---|---|---|---|
|
||
| `enableRetroactiveAlerts` | bool | no | Default `false`. When `true`, backfill observations emit outbox events for downstream alerting. Live capture batches always emit outbox events regardless of this flag. |
|
||
|
||
**Promotion response (`PromotionResultResponse`):**
|
||
|
||
| Field | Type | Description |
|
||
|---|---|---|
|
||
| `batchId` | Guid | The promoted batch |
|
||
| `status` | string | `promoted` |
|
||
| `patientId` | Guid | Live patient ID (created or matched) |
|
||
| `mrn` | string | Medical Record Number (e.g. `VCR-000001`) |
|
||
| `encounterId` | Guid | Live encounter ID (created or matched) |
|
||
| `observationIds` | Guid[] | Live observation IDs created |
|
||
| `promotedAt` | DateTimeOffset | Promotion timestamp |
|
||
| `outboxEventsWritten` | int | Number of outbox events emitted for downstream consumers |
|
||
|
||
**Status codes:**
|
||
|
||
| Code | Meaning |
|
||
|---|---|
|
||
| 200 | Batch approved and promoted (or idempotent replay) |
|
||
| 400 | Missing or invalid `Idempotency-Key` header |
|
||
| 404 | Batch not found |
|
||
| 409 | Illegal status transition or separation of duties violation |
|
||
| 422 | Missing draft patient data |
|
||
|
||
**Separation of duties:** The approver cannot be the entry clerk (`enteredByUserId`) or the verifier (`verifiedByUserId`) of the same batch. Both checks return `409 SEPARATION_OF_DUTIES_VIOLATION`.
|
||
|
||
**Patient deduplication:** On promotion, the service matches existing patients by `fullName` + `dateOfBirth`. If a match is found, the existing patient record is updated with any new fields from the draft. Otherwise, a new patient is created with a sequence-generated MRN (`VCR-NNNNNN`).
|
||
|
||
**Encounter matching:** Active encounters for the same patient and department are reused. Otherwise, a new encounter is created. Encounters with a discharge diagnosis are created with `discharged` status.
|
||
|
||
### Work Queues
|
||
|
||
| Method | Path | Auth | Description |
|
||
|---|---|---|---|
|
||
| GET | `/work-queue/verification` | Verifier, Clinical Approver, Administrator | Batches in `PENDING_VERIFICATION`, sorted by submission time |
|
||
| GET | `/work-queue/entry` | Data Entry Clerk, Administrator | Batches awaiting or in entry (`UPLOADED`, `IN_ENTRY`, `REJECTED`) |
|
||
| GET | `/work-queue/clinical-approval` | Clinical Approver, Administrator | Batches in `AWAITING_CLINICAL_APPROVAL` |
|
||
|
||
All work queue endpoints support pagination via `?page=1&pageSize=20`.
|
||
|
||
### 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`) |
|
||
|
||
---
|
||
|
||
## Data Models
|
||
|
||
### DigitizationBatch
|
||
|
||
```
|
||
id Guid PK
|
||
status string UPLOADED | IN_ENTRY | PENDING_VERIFICATION | REJECTED | VERIFIED | AWAITING_CLINICAL_APPROVAL | APPROVED | PROMOTED
|
||
batchType string PATIENT_REGISTRATION | ENCOUNTER_SUMMARY | VITALS_SHEET | LAB_RESULTS | MEDICATION_LIST | ALLERGY_UPDATE | MIXED
|
||
track string BACKFILL | LIVE_CAPTURE
|
||
patientId Guid? nullable until linked
|
||
encounterDraftId Guid? encounter context for vitals/labs
|
||
documentRef string MinIO object key for the scanned document
|
||
documentSha256 string content hash for integrity verification
|
||
enableRetroactiveAlerts bool default false
|
||
enteredByUserId Guid? set on first draft save
|
||
verifiedByUserId Guid? set on verification pass
|
||
approvedByUserId Guid? set on final approval
|
||
rejectionReason string? required when status → REJECTED
|
||
promotedAt DateTimeOffset? timestamp when live records created
|
||
promotionEncounterId Guid? VigilCareClinical encounter ID after promotion
|
||
supersedesBatchId Guid? links a correction batch to the batch it replaces
|
||
clinicianAttestation bool true for Track B batches attested at bedside
|
||
createdAt DateTimeOffset
|
||
updatedAt DateTimeOffset
|
||
```
|
||
|
||
### DraftPatient
|
||
|
||
```
|
||
id Guid PK
|
||
batchId Guid FK → DigitizationBatch
|
||
fullName string? required for patient_registration on submit
|
||
dateOfBirth DateOnly? required for patient_registration on submit
|
||
sex string? required for patient_registration on submit
|
||
bloodType string? A+ | A- | B+ | B- | AB+ | AB- | O+ | O-
|
||
emergencyContact string?
|
||
allergiesJson string? JSON array
|
||
noKnownAllergies bool
|
||
medicationsJson string? JSON array (for medication_list batches)
|
||
noActiveMedications bool
|
||
createdAt DateTimeOffset
|
||
updatedAt DateTimeOffset
|
||
```
|
||
|
||
### DraftEncounter
|
||
|
||
```
|
||
id Guid PK
|
||
batchId Guid FK → DigitizationBatch
|
||
admissionDate DateTimeOffset?
|
||
department string? ICU, ED, MedSurg, etc.
|
||
roomBed string? ward/bed assignment
|
||
admissionReason string?
|
||
dischargeDiagnosis string?
|
||
status string? encounter status from chart
|
||
createdAt DateTimeOffset
|
||
updatedAt DateTimeOffset
|
||
```
|
||
|
||
### DraftObservation
|
||
|
||
```
|
||
id Guid PK
|
||
batchId Guid FK → DigitizationBatch
|
||
observationCode string required (e.g. HEART_RATE, TEMP_C)
|
||
value decimal required — validated against plausibility ranges
|
||
unit string required (e.g. bpm, °C, mmHg)
|
||
recordedAt DateTimeOffset required — from the paper chart
|
||
note string? optional transcription note
|
||
createdAt DateTimeOffset
|
||
```
|
||
|
||
### ScannedDocument
|
||
|
||
```
|
||
id Guid PK
|
||
batchId Guid FK → DigitizationBatch
|
||
objectKey string MinIO object key
|
||
sha256 string content hash
|
||
contentType string application/pdf | image/jpeg | image/png
|
||
fileSizeBytes long
|
||
uploadedAt DateTimeOffset
|
||
```
|
||
|
||
### DigitizationEvent
|
||
|
||
```
|
||
id Guid PK
|
||
batchId Guid FK → DigitizationBatch
|
||
eventType string uploaded | entry_started | submitted_for_verification | rejected | verified | awaiting_clinical_approval | approved | promoted | correction_uploaded | correction_promoted | superseded | ...
|
||
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
|
||
aggregateType string e.g. Observation
|
||
aggregateId Guid FK → the created entity
|
||
payloadJson string full event payload for downstream consumers
|
||
createdAt DateTimeOffset
|
||
processedAt DateTimeOffset? set when consumed
|
||
retryCount int default 0
|
||
```
|
||
|
||
### IdempotencyRecord
|
||
|
||
```
|
||
id Guid PK
|
||
idempotencyKey string required, unique (from Idempotency-Key header)
|
||
operationName string e.g. batch_promote
|
||
resourceId Guid the batch ID
|
||
httpStatusCode int original response code
|
||
responseBodyJson string serialized original response
|
||
createdAt DateTimeOffset
|
||
expiresAt DateTimeOffset 24-hour TTL
|
||
```
|
||
|
||
### User
|
||
|
||
```
|
||
id Guid PK
|
||
username string required, unique
|
||
passwordHash string required (BCrypt)
|
||
fullName string required
|
||
role string INTAKE_CLERK | DATA_ENTRY_CLERK | VERIFIER | CLINICAL_APPROVER | CLINICIAN | ADMINISTRATOR
|
||
isActive bool default true
|
||
createdAt DateTimeOffset
|
||
lastLoginAt DateTimeOffset?
|
||
```
|
||
|
||
### RefreshToken
|
||
|
||
```
|
||
id Guid PK
|
||
token string required, unique — opaque base64 token
|
||
userId Guid FK → User
|
||
expiresAt DateTimeOffset required
|
||
createdAt DateTimeOffset
|
||
revokedAt DateTimeOffset? — set on refresh rotation or explicit logout
|
||
```
|
||
|
||
### AuthAuditEvent
|
||
|
||
```
|
||
id Guid PK
|
||
eventType string LOGIN | LOGOUT | TOKEN_REFRESHED | LOGIN_FAILED
|
||
userId Guid?
|
||
username string?
|
||
ipAddress string?
|
||
occurredAt DateTimeOffset
|
||
```
|
||
|
||
---
|
||
|
||
## Pagination
|
||
|
||
List endpoints use offset pagination:
|
||
|
||
| Param | Default | Description |
|
||
|---|---|---|
|
||
| `page` | 1 | Page number (1-based) |
|
||
| `pageSize` | 20 | Items per page |
|
||
|
||
Response shape:
|
||
|
||
```json
|
||
{
|
||
"items": [],
|
||
"page": 1,
|
||
"pageSize": 20,
|
||
"totalCount": 42,
|
||
"totalPages": 3
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Implemented Phases
|
||
|
||
Five phases from the project roadmap are implemented and verified. Integration tests (`dotnet test`) and per-phase verification scripts cover Phases 1–5.
|
||
|
||
| Phase | Feature | Status |
|
||
|---|---|---|
|
||
| 1 | Schema, EF Core migrations, JWT authentication with refresh tokens, six user roles, batch CRUD, MinIO upload with SHA-256 and presigned URLs, batch status machine with transition matrix, duplicate document detection, Redis batch assignment locks, twelve seeded demo users, auth audit events | Done |
|
||
| 2 | Draft data entry API (patient demographics, encounter context, observations), plausibility validation on observation values, batch-type completeness validation on submit-for-verification, automatic `UPLOADED → IN_ENTRY` transition on first save, assignment guard (`BATCH_NOT_ASSIGNED`), `DraftEntryTests` integration tests | Done |
|
||
| 3 | Verification with field-level checks (`ok`, `warning`, `error` per field), rejection with mandatory reason (min 10 chars), separation of duties enforcement (`SEPARATION_OF_DUTIES_VIOLATION`), site-configurable clinical approval routing per batch type, work queue endpoints (verification, entry, clinical approval) with role-based access, `VerificationTests` integration tests | Done |
|
||
| 4 | Approval 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 with clinician attestation, synchronous alert evaluation | Planned |
|
||
| 7 | Digitization workstation UI (Vue 3 side-by-side scan viewer + entry form) | Planned |
|
||
| 8 | Prometheus metrics, supervisor dashboard, promotion retry job | Planned |
|
||
| 9 | Seed data, E2E verification script, clinical scenario documentation | Planned |
|