Compare commits

..
10 Commits
Author SHA1 Message Date
voltsrage c871dc4842 Add deployment files
CI / frontend (push) Failing after 53s
CI / backend (push) Failing after 2m26s
2026-08-11 20:17:53 +08:00
voltsrage a4faf7eadd update prd 2026-06-28 02:06:58 +08:00
voltsrage 68a8fe8af1 chore: update readme 2026-06-28 01:59:31 +08:00
voltsrage c26ef22670 fix: Optional OCR-Assisted Draft Pre-Fill 2026-06-28 01:48:15 +08:00
voltsrage c160c5f912 feature: Optional OCR-Assisted Draft Pre-Fill 2026-06-28 01:28:54 +08:00
voltsrage 5039dbb979 feature: Backend as Single Source of Truth for Batch-Type Field Requirements 2026-06-27 23:47:56 +08:00
voltsrage 4c28bffcc9 chore: update readme 2026-06-27 22:35:47 +08:00
voltsrage 5646dfddb4 feature: HL7 FHIR R4 Integration 2026-06-27 22:23:45 +08:00
voltsrage 756cff332c feature: Barcode/QR Cover Sheet System 2026-06-27 21:50:32 +08:00
voltsrage 5db60f46eb fix: MetricsCollectorService does not track APPROVED or retry-pending batches 2026-06-27 20:58:57 +08:00
130 changed files with 17082 additions and 193 deletions
+28
View File
@@ -0,0 +1,28 @@
# .NET build output
**/bin/
**/obj/
**/out/
**/publish/
**/TestResults/
# Node
**/node_modules/
**/dist/
# Env / secrets
.env
.env.*
!.env.example
# VCS / IDE
.git/
.gitea/
.vscode/
.idea/
*.user
# Docs / misc noise not needed in build context
docs/
scripts/
README.md
*.md
+50
View File
@@ -0,0 +1,50 @@
# Copy this file to /opt/vigilcare-records/.env on the deploy host (chmod 600).
# CD never uploads or overwrites this file — only the IMAGE_TAG line is patched
# automatically on each release. See docs/26-vigilcare-records-cicd.md.
# ---- image coordinates ----
REGISTRY=git.vectur45.com/trent/vigilcare-records
IMAGE_TAG=v1.0.0
# ---- exposed ports on the production host ----
API_PORT=5217
DASHBOARD_PORT=8089
DASHBOARD_ORIGIN=https://vigilcare-records.vectur45.com
# ---- PostgreSQL ----
# VigilCare Records shares one PostgreSQL instance with VigilCareClinical on the
# same host (see docs/vigilcare-records-clinical-overview.md — "Integrated Database
# Deployment"). "postgres" below is the service name on the "shared-services"
# Docker network already created by VigilCareClinical's own compose project; that
# stack MUST be up before the first `docker compose -f docker-compose.prod.yml up`.
# Runtime (DML-only) connection used by the API container.
PG_CONNECTION="Host=postgres;Port=5432;Database=vigilcare_records;Username=vigilcare_records_app;Password=CHANGE_ME;SSL Mode=Disable"
# DDL-privileged connection used ONLY by the EF migration bundle (CD migrate job).
# That job runs on the act_runner host directly (not joined to shared-services), so
# it needs the externally-routable host:port, not the container network name.
# Never put this credential in the API container environment.
PG_CONNECTION_DDL="Host=postgres.site.com;Port=5432;Database=vigilcare_records;Username=vigilcare_records_migrator;Password=CHANGE_ME;SSL Mode=Require;Trust Server Certificate=false"
# ---- Redis (shared-services network) ----
# "redis" = the service name on the shared Redis compose project.
# Use a dedicated logical database so Records' batch-assignment locks never
# collide with VigilCareClinical's own Redis keys.
REDIS_CONNECTION=redis:6379,abortConnect=false,defaultDatabase=2
# ---- MinIO (shared-services network) ----
# "minio" = the service name on the shared MinIO compose project. Records stores
# scanned documents under its own bucket, separate from any clinical buckets.
MINIO_ENDPOINT=minio:9000
MINIO_ACCESS_KEY=admin
MINIO_SECRET_KEY=CHANGE_ME
MINIO_BUCKET_NAME=vigilcare-records-scans
MINIO_USE_SSL=false
# ---- Seq (shared-services network) ----
SEQ_URL=http://seq:80
SEQ_API_KEY=CHANGE_ME
# ---- application secrets (generate with: openssl rand -base64 48) ----
JWT_SECRET=CHANGE_ME_AT_LEAST_32_BYTES
JWT_ISSUER=VigilCareRecords
JWT_AUDIENCE=VigilCareRecords
+113
View File
@@ -0,0 +1,113 @@
# ---- image coordinates ----
REGISTRY=git.vectur45.com/trent/vigilcare-clinical
IMAGE_TAG=v1.0.0
# ---- exposed ports on the production host ----
API_PORT=5270
GATEWAY_PORT=5081
DASHBOARD_PORT=8088
DASHBOARD_ORIGIN=https://vigilcare-clinical.vectur45.com
# Gitea Actions var PROD_API_URL (dashboard build) — not read by compose:
# https://api.vigilcare-clinical.vectur45.com
# ---- PostgreSQL (container on the same VM, reached via the shared-service network) ----
# Runtime (DML-only) connection used by the API container.
# "postgres" = the service name in the Postgres compose project - rename to match it exactly.
# Port 5432 is the container's internal port, NOT the 5433 published on the host.
# SSL Mode=Disable: Postgres on the shared-services Docker network has no TLS.
# Use Require only when connecting to a TLS-enabled external Postgres.
PG_CONNECTION="Host=postgres;Port=5432;Database=vigilcare;Username=admin;Password=PartyHard753!;SSL Mode=Disable"
# DDL-privileged connection used ONLY by the EF migration bundle (Step 6 / CD migrate job).
# That job currently runs an act_runner container that is NOT joined to shared-service
# (see .gitea/workflows/cd.yml), so it MUST keep using the externally-routable host:port,
# not the container network name, unless that job is later attached to shared-service too.
# Never put this credential in the API container environment.
PG_CONNECTION_DDL="Host=postgres.site.com;Port=5432;Database=vigilcare;Username=admin;Password=PartyHard753!;SSL Mode=Require;Trust Server Certificate=false"
GATEWAY_PG_CONNECTION="Host=postgres;Port=5432;Database=vigilcare_ward;Username=admin;Password=PartyHard753!;SSL Mode=Disable"
# ---- Redis (container on the same VM, reached via the shared-service network) ----
# "redis" = the service name in the Redis compose project - rename to match it exactly.
# Port 6379 is the container's internal port; confirm it matches (it usually does).
REDIS_CONNECTION=redis:6379,abortConnect=false
GATEWAY_REDIS_CONNECTION=redis:6379,abortConnect=false,defaultDatabase=1
# ---- external Seq ----
# On vectur-home-server (Tailscale) - use the ingestion port (5341), not the web UI
# port (8080->80). No TLS is configured, so plain http, not https.
SEQ_URL=http://vectur-home-server:5341
# The compose only sets SEQ_FIRSTRUN_ADMINUSERNAME/PASSWORD for first-run login,
# it does not provision an API key. Generate one manually via Seq's web UI
# (Settings -> API Keys) after the container's first run, then paste it here.
SEQ_API_KEY=CHANGE_ME
# ---- external Kafka ----
# Single-broker cluster on vectur-home-server (Tailscale) - PLAINTEXT only, no SASL.
# Traffic relies on the Tailscale mesh for encryption in transit.
KAFKA_BOOTSTRAP=vectur-home-server:9092
KAFKA_REPLICATION_FACTOR=1
KAFKA_SECURITY_PROTOCOL=Plaintext
# ---- external Elasticsearch ----
# Unauthenticated cluster: leave ES_API_KEY / ES_USERNAME / ES_PASSWORD unset.
# Program.cs only attaches auth when those values are non-empty.
ES_URI=http://vectur-home-server:9200
ES_API_KEY=
# ES_USERNAME=
# ES_PASSWORD=
# ---- external RabbitMQ ----
# Plain AMQP on vectur-home-server (Tailscale) - only 5672 is exposed, no TLS listener.
# RABBITMQ_USERNAME/PASSWORD must match RABBITMQ_DEFAULT_USER/PASS in the RabbitMQ
# compose's own .env on vectur-home-server.
RABBITMQ_HOST=vectur-home-server
RABBITMQ_PORT=5672
RABBITMQ_USERNAME=admin
RABBITMQ_PASSWORD=2va2xcLWTAlRN4abEv3wa7EVawHXk+tnRkQVOOXqs2M=
RABBITMQ_USE_SSL=false
GATEWAY_RABBITMQ_HOST=vectur-home-server
GATEWAY_RABBITMQ_PORT=5672
GATEWAY_RABBITMQ_USERNAME=admin
GATEWAY_RABBITMQ_PASSWORD=2va2xcLWTAlRN4abEv3wa7EVawHXk+tnRkQVOOXqs2M=
GATEWAY_RABBITMQ_USE_SSL=false
# ---- external MinIO ----
# Host publishes the S3 API on 9002 (mapped to container's 9000), no TLS termination.
# MINIO_ACCESS_KEY/SECRET_KEY must match MINIO_ROOT_USER/MINIO_ROOT_PASSWORD in the
# MinIO compose's own .env on vectur-home-server.
MINIO_ENDPOINT=vectur-home-server:9002
MINIO_ACCESS_KEY=admin
MINIO_SECRET_KEY=p3QUh8mXvosfFjrJYJJPd36tGiPbsOASWdIe6FKzdLI=
MINIO_USE_SSL=false
# ---- application secrets (generate with: openssl rand -base64 48) ----
JWT_SIGNING_KEY=T0oK2f3YhBesoMgZnEB7vmi4Dfbd7LxtuemkXI8j3xA=
# WARNING: rotating PHI_SEARCH_TOKEN_KEY invalidates every stored patient
# search token. See docs/ops/phi-encryption-runbook.md before changing it.
PHI_SEARCH_TOKEN_KEY=tbL0Nku3+bK476bv4zmfyRBiiRMTTF3To4Qq9RUOSVg=
GATEWAY_API_KEY=ZxFKE4wUChEg+VzNGM16zFSuHeM+I+IQc59rIzN0U2g=
FHIR_API_KEY=wY29TLNIzLouIEKDu+XAxmZ3T1R5cjE2IIXxaYWpNAg=
GATEWAY_JWT_SIGNING_KEY=TfxbvLz992kr8simlbr8s61W5gKQbLqjyTuPjgCWjV0=
# ---- gateway identity ----
GATEWAY_ID=ce985c14-42de-4db4-8538-c2a203496d9e
GATEWAY_SITE_ID=e9fba67a-fcf5-4966-acb1-dab58a68bff2
GATEWAY_DEPARTMENT=ICU
GATEWAY_CODE=GW-ICU-1
GATEWAY_SITE_CODE=SITE-01
GATEWAY_SITE_NAME=Primary Site
# GATEWAY_SITE_ADDRESS=
# ---- production bootstrap users (API seeds these when missing; not demo accounts) ----
SEED_ADMIN_USERNAME=admin
SEED_ADMIN_PASSWORD="zx+yv8XtbxQq0E5YZ3d8kP5g"
SEED_ADMIN_DISPLAY_NAME=System Admin
SEED_NURSE_USERNAME=nurse
SEED_NURSE_PASSWORD="qcYtKfgLMezlT63AIxrPdmtK"
SEED_NURSE_DISPLAY_NAME=Charge Nurse
SEED_PHYSICIAN_USERNAME=physician
SEED_PHYSICIAN_PASSWORD="msKxpOQIGHK/StlrPIDBx9ZD"
SEED_PHYSICIAN_DISPLAY_NAME=Attending Physician
SIMULATION_ENABLED=true
SIMULATION_RUNNER_PASSWORD=tlIxrcgEEQKh9BYdjZ6/fWwj4TFoN4zTtCUfICrQpxI=
+174
View File
@@ -0,0 +1,174 @@
# Build images, migrate, deploy on version tags.
# Requires act_runner with docker, curl, ssh, scp, bash and label ubuntu-latest.
#
# Secrets: REGISTRY_USERNAME, REGISTRY_TOKEN, PG_CONNECTION_DDL,
# DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY
# Variables: REGISTRY (optional; defaults below)
name: CD
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
image_tag:
description: "Image tag to deploy (defaults to the pushed tag)"
required: false
env:
REGISTRY: git.vectur45.com/trent/vigilcare-records
jobs:
build-and-push:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.meta.outputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Resolve tag and registry
id: meta
run: |
if [ -n "${{ vars.REGISTRY }}" ]; then
echo "REGISTRY=${{ vars.REGISTRY }}" >> "$GITHUB_ENV"
fi
if [ -n "${{ inputs.image_tag }}" ]; then
echo "tag=${{ inputs.image_tag }}" >> "$GITHUB_OUTPUT"
elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
else
echo "image_tag input is required for workflow_dispatch without a tag" >&2
exit 1
fi
- name: Log in to the Gitea registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" \
| docker login "${REGISTRY%%/*}" \
-u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Build and push vigilcare-records-api
run: |
docker build -f VigilCareRecordsAPI/Dockerfile \
-t "${REGISTRY}/vigilcare-records-api:${{ steps.meta.outputs.tag }}" \
-t "${REGISTRY}/vigilcare-records-api:latest" .
docker push "${REGISTRY}/vigilcare-records-api:${{ steps.meta.outputs.tag }}"
docker push "${REGISTRY}/vigilcare-records-api:latest"
- name: Build and push vigilcare-records-dashboard
# Context is vigilcare-records-web/ — package.json and nginx.conf live there.
run: |
docker build -f vigilcare-records-web/Dockerfile \
-t "${REGISTRY}/vigilcare-records-dashboard:${{ steps.meta.outputs.tag }}" \
-t "${REGISTRY}/vigilcare-records-dashboard:latest" vigilcare-records-web
docker push "${REGISTRY}/vigilcare-records-dashboard:${{ steps.meta.outputs.tag }}"
docker push "${REGISTRY}/vigilcare-records-dashboard:latest"
migrate:
needs: build-and-push
runs-on: ubuntu-latest
# Checkout must run on the job host. Build the EF bundle via Dockerfile
# --target migrate (context upload), not docker run -v — under act_runner
# bind mounts resolve on the Docker host, not the job workspace.
# NOTE: Program.cs also runs db.Database.MigrateAsync() on API startup, so
# this job is a defense-in-depth pre-deploy step using a DDL-privileged
# credential the API container never sees, not the only migration path.
steps:
- uses: actions/checkout@v4
- name: Build migration bundle
run: |
docker build -f VigilCareRecordsAPI/Dockerfile --target migrate \
-t vigilcare-records-migrate-bundle:local .
cid=$(docker create vigilcare-records-migrate-bundle:local)
docker cp "$cid:/out/migrate-api" ./migrate-api
docker rm "$cid"
chmod +x ./migrate-api
# Runs while the previous release is still serving traffic, so every
# migration must be backwards-compatible with the outgoing image
# (expand-then-contract). Self-contained linux-x64 binary — runs on the
# job host directly.
- name: Apply migrations
run: ./migrate-api --connection "${{ secrets.PG_CONNECTION_DDL }}"
deploy:
needs: [build-and-push, migrate]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
- name: Copy compose file
run: |
scp -i ~/.ssh/id_ed25519 docker-compose.prod.yml \
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/opt/vigilcare-records/docker-compose.prod.yml"
- name: Deploy
env:
IMAGE_TAG: ${{ needs.build-and-push.outputs.tag }}
run: |
ssh -i ~/.ssh/id_ed25519 \
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
IMAGE_TAG="$IMAGE_TAG" bash -euo pipefail <<'EOF'
cd /opt/vigilcare-records
# Record the currently deployed tag so a rollback has a target.
grep '^IMAGE_TAG=' .env > .env.previous || true
if grep -q '^IMAGE_TAG=' .env; then
sed -i "s|^IMAGE_TAG=.*|IMAGE_TAG=${IMAGE_TAG}|" .env
else
echo "IMAGE_TAG=${IMAGE_TAG}" >> .env
fi
docker compose -f docker-compose.prod.yml --env-file .env pull
docker compose -f docker-compose.prod.yml --env-file .env up -d --remove-orphans
docker image prune -f
EOF
- name: Smoke test
run: |
ssh -i ~/.ssh/id_ed25519 \
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" bash -euo pipefail <<'EOF'
cd /opt/vigilcare-records
# Do not `source` .env — compose env files are not bash (semicolons,
# spaces in "SSL Mode=...", CRLF). Read only the host ports we need.
env_val() { sed -n "s/^${1}=//p" .env | tail -n1 | tr -d '\r'; }
API_PORT="$(env_val API_PORT)"; API_PORT="${API_PORT:-5217}"
DASHBOARD_PORT="$(env_val DASHBOARD_PORT)"; DASHBOARD_PORT="${DASHBOARD_PORT:-8089}"
for i in $(seq 1 30); do
if curl -fsS "http://localhost:${API_PORT}/health/ready" >/dev/null; then
echo "Ready check passed."
curl -fsS "http://localhost:${DASHBOARD_PORT}/" >/dev/null && echo "Dashboard serving."
exit 0
fi
sleep 5
done
echo "Ready check never passed — dumping API logs:"
docker compose -f docker-compose.prod.yml --env-file .env logs --tail 100 api
exit 1
EOF
- name: Roll back on failure
if: failure()
run: |
ssh -i ~/.ssh/id_ed25519 \
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" bash -euo pipefail <<'EOF'
cd /opt/vigilcare-records
# Restores the previous image tag only. Schema changes are NOT
# reverted — this is why migrations must be backwards-compatible.
if [ -f .env.previous ]; then
PREV=$(cut -d= -f2 .env.previous)
sed -i "s|^IMAGE_TAG=.*|IMAGE_TAG=${PREV}|" .env
docker compose -f docker-compose.prod.yml --env-file .env up -d
echo "Rolled back to ${PREV}"
fi
EOF
+95
View File
@@ -0,0 +1,95 @@
# Build and test on every push/PR. Fixtures read ConnectionStrings__* /
# Redis__* directly from environment (see VigilCareRecordsAPI.Tests/Fixtures/
# ApiFixture.cs) against the ports published by docker-compose.yml; MinIO is
# left at its docker-compose.yml defaults (localhost:9012, minioadmin/minioadmin,
# bucket auto-created on first upload by DocumentStorageService).
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start dependency stack
run: docker compose up -d postgres redis minio
- name: Wait for Postgres
run: |
for i in $(seq 1 60); do
docker compose exec -T postgres pg_isready -U postgres && break
sleep 2
done
docker compose exec -T postgres pg_isready -U postgres
- name: Wait for Redis / MinIO
run: |
for i in $(seq 1 60); do
docker compose exec -T redis redis-cli ping 2>/dev/null | grep -q PONG \
&& docker compose exec -T minio curl -sf http://localhost:9000/minio/health/live >/dev/null \
&& echo "Redis and MinIO are ready" && exit 0
echo "waiting for dependencies ($i)..."
sleep 2
done
echo "Dependencies failed to become ready" >&2
docker compose ps
docker compose logs --tail=50 redis minio
exit 1
- name: Create test database
run: |
docker compose exec -T postgres psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname='vigilcare_records_test'" \
| grep -q 1 \
|| docker compose exec -T postgres psql -U postgres -c "CREATE DATABASE vigilcare_records_test"
- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- name: Restore
run: dotnet restore VigilCareRecords.sln
- name: Build
run: dotnet build VigilCareRecords.sln -c Release --no-restore
- name: Test
env:
ASPNETCORE_ENVIRONMENT: "Testing"
run: |
dotnet test VigilCareRecords.sln -c Release --no-build \
--logger "trx;LogFileName=test-results.trx" \
--results-directory ./TestResults
- name: Publish test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: ./TestResults
- name: Tear down stack
if: always()
run: docker compose down -v
frontend:
runs-on: ubuntu-latest
container:
image: node:22-alpine
steps:
- uses: actions/checkout@v4
- name: Install
working-directory: vigilcare-records-web
run: npm ci
- name: Test
working-directory: vigilcare-records-web
run: npm run test:run
- name: Build
working-directory: vigilcare-records-web
run: npm run build
+159 -31
View File
@@ -2,7 +2,7 @@
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. 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 19 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)). 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. See [Implemented Phases](#implemented-phases) and the [gap analysis](docs/vigilcare-records-gap-analysis.md) for remaining work. **Implementation status:** Phases 113 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. Phase 11 adds an HL7 FHIR R4 read-only API for promoted clinical data (`Patient`, `Encounter`, `Observation`) with LOINC code mapping, search bundles with pagination links, `$everything`, and an administrator FHIR Explorer view at `/fhir-explorer`. Phase 12 makes the backend the single source of truth for batch-type field requirements — `fieldRequirements` metadata on batch and draft responses drives which entry/verification form sections render (allergies, medications, encounter summary, observations). Phase 13 adds optional OCR-assisted draft pre-fill (`Ocr:Enabled`, Azure Document Intelligence or self-hosted Tesseract), confidence scoring on draft fields, and UI confidence indicators; disabled by default. 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, assignment-time `IN_ENTRY` transitions, API-proxied document streaming for the scan viewer, and CORS for production frontend origins. 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 ## Domain Model — How It Maps to a Real Clinical System
@@ -23,7 +23,7 @@ The unit of work for one digitization effort — typically one scanned document
### DraftPatient ### 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. Structured patient fields extracted from paper — full name, date of birth, sex, blood type, allergies, emergency contact, medications. Stored in PostgreSQL as JSON columns (`allergies_json`, `medications_json`); the draft API exposes them as `allergies` and `medications` string arrays on read/write. On approval of a `PATIENT_REGISTRATION` or `ALLERGY_UPDATE` batch, merges into VigilCareClinical's live `Patient` record.
### DraftEncounter ### DraftEncounter
@@ -45,7 +45,8 @@ Append-only audit log entry for every state transition, field-level correction,
## Features ## Features
- **Document Upload and Batch Creation** — multipart upload (PDF, JPEG, PNG; max 25 MB) with MinIO storage, SHA-256 integrity hash, presigned GET URLs (15-minute expiry); duplicate detection per patient within 24 hours by SHA-256 (`409 DUPLICATE_DOCUMENT`); cross-patient duplicate scans allowed; optional `supersedesBatchId` creates a correction batch linked to a promoted batch; batch created in `UPLOADED` status with `DigitizationEvent` audit trail - **Document Upload and Batch Creation** — multipart upload (PDF, JPEG, PNG; max 25 MB) with MinIO storage (object key `scans/{year}/{month}/{batchId}/{sha256}.{ext}`), SHA-256 integrity hash, presigned GET URLs (15-minute expiry on batch detail); 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 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 - **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`) - **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`)
@@ -60,8 +61,12 @@ Append-only audit log entry for every state transition, field-level correction,
- **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 - **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 - **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 - **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 - **Batch-Type Field Requirements** — `BatchTypeFieldRequirements` metadata on `GET /digitization-batches/{id}` and `GET /digitization-batches/{id}/draft` tells the workstation which form sections to show per batch type (patient demographics, encounter context, encounter summary fields, observations, allergies, medications); entry and verification forms read this metadata instead of hardcoding batch-type rules
- **Digitization Workstation UI** — Vue 3 SPA at `vigilcare-records-web`: role-based routing and nav (intake, entry, verification, clinical approval, live capture, patient history, supervisor dashboard); split-pane scan viewer with zoom/pan/rotate; 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 - **Document Access Audit** — `GET /digitization-batches/:id` and `GET /digitization-batches/:id/document` write a `document_accessed` digitization event (deduplicated per user within 5 minutes) whenever a scan is retrieved
- **Digitization Workstation UI** — Vue 3 SPA at `vigilcare-records-web` (dev server port **3028**): role-based routing and nav (intake, cover sheets, entry, verification, clinical approval, live capture, patient history, supervisor dashboard, FHIR Explorer); split-pane scan viewer with zoom/pan/rotate (loads scans via authenticated `GET /digitization-batches/:id/document` blob URLs — avoids cross-origin MinIO iframe issues); 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 backend-driven field visibility (allergies, medications, discharge diagnosis); optional OCR confidence indicators when Phase 13 OCR is enabled; 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; JWT refresh interceptor and toast notifications
- **Optional OCR-Assisted Pre-Fill (Phase 13)** — background `OcrProcessingService` polls uploaded batches when `Ocr:Enabled` is true; extracts text via Azure Document Intelligence or self-hosted Tesseract; pre-fills draft patient/encounter/observation fields with per-field confidence scores returned as `ocrConfidence` on the draft payload; entry clerks review and correct — OCR does not skip verification; disabled by default in `appsettings.json`
- **HL7 FHIR R4 Read API** — read-only FHIR endpoints at `/fhir` for promoted clinical data: `GET /fhir/metadata` (anonymous `CapabilityStatement`); authenticated read and search for `Patient`, `Encounter`, and `Observation`; MRN search via `Patient?identifier=`; LOINC bidirectional mapping for observation codes (e.g. `HEART_RATE``8867-4`); vital-signs category search; date filtering on `recordedAt`; `GET /fhir/Patient/{id}/$everything` composite bundle; search bundles with `self`/`next` pagination links; `404` responses as FHIR `OperationOutcome`; `application/fhir+json` content negotiation via `FhirJsonOutputFormatter`
- **FHIR Explorer UI** — administrator-only Vue view at `/fhir-explorer`: browse Patient/Encounter/Observation resources, run FHIR searches, inspect raw JSON, load Patient `$everything`, and open the CapabilityStatement metadata link; dev proxy at `/fhir` → API port 5217
- **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` - **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` - **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`) - **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`)
@@ -99,7 +104,11 @@ HTTP request
├── UserDirectoryService (active user listing, create/update/deactivate, password reset/change) ├── UserDirectoryService (active user listing, create/update/deactivate, password reset/change)
├── AttestationService (clinician role + password re-confirm for live capture) ├── AttestationService (clinician role + password re-confirm for live capture)
├── LiveCaptureService (Track B synchronous promotion + critical alert evaluation) ├── LiveCaptureService (Track B synchronous promotion + critical alert evaluation)
├── DocumentStorageService (MinIO upload, SHA-256, presigned URLs) ├── CoverSheetService (generate, lookup, redeem, list cover sheets)
├── CoverSheetPdfGenerator (printable PDF with QR codes)
├── FhirService (FHIR R4 read/search/$everything over promoted clinical tables)
├── OcrProcessingService + IOcrService (optional Azure/Tesseract draft pre-fill when enabled)
├── DocumentStorageService (MinIO upload, SHA-256, presigned URLs, download stream)
├── PlausibilityValidator (per-code numeric range guard) ├── PlausibilityValidator (per-code numeric range guard)
├── PostgreSQL (EF Core — writes, keyed reads, clinical schema for live tables) ├── PostgreSQL (EF Core — writes, keyed reads, clinical schema for live tables)
├── Redis (batch assignment locks) ├── Redis (batch assignment locks)
@@ -143,6 +152,8 @@ HTTP request
| Logging | Serilog + Seq sink | | Logging | Serilog + Seq sink |
| Metrics | Prometheus (`prometheus-net`) + Grafana | | Metrics | Prometheus (`prometheus-net`) + Grafana |
| Docs | Swagger / OpenAPI (Swashbuckle) | | Docs | Swagger / OpenAPI (Swashbuckle) |
| Barcode / PDF | QRCoder (cover sheet QR codes; raw PDF generation) |
| FHIR | Hl7.Fhir.R4 5.x (Firely SDK — Patient, Encounter, Observation, Bundle, OperationOutcome) |
| Testing | xUnit + FluentAssertions + WebApplicationFactory | | Testing | xUnit + FluentAssertions + WebApplicationFactory |
--- ---
@@ -153,11 +164,13 @@ HTTP request
VigilCareRecords/ VigilCareRecords/
├── VigilCareRecordsAPI/ ├── VigilCareRecordsAPI/
│ ├── Program.cs # Service registration, middleware, seed on startup │ ├── Program.cs # Service registration, middleware, seed on startup
│ ├── appsettings.json # Connection strings, Redis, MinIO, Seq, JWT, site config │ ├── appsettings.json # Connection strings, Redis, MinIO, Seq, JWT, site config, OCR (disabled by default)
│ ├── Controllers/ │ ├── Controllers/
│ │ ├── ApprovalController.cs # Batch approval, promotion deferral (202), promotion to live tables │ │ ├── ApprovalController.cs # Batch approval, promotion deferral (202), promotion to live tables
│ │ ├── AuthController.cs # JWT login, token refresh, logout, authenticated user profile │ │ ├── AuthController.cs # JWT login, token refresh, logout, authenticated user profile
│ │ ├── DigitizationBatchesController.cs # Batch CRUD, document upload, assignment, events, promote │ │ ├── CoverSheetController.cs # Cover sheet generate, lookup, list, PDF export
│ │ ├── Fhir/ # FHIR R4 read/search controllers (Patient, Encounter, Observation, metadata)
│ │ ├── DigitizationBatchesController.cs # Batch CRUD, document upload/stream, assignment, events, promote
│ │ ├── DraftController.cs # Draft data entry: patient, encounter, observations, submit │ │ ├── DraftController.cs # Draft data entry: patient, encounter, observations, submit
│ │ ├── PatientsController.cs # Patient search and digitization history │ │ ├── PatientsController.cs # Patient search and digitization history
│ │ ├── UsersController.cs # User directory and admin user management │ │ ├── UsersController.cs # User directory and admin user management
@@ -166,22 +179,24 @@ VigilCareRecords/
│ │ └── WorkQueueController.cs # Work queues and supervisor overview │ │ └── WorkQueueController.cs # Work queues and supervisor overview
│ ├── HealthChecks/MinioHealthCheck.cs # MinIO bucket-exists readiness probe │ ├── HealthChecks/MinioHealthCheck.cs # MinIO bucket-exists readiness probe
│ ├── Domain/ … # Entities, enums (batch, draft, clinical, user) │ ├── Domain/ … # Entities, enums (batch, draft, clinical, user)
│ ├── Services/ … # Auth, batch, draft, verification, promotion, work queue, patient registry, user directory, live capture │ ├── Services/ … # Auth, batch, draft, verification, promotion, FHIR, work queue, patient registry, user directory, live capture
│ ├── Infrastructure/Fhir/ … # FHIR mappers, JSON formatter, OperationOutcome helpers
│ ├── Models/Records/ … # Request/response DTOs │ ├── Models/Records/ … # Request/response DTOs
│ ├── Data/ … # EF Core context, configurations, migrations, seed │ ├── Data/ … # EF Core context, configurations, migrations, seed
│ └── … # Middleware, Common, Infrastructure │ └── … # Middleware, Common, Infrastructure
├── vigilcare-records-web/ # Vue 3 digitization workstation UI ├── vigilcare-records-web/ # Vue 3 digitization workstation UI
│ ├── src/ │ ├── src/
│ │ ├── api/client.ts # Axios client with JWT interceptor and proactive refresh │ │ ├── api/client.ts # Axios client with JWT interceptor and proactive refresh
│ │ ├── api/fhirClient.ts # Authenticated FHIR GET client (`Accept: application/fhir+json`)
│ │ ├── stores/ # Pinia: auth, batches, liveCapture │ │ ├── stores/ # Pinia: auth, batches, liveCapture
│ │ ├── router/index.ts # Role-based routes and navigation guards │ │ ├── router/index.ts # Role-based routes and navigation guards
│ │ ├── views/ # Login, Intake, Entry, Verification, Approval, LiveCapture, PatientHistory, QueueDashboard │ │ ├── views/ # Login, Intake, CoverSheets, Entry, Verification, Approval, LiveCapture, PatientHistory, QueueDashboard, FhirExplorer
│ │ ├── components/ # ScanViewer, EntryForm, VerificationForm, BatchList, PatientSearch, AppHeader, ToastContainer, AssignClerkDialog │ │ ├── components/ # ScanViewer, EntryForm, VerificationForm, BatchList, PatientSearch, AppHeader, ToastContainer, AssignClerkDialog
│ │ ├── composables/ # usePresignedUrl (URL refresh), useToast (notifications) │ │ ├── composables/ # usePresignedUrl (document blob load), useOcrFieldConfidence, useToast
│ │ └── types/index.ts # TypeScript interfaces matching API response shapes │ │ └── types/index.ts # TypeScript interfaces matching API response shapes
│ ├── vite.config.ts # Dev server on port 3028; proxies /api → localhost:5217 │ ├── vite.config.ts # Dev server on port 3028; proxies /api and /fhir → localhost:5217
│ └── tailwind.config.js # Clinical color palette and layout component classes │ └── tailwind.config.js # Clinical color palette and layout component classes
├── VigilCareRecordsAPI.Tests/ # Integration tests (Phases 19) ├── VigilCareRecordsAPI.Tests/ # Integration tests (Phases 113)
├── prometheus.yml # Prometheus scrape config (API on host.docker.internal:5217) ├── prometheus.yml # Prometheus scrape config (API on host.docker.internal:5217)
├── docker-compose.yml # PostgreSQL, Redis, Seq, MinIO, Prometheus, Grafana ├── docker-compose.yml # PostgreSQL, Redis, Seq, MinIO, Prometheus, Grafana
├── scripts/ ├── scripts/
@@ -192,9 +207,13 @@ VigilCareRecords/
│ ├── run-vigilcare-records-phase-5-verification.sh │ ├── run-vigilcare-records-phase-5-verification.sh
│ ├── run-vigilcare-records-phase-6-verification.sh │ ├── run-vigilcare-records-phase-6-verification.sh
│ ├── run-vigilcare-records-phase-8-verification.sh # Prometheus metrics, overview, events, retry │ ├── 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-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, LOINC mapping
│ ├── run-vigilcare-records-phase-13-verification.sh # OCR config, ocrConfidence API, optional live OCR polling
│ └── fixtures/test-scan.pdf # Sample PDF for upload verification scripts
└── docs/ └── docs/
├── plans/ # Phase 19 implementation guides ├── plans/ # Phase 113 implementation guides
├── digitization-workstation-guide.md # Clinical scenarios and clerk workflow reference ├── digitization-workstation-guide.md # Clinical scenarios and clerk workflow reference
├── vigilcare-records-gap-analysis.md # Known gaps and hardening backlog ├── vigilcare-records-gap-analysis.md # Known gaps and hardening backlog
└── vigilcare-records-prd.md # Product requirements and phase roadmap └── vigilcare-records-prd.md # Product requirements and phase roadmap
@@ -359,16 +378,16 @@ npm install
npm run dev npm run dev
``` ```
Open `http://localhost:3028`. The Vite dev server proxies `/api` requests to the API on port 5217. Open `http://localhost:3028`. The Vite dev server proxies `/api` and `/fhir` requests to the API on port 5217.
| Username | Password | Default route | | Username | Password | Default route |
|---|---|---| |---|---|---|
| `intake1` | `password` | `/intake` — upload scans, assign entry clerks | | `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 | | `entry1` | `password` | `/entry` — data entry queue and split-pane form |
| `verifier1` | `password` | `/verification` — field-level verification | | `verifier1` | `password` | `/verification` — field-level verification |
| `approver1` | `password` | `/approval` — clinical sign-off before promotion | | `approver1` | `password` | `/approval` — clinical sign-off before promotion |
| `clinician1` | `password` | `/live-capture` — bedside vitals with attestation | | `clinician1` | `password` | `/live-capture` — bedside vitals with attestation |
| `admin1` | `password` | `/dashboard` — supervisor queue overview | | `admin1` | `password` | `/dashboard` — supervisor queue overview; `/fhir-explorer` — FHIR resource browser |
All roles can access `/patients` for patient search and digitization history. All roles can access `/patients` for patient search and digitization history.
@@ -385,6 +404,8 @@ 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). 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).
To enable optional OCR pre-fill (Phase 13), set `Ocr:Enabled` to `true` in `appsettings.json` or via environment variable (`Ocr__Enabled=true`), configure Azure credentials or install Tesseract data (`Ocr:Tesseract:DataPath`, default `/usr/share/tessdata`), then restart the API. Run `./scripts/run-vigilcare-records-phase-13-verification.sh` to verify; set `VIGILCARE_OCR_LIVE=1` for live OCR polling tests.
### Run Tests ### Run Tests
```bash ```bash
@@ -401,6 +422,9 @@ Integration tests use `WebApplicationFactory` with PostgreSQL, Redis, and MinIO
| `CorrectionSupersessionTests` | 5 | Correction batch supersession, validation guards (non-promoted, already superseded), patient digitization history, unknown patient 404 | | `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 | | `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 | | `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 |
| `FhirIntegrationTests` | 11 | FHIR metadata, Patient/Encounter/Observation read and search, LOINC mapping, `$everything`, content-type, 404 OperationOutcome, bundle pagination |
| `BatchOperationsTests` | — | Batch cancellation (status guards, Redis lock release), list/queue `sortBy`/`sortDirection` validation | | `BatchOperationsTests` | — | Batch cancellation (status guards, Redis lock release), list/queue `sortBy`/`sortDirection` validation |
### Verification Scripts ### Verification Scripts
@@ -416,8 +440,12 @@ With the API running (`dotnet run`) and Docker Compose up:
./scripts/run-vigilcare-records-phase-6-verification.sh # Phase 6 — live capture, attestation, critical alerts ./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-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-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 mapping, clinical fixture seed
``` ```
The Phase 11 script auto-seeds a minimal clinical fixture for `VCR-000001` when PostgreSQL is available (updates existing patients or inserts deterministic demo rows). Run `dotnet test --filter FullyQualifiedName~FhirIntegrationTests` separately for WebApplicationFactory integration coverage.
--- ---
## API Reference ## API Reference
@@ -492,7 +520,8 @@ Error response:
|---|---|---| |---|---|---|
| POST | `/digitization-batches` | Upload a scanned document and create a batch (multipart/form-data) | | 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` | 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` | | GET | `/digitization-batches/{id}` | Batch detail with presigned document URL (15-minute expiry) and `fieldRequirements`; audits `document_accessed` |
| GET | `/digitization-batches/{id}/document` | Stream scanned document (PDF/JPEG/PNG) for in-app viewing; same auth and audit as batch detail |
| PATCH | `/digitization-batches/{id}/assign` | Assign batch to an entry clerk (Redis lock; transitions to `IN_ENTRY`) | | 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 | `/digitization-batches/{id}/cancel` | Cancel batch permanently (administrator only; `UPLOADED`, `IN_ENTRY`, or `REJECTED`) |
@@ -501,10 +530,11 @@ Error response:
| Field | Type | Required | Description | | Field | Type | Required | Description |
|---|---|---|---| |---|---|---|---|
| `file` | binary | yes | PDF, JPEG, or PNG (max 25 MB) | | `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` | | `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` | | `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) | | `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 | | `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:** **Status codes:**
@@ -512,8 +542,8 @@ Error response:
|---|---| |---|---|
| 201 | Batch created | | 201 | Batch created |
| 400 | Empty file or invalid MIME type | | 400 | Empty file or invalid MIME type |
| 404 | Superseded batch not found (`SUPERSEDED_BATCH_NOT_FOUND`) | | 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`) | | 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`) | | 422 | Superseded batch not in `PROMOTED` status (`SUPERSEDED_BATCH_NOT_PROMOTED`) |
**PATCH `/digitization-batches/{id}/assign` body:** **PATCH `/digitization-batches/{id}/assign` body:**
@@ -530,11 +560,35 @@ Error response:
**Status codes:** `409 ILLEGAL_STATUS_TRANSITION` when the batch is not in a cancellable status. **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 ### Draft Data Entry
| Method | Path | Description | | Method | Path | Description |
|---|---|---| |---|---|---|
| GET | `/digitization-batches/{id}/draft` | Full draft payload: patient, encounter, observations | | GET | `/digitization-batches/{id}/draft` | Full draft payload: `fieldRequirements`, `ocrConfidence`, patient, encounter, observations |
| PUT | `/digitization-batches/{id}/draft/patient` | Upsert draft patient demographics | | PUT | `/digitization-batches/{id}/draft/patient` | Upsert draft patient demographics |
| PUT | `/digitization-batches/{id}/draft/encounter` | Upsert draft encounter fields | | PUT | `/digitization-batches/{id}/draft/encounter` | Upsert draft encounter fields |
| POST | `/digitization-batches/{id}/draft/observations` | Add an observation row | | POST | `/digitization-batches/{id}/draft/observations` | Add an observation row |
@@ -542,6 +596,32 @@ Error response:
| DELETE | `/digitization-batches/{id}/draft/observations/{obsId}` | Remove an observation from draft | | 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` | | POST | `/digitization-batches/{id}/submit-for-verification` | Validate completeness and transition to `PENDING_VERIFICATION` |
**PUT `/digitization-batches/{id}/draft/patient` body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `fullName` | string | no | Patient full name |
| `dateOfBirth` | string (ISO date) | no | Date of birth; omit or send `null` when unknown — do not send empty string |
| `sex` | string | no | `male`, `female`, `other`, or `unknown` |
| `bloodType` | string | no | `A+`, `A-`, `B+`, `B-`, `AB+`, `AB-`, `O+`, `O-` |
| `emergencyContact` | string | no | Emergency contact info |
| `allergies` | string[] | no | Allergy list; omit or `null` when `noKnownAllergies` is true |
| `noKnownAllergies` | bool | no | Explicit NKA flag |
| `medications` | string[] | no | Medication list; omit or `null` when `noActiveMedications` is true |
| `noActiveMedications` | bool | no | Explicit no-medications flag |
**Entry form visibility by batch type (`fieldRequirements` on draft/batch detail):**
| batchType | Allergies section | Medications section | Observations | Encounter summary fields |
|---|---|---|---|---|
| `PATIENT_REGISTRATION` | — | — | — | — |
| `VITALS_SHEET` | — | — | yes | — |
| `LAB_RESULTS` | — | — | yes | — |
| `ALLERGY_UPDATE` | yes | — | — | — |
| `ENCOUNTER_SUMMARY` | — | — | — | yes |
| `MEDICATION_LIST` | — | yes | — | — |
| `MIXED` | yes | yes | yes | yes |
**Observation request body:** **Observation request body:**
| Field | Type | Required | Description | | Field | Type | Required | Description |
@@ -560,7 +640,7 @@ Error response:
| `ENCOUNTER_SUMMARY` | Linked patient; encounter with admission date, department, admission reason | | `ENCOUNTER_SUMMARY` | Linked patient; encounter with admission date, department, admission reason |
| `VITALS_SHEET` | Linked patient, encounter context, at least one observation with `recordedAt` | | `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) | | `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` | | `MEDICATION_LIST` | Linked patient; medications list with at least one entry or explicit `noActiveMedications: true` |
| `ALLERGY_UPDATE` | Linked patient, allergies list (may be empty with explicit `noKnownAllergies: 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 | | `MIXED` | Linked patient, encounter context, and at least one of: observation with `recordedAt`, or complete encounter summary |
@@ -825,10 +905,54 @@ Unauthenticated probe endpoints for orchestrators and load balancers:
Returns `503` when a required dependency is unhealthy. Returns `503` when a required dependency is unhealthy.
### HL7 FHIR R4 (Read-Only)
FHIR endpoints live at `/fhir` (not under `/api/v1`). Responses use `application/fhir+json`. Errors return FHIR `OperationOutcome` resources.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | `/fhir/metadata` | Anonymous | Server `CapabilityStatement` (supported resources, interactions, search params) |
| GET | `/fhir/Patient/{id}` | JWT | Read a promoted patient by logical ID |
| GET | `/fhir/Patient` | JWT | Search patients by `name`, `birthdate`, `identifier` (MRN); supports `_count`, `_offset` |
| GET | `/fhir/Patient/{id}/$everything` | JWT | Bundle of Patient + all Encounters + all Observations for the patient |
| GET | `/fhir/Encounter/{id}` | JWT | Read a promoted encounter by logical ID |
| GET | `/fhir/Encounter` | JWT | Search encounters by `patient`, `status`, `date`; supports `_count`, `_offset` |
| GET | `/fhir/Observation/{id}` | JWT | Read a promoted observation by logical ID |
| GET | `/fhir/Observation` | JWT | Search observations by `patient`, `code` (LOINC or VigilCare code), `date`, `category`, `encounter`; supports `_count`, `_offset` |
**Search notes:**
- Observation `code=8867-4` resolves LOINC to VigilCare `HEART_RATE` (and reverse mapping on read).
- `category=vital-signs` filters vital-sign observation codes (`HEART_RATE`, `TEMP_C`, blood pressure, etc.).
- Date parameters use FHIR prefixes (e.g. `date=ge2026-06-20`) against observation `recordedAt`.
- Search bundles include `self` and `next` links when paginating.
**Status codes:** `404` with `OperationOutcome` issue code `not-found` when a resource ID does not exist.
**UI:** Administrators can browse FHIR resources interactively at `http://localhost:3028/fhir-explorer`.
--- ---
## Data Models ## 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 ### DigitizationBatch
``` ```
@@ -863,9 +987,9 @@ dateOfBirth DateOnly? required for patient_registration on submit
sex string? required for patient_registration on submit sex string? required for patient_registration on submit
bloodType string? A+ | A- | B+ | B- | AB+ | AB- | O+ | O- bloodType string? A+ | A- | B+ | B- | AB+ | AB- | O+ | O-
emergencyContact string? emergencyContact string?
allergiesJson string? JSON array allergiesJson string? JSON array (DB column; API read/write uses `allergies` string[])
noKnownAllergies bool noKnownAllergies bool
medicationsJson string? JSON array (for medication_list batches) medicationsJson string? JSON array (DB column; API read/write uses `medications` string[])
noActiveMedications bool noActiveMedications bool
createdAt DateTimeOffset createdAt DateTimeOffset
updatedAt DateTimeOffset updatedAt DateTimeOffset
@@ -1149,7 +1273,7 @@ Response shape:
## Implemented Phases ## Implemented Phases
Phases 19 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. See [docs/vigilcare-records-gap-analysis.md](docs/vigilcare-records-gap-analysis.md) for the remaining backlog. Phases 113 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, API-proxied document streaming) is also in place. See [docs/vigilcare-records-gap-analysis.md](docs/vigilcare-records-gap-analysis.md) for the remaining backlog.
| Phase | Feature | Status | | Phase | Feature | Status |
|---|---|---| |---|---|---|
@@ -1159,7 +1283,11 @@ Phases 19 are fully implemented and verified via integration tests and per-ph
| 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 | | 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 | | 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 | | 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 | | 7 | Digitization workstation UI (`vigilcare-records-web`): Vue 3 + Pinia + Tailwind, role-based routing and nav, split-pane scan viewer (API-proxied document stream), backend-driven draft entry field visibility, verification checkboxes, clinical approval view, live capture view (new/existing encounter), patient history timeline, patient search, assign-clerk dialog, 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 | | 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 | | 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 |
| | 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 | | 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 |
| 11 | HL7 FHIR R4 read-only API: `FhirService`, Patient/Encounter/Observation mappers with LOINC mapping, read/search/`$everything` controllers, `CapabilityStatement` metadata, bundle pagination links, `FhirJsonOutputFormatter`, `FhirIntegrationTests`, administrator FHIR Explorer UI (`/fhir-explorer`), Phase 11 verification script | Done |
| 12 | Backend-driven batch-type field requirements: `BatchTypeFieldRequirements` on batch/draft responses, entry and verification forms consume `fieldRequirements` metadata instead of hardcoded batch-type switches | Done |
| 13 | Optional OCR-assisted draft pre-fill: `OcrProcessingService`, Azure/Tesseract providers, `ocrConfidence` on draft payload, UI confidence indicators, `OcrResult` entity, Phase 13 verification script (`run-vigilcare-records-phase-13-verification.sh`); disabled by default (`Ocr:Enabled=false`) | 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, `GET /digitization-batches/:id/document` scan streaming for workstation viewer | Done |
@@ -0,0 +1,143 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Tests for barcode-assisted batch creation via cover sheet codes.
/// </summary>
[Collection("Database")]
public class CoverSheetBatchTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private HttpClient _intakeClient = null!;
public CoverSheetBatchTests(ApiFixture fixture) => _fixture = fixture;
public async Task InitializeAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await DbResetHelper.ResetAsync(db);
await DataSeeder.SeedAsync(db);
_intakeClient = await AuthHelper.LoginAsync(_fixture, "intake1");
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Create_WithCoverSheetCode_CreatesBatchAndRedeemsCoverSheet()
{
var code = await GenerateCoverSheetCodeAsync("VITALS_SHEET", "BACKFILL");
var response = await UploadWithCoverSheetAsync(code);
response.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
var batchId = body.GetProperty("data").GetProperty("id").GetGuid();
body.GetProperty("data").GetProperty("batchType").GetString()
.Should().Be("VITALS_SHEET");
body.GetProperty("data").GetProperty("track").GetString()
.Should().Be("BACKFILL");
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var sheet = await db.CoverSheets.FirstAsync(c => c.Code == code);
sheet.IsUsed.Should().BeTrue();
sheet.BatchId.Should().Be(batchId);
sheet.UsedAt.Should().NotBeNull();
}
[Fact]
public async Task Create_WithUsedCoverSheetCode_Returns409()
{
var code = await GenerateCoverSheetCodeAsync("VITALS_SHEET", "BACKFILL");
var first = await UploadWithCoverSheetAsync(code);
first.EnsureSuccessStatusCode();
var second = await UploadWithCoverSheetAsync(code);
second.StatusCode.Should().Be(HttpStatusCode.Conflict);
var body = await second.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("error").GetProperty("code").GetString()
.Should().Be("COVER_SHEET_ALREADY_USED");
}
[Fact]
public async Task Create_WithUnknownCoverSheetCode_Returns404()
{
var response = await UploadWithCoverSheetAsync("VCR-CS-DEADBEEF");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("error").GetProperty("code").GetString()
.Should().Be("COVER_SHEET_NOT_FOUND");
}
[Fact]
public async Task Create_WithCoverSheetPreAssigned_AutoAssignsBatch()
{
Guid entryUserId;
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
entryUserId = await BatchSeedHelper.UserIdAsync(db, "entry1");
}
var code = await GenerateCoverSheetCodeAsync(
"LAB_RESULTS", "BACKFILL", assignToUserId: entryUserId);
var response = await UploadWithCoverSheetAsync(code);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("data").GetProperty("batchType").GetString()
.Should().Be("LAB_RESULTS");
body.GetProperty("data").GetProperty("enteredByUserId").GetGuid()
.Should().Be(entryUserId);
body.GetProperty("data").GetProperty("status").GetString()
.Should().Be("IN_ENTRY");
}
private async Task<string> GenerateCoverSheetCodeAsync(
string batchType,
string track,
Guid? assignToUserId = null)
{
var payload = new
{
count = 1,
batchType,
track,
assignToUserId
};
var response = await _intakeClient.PostAsJsonAsync("/api/v1/cover-sheets/generate", payload);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
return body.GetProperty("data")[0].GetProperty("code").GetString()!;
}
private async Task<HttpResponseMessage> UploadWithCoverSheetAsync(string coverSheetCode)
{
var fileContent = new ByteArrayContent(
System.Text.Encoding.ASCII.GetBytes(
$"%PDF-1.4\n%%EOF\n%test-{Guid.NewGuid()}"));
fileContent.Headers.ContentType =
new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
var formData = new MultipartFormDataContent
{
{ fileContent, "file", "test.pdf" },
{ new StringContent(coverSheetCode), "coverSheetCode" }
};
return await _intakeClient.PostAsync("/api/v1/digitization-batches", formData);
}
}
@@ -0,0 +1,154 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Tests for cover sheet PDF generation endpoints and the PDF builder.
/// </summary>
[Collection("Database")]
public class CoverSheetPdfTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private HttpClient _intakeClient = null!;
public CoverSheetPdfTests(ApiFixture fixture) => _fixture = fixture;
public async Task InitializeAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await DbResetHelper.ResetAsync(db);
await DataSeeder.SeedAsync(db);
_intakeClient = await AuthHelper.LoginAsync(_fixture, "intake1");
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public void Generate_ProducesValidPdfWithCoverSheetMetadata()
{
var sheet = new CoverSheet
{
Id = Guid.NewGuid(),
Code = "VCR-CS-A3F7B2D1",
BatchType = BatchType.VitalsSheet,
Track = BatchTrack.Backfill,
CreatedAt = new DateTimeOffset(2026, 6, 27, 12, 0, 0, TimeSpan.Zero),
Patient = new Patient
{
Id = Guid.NewGuid(),
FullName = "Maria Garcia",
Mrn = "MRN-12345"
},
AssignToUser = new User
{
Id = Guid.NewGuid(),
FullName = "Entry Clerk One"
}
};
var pdf = CoverSheetPdfGenerator.Generate(sheet);
var text = Encoding.ASCII.GetString(pdf);
text.Should().StartWith("%PDF-1.4");
text.Should().Contain("VCR-CS-A3F7B2D1");
text.Should().Contain("VITALS_SHEET");
text.Should().Contain("BACKFILL");
text.Should().Contain("Maria Garcia");
text.Should().Contain("MRN-12345");
text.Should().Contain("Entry Clerk One");
text.Should().Contain("2026-06-27");
text.Should().Contain("Attach to front of chart section");
text.Should().Contain("/Subtype /Image");
}
[Fact]
public void GenerateBatch_ProducesMultiPagePdf()
{
var sheets = new[]
{
new CoverSheet
{
Id = Guid.NewGuid(),
Code = "VCR-CS-11111111",
BatchType = BatchType.VitalsSheet,
Track = BatchTrack.Backfill,
CreatedAt = DateTimeOffset.UtcNow
},
new CoverSheet
{
Id = Guid.NewGuid(),
Code = "VCR-CS-22222222",
BatchType = BatchType.LabResults,
Track = BatchTrack.Backfill,
CreatedAt = DateTimeOffset.UtcNow
}
};
var pdf = CoverSheetPdfGenerator.GenerateBatch(sheets);
var text = Encoding.ASCII.GetString(pdf);
text.Should().Contain("/Count 2");
text.Should().Contain("VCR-CS-11111111");
text.Should().Contain("VCR-CS-22222222");
}
[Fact]
public async Task GeneratePdf_Endpoint_ReturnsPdfForCoverSheetId()
{
var sheetId = await GenerateCoverSheetIdAsync();
var response = await _intakeClient.PostAsync($"/api/v1/cover-sheets/{sheetId}/pdf", null);
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType!.MediaType.Should().Be("application/pdf");
var bytes = await response.Content.ReadAsByteArrayAsync();
var text = Encoding.ASCII.GetString(bytes);
text.Should().StartWith("%PDF-1.4");
text.Should().Contain("VCR-CS-");
}
[Fact]
public async Task GenerateBatchPdf_Endpoint_ReturnsMultiPagePdf()
{
var ids = new List<Guid>
{
await GenerateCoverSheetIdAsync(),
await GenerateCoverSheetIdAsync()
};
var response = await _intakeClient.PostAsJsonAsync(
"/api/v1/cover-sheets/batch-pdf",
new { coverSheetIds = ids });
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType!.MediaType.Should().Be("application/pdf");
var text = Encoding.ASCII.GetString(await response.Content.ReadAsByteArrayAsync());
text.Should().Contain("/Count 2");
}
[Fact]
public async Task GeneratePdf_UnknownId_Returns404()
{
var response = await _intakeClient.PostAsync(
$"/api/v1/cover-sheets/{Guid.NewGuid()}/pdf", null);
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
private async Task<Guid> GenerateCoverSheetIdAsync()
{
var response = await _intakeClient.PostAsJsonAsync(
"/api/v1/cover-sheets/generate",
new { count = 1, batchType = "VITALS_SHEET", track = "BACKFILL" });
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
return body.GetProperty("data")[0].GetProperty("id").GetGuid();
}
}
@@ -0,0 +1,298 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Integration tests for FHIR R4 read/search endpoints (Phase 11 Step 7).
/// </summary>
[Collection("Database")]
public class FhirIntegrationTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private HttpClient _client = null!;
private HttpClient _anonymousClient = null!;
public FhirIntegrationTests(ApiFixture fixture) => _fixture = fixture;
public async Task InitializeAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await DbResetHelper.ResetAsync(db);
await DataSeeder.SeedAsync(db);
await FhirClinicalSeedHelper.SeedAsync(db);
_client = await AuthHelper.LoginAsync(_fixture, "admin1");
_anonymousClient = _fixture.CreateClient();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Metadata_ReturnsCapabilityStatementWithSupportedResources()
{
var response = await GetFhirAsync("/fhir/metadata", authenticated: false);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
var resourceTypes = body.GetProperty("rest")[0]
.GetProperty("resource")
.EnumerateArray()
.Select(r => r.GetProperty("type").GetString())
.ToList();
resourceTypes.Should().Contain(new[] { "Patient", "Encounter", "Observation" });
}
[Fact]
public async Task ReadPatient_ReturnsFhirPatientWithMrnNameAndGender()
{
var response = await GetFhirAsync($"/fhir/Patient/{FhirClinicalSeedHelper.Patient1Id}");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("resourceType").GetString().Should().Be("Patient");
body.GetProperty("id").GetString()
.Should().Be(FhirClinicalSeedHelper.Patient1Id.ToString());
var identifier = body.GetProperty("identifier")[0];
identifier.GetProperty("value").GetString().Should().Be("VCR-000001");
body.GetProperty("name")[0].GetProperty("text").GetString()
.Should().Be("MARIA SANTOS");
body.GetProperty("gender").GetString().Should().Be("female");
body.GetProperty("birthDate").GetString().Should().Be("1978-03-15");
}
[Fact]
public async Task SearchPatients_ByName_ReturnsMatchingBundle()
{
var response = await GetFhirAsync("/fhir/Patient?name=Santos");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("type").GetString().Should().Be("searchset");
body.GetProperty("total").GetInt32().Should().BeGreaterThan(0);
body.GetProperty("entry").GetArrayLength().Should().BeGreaterThan(0);
body.GetProperty("entry")[0].GetProperty("resource")
.GetProperty("name")[0].GetProperty("text").GetString()
.Should().Contain("SANTOS");
}
[Fact]
public async Task SearchPatients_ByIdentifier_ReturnsPatientByMrn()
{
var response = await GetFhirAsync("/fhir/Patient?identifier=VCR-000001");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("entry").GetArrayLength().Should().Be(1);
body.GetProperty("entry")[0].GetProperty("resource")
.GetProperty("identifier")[0].GetProperty("value").GetString()
.Should().Be("VCR-000001");
}
[Fact]
public async Task ReadEncounter_ReturnsFhirEncounterWithStatusAndPatientReference()
{
var response = await GetFhirAsync($"/fhir/Encounter/{FhirClinicalSeedHelper.Encounter1Id}");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("resourceType").GetString().Should().Be("Encounter");
body.GetProperty("status").GetString().Should().Be("in-progress");
body.GetProperty("subject").GetProperty("reference").GetString()
.Should().Be($"Patient/{FhirClinicalSeedHelper.Patient1Id}");
}
[Fact]
public async Task SearchEncounters_ByPatient_ReturnsMatchingEncounters()
{
var patientId = FhirClinicalSeedHelper.Patient1Id;
var response = await GetFhirAsync($"/fhir/Encounter?patient={patientId}");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("total").GetInt32().Should().BeGreaterThan(0);
body.GetProperty("entry").EnumerateArray().Should().AllSatisfy(entry =>
{
entry.GetProperty("resource").GetProperty("subject")
.GetProperty("reference").GetString()
.Should().Be($"Patient/{patientId}");
});
}
[Fact]
public async Task ReadObservation_ReturnsFhirObservationWithLoincCodeAndUcumUnit()
{
var response = await GetFhirAsync($"/fhir/Observation/{FhirClinicalSeedHelper.HeartRateObsId}");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("resourceType").GetString().Should().Be("Observation");
var coding = body.GetProperty("code").GetProperty("coding")[0];
coding.GetProperty("system").GetString().Should().Be("http://loinc.org");
coding.GetProperty("code").GetString().Should().Be("8867-4");
if (coding.TryGetProperty("display", out var display))
display.GetString().Should().Be("Heart rate");
var value = body.GetProperty("valueQuantity");
value.GetProperty("value").GetDecimal().Should().Be(88m);
value.GetProperty("unit").GetString().Should().Be("bpm");
value.GetProperty("code").GetString().Should().Be("/min");
}
[Fact]
public async Task SearchObservations_ByCategoryVitalSigns_ReturnsVitalSignObservationsOnly()
{
var patientId = FhirClinicalSeedHelper.Patient1Id;
var response = await GetFhirAsync(
$"/fhir/Observation?patient={patientId}&category=vital-signs");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("entry").GetArrayLength().Should().BeGreaterThan(0);
foreach (var entry in body.GetProperty("entry").EnumerateArray())
{
var category = entry.GetProperty("resource").GetProperty("category")[0]
.GetProperty("coding")[0].GetProperty("code").GetString();
category.Should().Be("vital-signs");
}
}
[Fact]
public async Task SearchObservations_ByLoincCode_ResolvesToHeartRate()
{
var response = await GetFhirAsync("/fhir/Observation?code=8867-4");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("entry").GetArrayLength().Should().BeGreaterThan(0);
var coding = body.GetProperty("entry")[0].GetProperty("resource")
.GetProperty("code").GetProperty("coding")[0];
coding.GetProperty("code").GetString().Should().Be("8867-4");
var value = body.GetProperty("entry")[0].GetProperty("resource")
.GetProperty("valueQuantity").GetProperty("value").GetDecimal();
value.Should().Be(88m);
}
[Fact]
public async Task SearchObservations_ByDateGreaterOrEqual_FiltersByRecordedAt()
{
var patientId = FhirClinicalSeedHelper.Patient1Id;
var response = await GetFhirAsync(
$"/fhir/Observation?patient={patientId}&date=ge2026-06-20");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("total").GetInt32().Should().BeGreaterThan(0);
body.GetProperty("entry").GetArrayLength().Should().BeGreaterThan(0);
}
[Fact]
public async Task PatientEverything_ReturnsCompletePatientBundle()
{
var response = await GetFhirAsync(
$"/fhir/Patient/{FhirClinicalSeedHelper.Patient1Id}/$everything");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("type").GetString().Should().Be("searchset");
body.GetProperty("total").GetInt32().Should().BeGreaterThan(1);
var resourceTypes = body.GetProperty("entry").EnumerateArray()
.Select(e => e.GetProperty("resource").GetProperty("resourceType").GetString())
.ToList();
resourceTypes.Should().Contain("Patient");
resourceTypes.Should().Contain("Encounter");
resourceTypes.Should().Contain("Observation");
var includeModes = body.GetProperty("entry").EnumerateArray()
.Where(e => e.GetProperty("resource").GetProperty("resourceType").GetString() != "Patient")
.Select(e => e.GetProperty("search").GetProperty("mode").GetString());
includeModes.Should().AllBe("include");
}
[Fact]
public async Task ReadPatient_NotFound_Returns404OperationOutcome()
{
var missingId = Guid.Parse("00000000-0000-0000-0000-000000000001");
var response = await GetFhirAsync($"/fhir/Patient/{missingId}");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var body = await ParseJsonAsync(response);
body.GetProperty("resourceType").GetString().Should().Be("OperationOutcome");
var issue = body.GetProperty("issue")[0];
issue.GetProperty("severity").GetString().Should().Be("error");
issue.GetProperty("code").GetString().Should().Be("not-found");
issue.GetProperty("diagnostics").GetString()
.Should().Contain($"Patient/{missingId}");
}
[Fact]
public async Task ReadPatient_ReturnsApplicationFhirJsonContentType()
{
var response = await GetFhirAsync($"/fhir/Patient/{FhirClinicalSeedHelper.Patient1Id}");
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType!.MediaType.Should().Be("application/fhir+json");
}
[Fact]
public async Task SearchPatients_PaginationLinks_AreCorrect()
{
var response = await GetFhirAsync("/fhir/Patient?_count=1&_offset=0");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("total").GetInt32().Should().BeGreaterThanOrEqualTo(2);
body.GetProperty("entry").GetArrayLength().Should().Be(1);
var links = body.GetProperty("link").EnumerateArray()
.ToDictionary(l => l.GetProperty("relation").GetString()!, l => l.GetProperty("url").GetString());
links.Should().ContainKey("self");
links["self"].Should().Contain("_count=1");
links["self"].Should().Contain("_offset=0");
links.Should().ContainKey("next");
links["next"].Should().Contain("_count=1");
links["next"].Should().Contain("_offset=1");
}
private async Task<HttpResponseMessage> GetFhirAsync(string path, bool authenticated = true)
{
var client = authenticated ? _client : _anonymousClient;
var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/fhir+json"));
return await client.SendAsync(request);
}
private static async Task<JsonElement> ParseJsonAsync(HttpResponseMessage response)
{
var json = await response.Content.ReadAsStringAsync();
return JsonDocument.Parse(json).RootElement;
}
}
@@ -18,7 +18,8 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
{ {
["ConnectionStrings:DefaultConnection"] = ["ConnectionStrings:DefaultConnection"] =
"Host=localhost;Port=5437;Database=vigilcare_records_test;Username=postgres;Password=password", "Host=localhost;Port=5437;Database=vigilcare_records_test;Username=postgres;Password=password",
["Redis:ConnectionString"] = "localhost:6383,defaultDatabase=1,allowAdmin=true" ["Redis:ConnectionString"] = "localhost:6383,defaultDatabase=1,allowAdmin=true",
["Fhir:BaseUrl"] = "http://localhost/fhir"
}); });
}); });
} }
@@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Seeds promoted clinical records aligned with Phase 9 demo batches for FHIR tests.
/// DataSeeder creates digitization batches; this helper populates the clinical tables
/// that FhirService reads.
/// </summary>
public static class FhirClinicalSeedHelper
{
public static readonly Guid Patient1Id = Guid.Parse("b1000000-0000-0000-0000-000000000001");
public static readonly Guid Patient2Id = Guid.Parse("b1000000-0000-0000-0000-000000000002");
public static readonly Guid Encounter1Id = Guid.Parse("d1000000-0000-0000-0000-000000000001");
public static readonly Guid HeartRateObsId = Guid.Parse("e1000000-0000-0000-0000-000000000001");
public static readonly Guid WbcObsId = Guid.Parse("e1000000-0000-0000-0000-000000000002");
public static readonly Guid Batch1Id = Guid.Parse("c1000000-0000-0000-0000-000000000001");
public static async Task SeedAsync(AppDbContext db)
{
if (await db.Patients.AnyAsync())
return;
var now = DateTimeOffset.UtcNow;
var recordedAt = now.AddDays(-5);
db.Patients.AddRange(
new Patient
{
Id = Patient1Id,
Mrn = "VCR-000001",
FullName = "MARIA SANTOS",
DateOfBirth = new DateOnly(1978, 3, 15),
Sex = "female",
BloodType = BloodType.APos,
EmergencyContact = "Juan Santos - 555-0101",
NoKnownAllergies = false,
AllergiesJson = "[\"Penicillin\", \"Sulfa drugs\"]",
CreatedAt = now.AddDays(-3),
UpdatedAt = now.AddHours(-12),
},
new Patient
{
Id = Patient2Id,
Mrn = "VCR-000002",
FullName = "KENJI NAKAMURA",
DateOfBirth = new DateOnly(1952, 11, 8),
Sex = "male",
BloodType = BloodType.ONeg,
EmergencyContact = "Yuki Nakamura - 555-0202",
NoKnownAllergies = true,
CreatedAt = now.AddDays(-1),
UpdatedAt = now.AddDays(-1),
});
db.Encounters.Add(new Encounter
{
Id = Encounter1Id,
PatientId = Patient1Id,
AdmissionDate = recordedAt,
Department = Department.InternalMedicine,
RoomBed = "2A-04",
AdmissionReason = "Pneumonia with elevated WBC",
Status = "active",
SourceBatchId = Batch1Id,
CreatedAt = now.AddDays(-3),
UpdatedAt = now.AddHours(-12),
});
db.Observations.AddRange(
new Observation
{
Id = HeartRateObsId,
EncounterId = Encounter1Id,
PatientId = Patient1Id,
ObservationCode = "HEART_RATE",
Value = 88m,
Unit = "bpm",
RecordedAt = recordedAt,
Source = "digitization_backfill",
SourceBatchId = Batch1Id,
CreatedAt = now.AddHours(-12),
},
new Observation
{
Id = WbcObsId,
EncounterId = Encounter1Id,
PatientId = Patient1Id,
ObservationCode = "WBC_K_UL",
Value = 14.2m,
Unit = "K/uL",
RecordedAt = recordedAt,
Source = "digitization_backfill",
SourceBatchId = Batch1Id,
CreatedAt = now.AddHours(-12),
});
await db.SaveChangesAsync();
}
}
@@ -39,7 +39,8 @@ public class MetricsCollectorService : BackgroundService
BatchStatus.Verified, BatchStatus.Verified,
BatchStatus.AwaitingClinicalApproval, BatchStatus.AwaitingClinicalApproval,
BatchStatus.Approved, BatchStatus.Approved,
BatchStatus.Promoted BatchStatus.Promoted,
BatchStatus.Cancelled
}; };
public MetricsCollectorService( public MetricsCollectorService(
@@ -121,11 +122,41 @@ public class MetricsCollectorService : BackgroundService
DiagnosticsMetrics.QueueAgeSeconds.Set(0); DiagnosticsMetrics.QueueAgeSeconds.Set(0);
} }
// --- Promotion retry gauges ---
var pendingRetries = await db.PromotionAttempts
.AsNoTracking()
.CountAsync(a => !a.Succeeded && a.NextRetryAt != null, ct);
DiagnosticsMetrics.PromotionPendingRetries.Set(pendingRetries);
var exhaustedCount = await db.DigitizationBatches
.AsNoTracking()
.Where(b => b.Status == BatchStatus.Approved)
.Where(b => db.PromotionAttempts
.Any(a => a.BatchId == b.Id && !a.Succeeded && a.NextRetryAt == null))
.CountAsync(ct);
DiagnosticsMetrics.PromotionExhaustedTotal.Set(exhaustedCount);
// --- Approval queue age: oldest APPROVED batch ---
var oldestApprovedUpdatedAt = await db.DigitizationBatches
.AsNoTracking()
.Where(b => b.Status == BatchStatus.Approved)
.OrderBy(b => b.UpdatedAt)
.Select(b => (DateTimeOffset?)b.UpdatedAt)
.FirstOrDefaultAsync(ct);
DiagnosticsMetrics.ApprovalQueueAgeSeconds.Set(
oldestApprovedUpdatedAt.HasValue
? (DateTimeOffset.UtcNow - oldestApprovedUpdatedAt.Value).TotalSeconds
: 0);
_logger.LogDebug( _logger.LogDebug(
"Metrics collected: {StatusCount} status groups, queue age {QueueAge}s", "Metrics collected: {StatusCount} status groups, queue age {QueueAge}s, " +
"pending retries {PendingRetries}, exhausted {Exhausted}",
statusCounts.Count, statusCounts.Count,
oldestPendingUpdatedAt.HasValue oldestPendingUpdatedAt.HasValue
? (DateTimeOffset.UtcNow - oldestPendingUpdatedAt.Value).TotalSeconds ? (DateTimeOffset.UtcNow - oldestPendingUpdatedAt.Value).TotalSeconds
: 0); : 0,
pendingRetries,
exhaustedCount);
} }
} }
@@ -0,0 +1,216 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
/// <summary>
/// Polls for uploaded batches without OCR results and pre-fills draft fields.
/// OCR is opt-in and non-blocking: failures leave the batch in UPLOADED status
/// for manual entry.
/// </summary>
public class OcrProcessingService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly OcrOptions _options;
private readonly ILogger<OcrProcessingService> _logger;
public OcrProcessingService(
IServiceScopeFactory scopeFactory,
IOptions<OcrOptions> options,
ILogger<OcrProcessingService> logger)
{
_scopeFactory = scopeFactory;
_options = options.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation(
"OcrProcessingService started. Provider: {Provider}, poll interval: {PollInterval}s, confidence threshold: {Threshold}",
_options.Provider,
_options.PollIntervalSeconds,
_options.ConfidenceThreshold);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessPendingBatchesAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "OCR processing cycle failed");
}
await Task.Delay(
TimeSpan.FromSeconds(_options.PollIntervalSeconds), stoppingToken);
}
_logger.LogInformation("OcrProcessingService stopped");
}
private async Task ProcessPendingBatchesAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var ocr = scope.ServiceProvider.GetRequiredService<IOcrService>();
var storage = scope.ServiceProvider.GetRequiredService<IDocumentStorageService>();
var preFiller = scope.ServiceProvider.GetRequiredService<OcrDraftPreFiller>();
var pendingBatches = await db.DigitizationBatches
.AsNoTracking()
.Where(b => b.Status == BatchStatus.Uploaded)
.Where(b => !db.OcrResults.Any(o => o.BatchId == b.Id))
.Where(b => !db.DigitizationEvents.Any(e =>
e.BatchId == b.Id &&
(e.EventType == DigitizationEventType.OcrCompleted ||
e.EventType == DigitizationEventType.OcrFailed)))
.OrderBy(b => b.CreatedAt)
.Take(5)
.ToListAsync(ct);
foreach (var batch in pendingBatches)
{
await ProcessBatchAsync(db, ocr, storage, preFiller, batch, ct);
}
}
private async Task ProcessBatchAsync(
AppDbContext db,
IOcrService ocr,
IDocumentStorageService storage,
OcrDraftPreFiller preFiller,
DigitizationBatch batch,
CancellationToken ct)
{
var currentStatus = await db.DigitizationBatches
.AsNoTracking()
.Where(b => b.Id == batch.Id)
.Select(b => b.Status)
.FirstOrDefaultAsync(ct);
if (currentStatus != BatchStatus.Uploaded)
{
_logger.LogDebug(
"Skipping OCR for batch {BatchId}: status is {Status}",
batch.Id, currentStatus.ToDbString());
return;
}
var actorUserId = await db.DigitizationEvents
.AsNoTracking()
.Where(e => e.BatchId == batch.Id &&
(e.EventType == DigitizationEventType.Uploaded ||
e.EventType == DigitizationEventType.CorrectionUploaded))
.OrderBy(e => e.OccurredAt)
.Select(e => e.ActorUserId)
.FirstOrDefaultAsync(ct);
if (actorUserId == Guid.Empty)
{
_logger.LogWarning(
"Skipping OCR for batch {BatchId}: no upload event found",
batch.Id);
return;
}
var document = await db.ScannedDocuments
.AsNoTracking()
.FirstOrDefaultAsync(d => d.BatchId == batch.Id, ct);
if (document is null)
{
_logger.LogWarning(
"Skipping OCR for batch {BatchId}: scanned document metadata not found",
batch.Id);
return;
}
var startedAt = DateTimeOffset.UtcNow;
db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batch.Id,
EventType = DigitizationEventType.OcrStarted,
ActorUserId = actorUserId,
OccurredAt = startedAt,
MetadataJson = JsonSerializer.Serialize(new
{
provider = _options.Provider
})
});
await db.SaveChangesAsync(ct);
try
{
await using var documentStream = await storage.DownloadAsync(document.ObjectKey);
var extraction = await ocr.ExtractAsync(documentStream, document.ContentType);
await preFiller.PreFillAsync(batch.Id, batch.BatchType, extraction);
var fieldConfidences = extraction.Fields
.GroupBy(f => f.FieldName, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Confidence, StringComparer.Ordinal);
var processedAt = DateTimeOffset.UtcNow;
db.OcrResults.Add(new OcrResult
{
Id = Guid.NewGuid(),
BatchId = batch.Id,
Provider = _options.Provider,
FieldConfidencesJson = JsonSerializer.Serialize(fieldConfidences),
RawText = extraction.RawText,
DurationMs = extraction.DurationMs,
ProcessedAt = processedAt
});
db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batch.Id,
EventType = DigitizationEventType.OcrCompleted,
ActorUserId = actorUserId,
OccurredAt = processedAt,
MetadataJson = JsonSerializer.Serialize(new
{
provider = _options.Provider,
durationMs = extraction.DurationMs,
fieldCount = extraction.Fields.Count,
confidentFieldCount = fieldConfidences.Count(kv => kv.Value >= _options.ConfidenceThreshold)
})
});
await db.SaveChangesAsync(ct);
_logger.LogInformation(
"OCR completed for batch {BatchId}: provider={Provider}, fields={FieldCount}, duration={DurationMs}ms",
batch.Id, _options.Provider, extraction.Fields.Count, extraction.DurationMs);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batch.Id,
EventType = DigitizationEventType.OcrFailed,
ActorUserId = actorUserId,
OccurredAt = DateTimeOffset.UtcNow,
MetadataJson = JsonSerializer.Serialize(new
{
provider = _options.Provider,
error = ex.Message
})
});
await db.SaveChangesAsync(ct);
_logger.LogWarning(
ex,
"OCR failed for batch {BatchId}: {ErrorMessage}",
batch.Id, ex.Message);
}
}
}
@@ -0,0 +1,5 @@
public class AzureOcrOptions
{
public string Endpoint { get; set; } = "";
public string ApiKey { get; set; } = "";
}
@@ -0,0 +1,9 @@
public class FhirOptions
{
public const string Section = "Fhir";
public string BaseUrl { get; set; } = "http://localhost:5217/fhir";
public string PublisherName { get; set; } = "VigilCare Records";
public string PublisherUrl { get; set; } = "https://vigilcare.local";
public string ServerVersion { get; set; } = "1.0.0";
}
@@ -0,0 +1,12 @@
public class OcrOptions
{
public const string Section = "Ocr";
public bool Enabled { get; set; } = false;
public string Provider { get; set; } = "azure"; // "azure" or "tesseract"
public double ConfidenceThreshold { get; set; } = 0.7;
public int PollIntervalSeconds { get; set; } = 15;
public AzureOcrOptions Azure { get; set; } = new();
public TesseractOcrOptions Tesseract { get; set; } = new();
}
@@ -0,0 +1,5 @@
public class TesseractOcrOptions
{
public string DataPath { get; set; } = "/usr/share/tessdata";
public string Language { get; set; } = "eng";
}
@@ -0,0 +1,117 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/v1/cover-sheets")]
[Produces("application/json")]
[Authorize]
public class CoverSheetController : ControllerBase
{
private readonly ICoverSheetService _coverSheets;
public CoverSheetController(ICoverSheetService coverSheets)
{
_coverSheets = coverSheets;
}
/// <summary>
/// Generates one or more cover sheets with unique barcode codes.
/// Each cover sheet encodes batch type, track, optional patient, and
/// optional entry clerk assignment.
/// </summary>
[HttpPost("generate")]
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
[ProducesResponseType(typeof(ApiResponse<List<CoverSheetResponse>>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Generate([FromBody] GenerateCoverSheetsRequest request)
{
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var sheets = await _coverSheets.GenerateAsync(request, actorUserId);
var response = sheets.Select(MapToResponse).ToList();
return StatusCode(201, ApiResponse<List<CoverSheetResponse>>.Created(response));
}
/// <summary>
/// Looks up a cover sheet by its barcode code. Used during barcode-assisted
/// upload to auto-populate batch type, track, patient, and clerk assignment.
/// </summary>
[HttpGet("lookup/{code}")]
[ProducesResponseType(typeof(ApiResponse<CoverSheetResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Lookup(string code)
{
var sheet = await _coverSheets.LookupByCodeAsync(code);
if (sheet is null)
return NotFound(ApiResponse<object>.Fail(404, "Cover sheet not found.", "COVER_SHEET_NOT_FOUND"));
return Ok(ApiResponse<CoverSheetResponse>.Ok(MapToResponse(sheet)));
}
/// <summary>
/// Lists cover sheets with optional filters.
/// </summary>
[HttpGet]
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
[ProducesResponseType(typeof(ApiResponse<List<CoverSheetResponse>>), StatusCodes.Status200OK)]
public async Task<IActionResult> List(
[FromQuery] bool? isUsed,
[FromQuery] Guid? patientId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var sheets = await _coverSheets.ListAsync(isUsed, patientId, page, pageSize);
var response = sheets.Select(MapToResponse).ToList();
return Ok(ApiResponse<List<CoverSheetResponse>>.Ok(response));
}
/// <summary>
/// Generates a printable PDF containing cover sheets with QR codes.
/// Each page has the cover sheet code as a QR code, plus human-readable
/// batch type, patient info, and generation date.
/// </summary>
[HttpPost("{id:guid}/pdf")]
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GeneratePdf(Guid id)
{
var sheet = await _coverSheets.LookupByIdAsync(id);
if (sheet is null)
return NotFound(ApiResponse<object>.Fail(404, "Cover sheet not found.", "COVER_SHEET_NOT_FOUND"));
var pdfBytes = CoverSheetPdfGenerator.Generate(sheet);
return File(pdfBytes, "application/pdf", $"coversheet-{sheet.Code}.pdf");
}
/// <summary>
/// Generates a batch PDF containing multiple cover sheets (one per page).
/// Accepts a list of cover sheet IDs.
/// </summary>
[HttpPost("batch-pdf")]
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public async Task<IActionResult> GenerateBatchPdf([FromBody] BatchPdfRequest request)
{
var sheets = await _coverSheets.GetByIdsAsync(request.CoverSheetIds);
var pdfBytes = CoverSheetPdfGenerator.GenerateBatch(sheets);
return File(pdfBytes, "application/pdf", $"coversheets-batch-{DateTime.UtcNow:yyyyMMdd}.pdf");
}
private static CoverSheetResponse MapToResponse(CoverSheet sheet) => new(
Id: sheet.Id,
Code: sheet.Code,
BatchType: sheet.BatchType.ToDbString(),
Track: sheet.Track.ToDbString(),
PatientId: sheet.PatientId,
PatientName: sheet.Patient?.FullName,
PatientMrn: sheet.Patient?.Mrn,
AssignToUserId: sheet.AssignToUserId,
AssignToUserName: sheet.AssignToUser?.FullName,
IsUsed: sheet.IsUsed,
BatchId: sheet.BatchId,
CreatedAt: sheet.CreatedAt,
UsedAt: sheet.UsedAt
);
}
@@ -17,6 +17,7 @@ public class DigitizationBatchesController : ControllerBase
private readonly IDocumentStorageService _storage; private readonly IDocumentStorageService _storage;
private readonly IPromotionService _promotion; private readonly IPromotionService _promotion;
private readonly IBatchEventService _batchEventService; private readonly IBatchEventService _batchEventService;
private readonly ICoverSheetService _coverSheets;
private readonly AppDbContext _db; private readonly AppDbContext _db;
private static readonly HashSet<string> _allowedMimeTypes = new() private static readonly HashSet<string> _allowedMimeTypes = new()
@@ -29,13 +30,15 @@ public class DigitizationBatchesController : ControllerBase
IDocumentStorageService storage, IDocumentStorageService storage,
IPromotionService promotion, IPromotionService promotion,
IBatchEventService batchEventService, IBatchEventService batchEventService,
AppDbContext db) AppDbContext db,
ICoverSheetService coverSheets)
{ {
_batches = batches; _batches = batches;
_storage = storage; _storage = storage;
_promotion = promotion; _promotion = promotion;
_batchEventService = batchEventService; _batchEventService = batchEventService;
_db = db; _db = db;
_coverSheets = coverSheets;
} }
/// <summary> /// <summary>
@@ -60,10 +63,40 @@ public class DigitizationBatchesController : ControllerBase
return BadRequest(ApiResponse<object>.Fail(400, return BadRequest(ApiResponse<object>.Fail(400,
"Accepted formats: PDF, JPEG, PNG.", "INVALID_MIME_TYPE")); "Accepted formats: PDF, JPEG, PNG.", "INVALID_MIME_TYPE"));
var parsedBatchType = BatchTypeExtensions.FromDbString(form.BatchType.ToUpperInvariant()); CoverSheet? coverSheet = null;
var parsedTrack = string.IsNullOrEmpty(form.Track) BatchType parsedBatchType;
? BatchTrack.Backfill BatchTrack parsedTrack;
: BatchTrackExtensions.FromDbString(form.Track.ToUpperInvariant());
if (!string.IsNullOrWhiteSpace(form.CoverSheetCode))
{
coverSheet = await _coverSheets.LookupByCodeAsync(form.CoverSheetCode);
if (coverSheet is null)
return NotFound(ApiResponse<object>.Fail(404,
"Cover sheet not found.", "COVER_SHEET_NOT_FOUND"));
if (coverSheet.IsUsed)
return Conflict(ApiResponse<object>.Fail(409,
$"Cover sheet {coverSheet.Code} has already been used.",
"COVER_SHEET_ALREADY_USED"));
parsedBatchType = coverSheet.BatchType;
parsedTrack = coverSheet.Track;
if (coverSheet.PatientId.HasValue)
form.PatientId ??= coverSheet.PatientId;
}
else
{
if (string.IsNullOrWhiteSpace(form.BatchType))
return BadRequest(ApiResponse<object>.Fail(400,
"BatchType is required when no cover sheet code is provided.",
"MISSING_BATCH_TYPE"));
parsedBatchType = BatchTypeExtensions.FromDbString(form.BatchType.ToUpperInvariant());
parsedTrack = string.IsNullOrEmpty(form.Track)
? BatchTrack.Backfill
: BatchTrackExtensions.FromDbString(form.Track.ToUpperInvariant());
}
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
using var stream = form.File.OpenReadStream(); using var stream = form.File.OpenReadStream();
@@ -71,8 +104,17 @@ public class DigitizationBatchesController : ControllerBase
stream, form.File.ContentType, parsedBatchType, parsedTrack, stream, form.File.ContentType, parsedBatchType, parsedTrack,
form.PatientId, form.SupersedesBatchId, actorUserId); form.PatientId, form.SupersedesBatchId, actorUserId);
var batch = result.Batch;
if (coverSheet is not null)
{
await _coverSheets.RedeemAsync(coverSheet.Id, batch.Id);
if (coverSheet.AssignToUserId.HasValue)
batch = await _batches.AssignAsync(batch.Id, coverSheet.AssignToUserId.Value, actorUserId);
}
return StatusCode(201, ApiResponse<BatchDetailResponse>.Created( return StatusCode(201, ApiResponse<BatchDetailResponse>.Created(
BatchDetailResponse.FromEntity(result.Batch, supersession: result.Supersession))); BatchDetailResponse.FromEntity(batch, supersession: result.Supersession)));
} }
/// <summary> /// <summary>
@@ -87,31 +129,42 @@ public class DigitizationBatchesController : ControllerBase
var batch = await _batches.GetByIdAsync(id); var batch = await _batches.GetByIdAsync(id);
var presignedUrl = await _storage.GetPresignedUrlAsync(batch.DocumentRef); var presignedUrl = await _storage.GetPresignedUrlAsync(batch.DocumentRef);
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); await RecordDocumentAccessAsync(id);
var cutoff = DateTimeOffset.UtcNow.AddMinutes(-5);
var recentAccess = await _db.DigitizationEvents.AnyAsync(e =>
e.BatchId == id &&
e.EventType == DigitizationEventType.DocumentAccessed &&
e.ActorUserId == userId &&
e.OccurredAt >= cutoff);
if (!recentAccess)
{
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = id,
EventType = DigitizationEventType.DocumentAccessed,
ActorUserId = userId,
OccurredAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync();
}
return Ok(ApiResponse<BatchDetailResponse>.Ok( return Ok(ApiResponse<BatchDetailResponse>.Ok(
BatchDetailResponse.FromEntity(batch, presignedUrl))); BatchDetailResponse.FromEntity(batch, presignedUrl)));
} }
/// <summary>
/// Streams the scanned document for in-app viewing (same auth and audit as GET batch).
/// Proxied through the API so the workstation can render PDFs without cross-origin iframe issues.
/// </summary>
[HttpGet("{id:guid}/document")]
[Produces("application/pdf", "image/jpeg", "image/png", "application/octet-stream")]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetDocument(Guid id)
{
var batch = await _batches.GetByIdAsync(id);
if (batch.DocumentRef == "live-capture")
return NotFound(ApiResponse<object>.Fail(
404, "This batch has no scanned document.", "NO_DOCUMENT"));
var scanned = await _db.ScannedDocuments
.AsNoTracking()
.FirstOrDefaultAsync(d => d.BatchId == id);
var contentType = scanned?.ContentType ?? "application/octet-stream";
await RecordDocumentAccessAsync(id);
var stream = await _storage.DownloadAsync(batch.DocumentRef);
Response.Headers.CacheControl = "private, max-age=300";
return File(stream, contentType);
}
/// <summary> /// <summary>
/// Lists batches with optional filters and pagination. /// Lists batches with optional filters and pagination.
/// </summary> /// </summary>
@@ -223,4 +276,27 @@ public class DigitizationBatchesController : ControllerBase
var result = await _batchEventService.GetEventsAsync(id, afterCursor, pageSize); var result = await _batchEventService.GetEventsAsync(id, afterCursor, pageSize);
return Ok(ApiResponse<CursorPagedResult<BatchEventResponse>>.Ok(result)); return Ok(ApiResponse<CursorPagedResult<BatchEventResponse>>.Ok(result));
} }
private async Task RecordDocumentAccessAsync(Guid batchId)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var cutoff = DateTimeOffset.UtcNow.AddMinutes(-5);
var recentAccess = await _db.DigitizationEvents.AnyAsync(e =>
e.BatchId == batchId &&
e.EventType == DigitizationEventType.DocumentAccessed &&
e.ActorUserId == userId &&
e.OccurredAt >= cutoff);
if (recentAccess) return;
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = DigitizationEventType.DocumentAccessed,
ActorUserId = userId,
OccurredAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync();
}
} }
@@ -0,0 +1,42 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("fhir/Encounter")]
[Produces("application/fhir+json")]
[Authorize]
public class FhirEncounterController : ControllerBase
{
private readonly IFhirService _fhir;
public FhirEncounterController(IFhirService fhir) => _fhir = fhir;
/// <summary>
/// FHIR read: GET /fhir/Encounter/{id}
/// </summary>
[HttpGet("{id}")]
public async Task<IActionResult> Read(string id)
{
var encounter = await _fhir.GetEncounterAsync(Guid.Parse(id));
if (encounter is null)
return NotFound(FhirErrorHelper.NotFound("Encounter", id));
return Ok(encounter);
}
/// <summary>
/// FHIR search: GET /fhir/Encounter?patient=X&amp;status=Y&amp;date=Z
/// Supports search by patient reference, status, and date range.
/// </summary>
[HttpGet]
public async Task<IActionResult> Search(
[FromQuery] string? patient,
[FromQuery] string? status,
[FromQuery] string? date,
[FromQuery(Name = "_count")] int count = 20,
[FromQuery(Name = "_offset")] int offset = 0)
{
var bundle = await _fhir.SearchEncountersAsync(patient, status, date, count, offset);
return Ok(bundle);
}
}
@@ -0,0 +1,27 @@
using Hl7.Fhir.Model;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("fhir")]
[Produces("application/fhir+json")]
public class FhirMetadataController : ControllerBase
{
private readonly IFhirService _fhir;
public FhirMetadataController(IFhirService fhir) => _fhir = fhir;
/// <summary>
/// FHIR metadata: GET /fhir/metadata
/// Returns the server's CapabilityStatement describing supported
/// resources, interactions, and search parameters.
/// No authentication required (FHIR spec requirement).
/// </summary>
[HttpGet("metadata")]
[AllowAnonymous]
[ProducesResponseType(typeof(CapabilityStatement), StatusCodes.Status200OK)]
public IActionResult GetMetadata()
{
return Ok(_fhir.GetCapabilityStatement());
}
}
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("fhir/Observation")]
[Produces("application/fhir+json")]
[Authorize]
public class FhirObservationController : ControllerBase
{
private readonly IFhirService _fhir;
public FhirObservationController(IFhirService fhir) => _fhir = fhir;
/// <summary>
/// FHIR read: GET /fhir/Observation/{id}
/// </summary>
[HttpGet("{id}")]
public async Task<IActionResult> Read(string id)
{
var observation = await _fhir.GetObservationAsync(Guid.Parse(id));
if (observation is null)
return NotFound(FhirErrorHelper.NotFound("Observation", id));
return Ok(observation);
}
/// <summary>
/// FHIR search: GET /fhir/Observation?patient=X&amp;code=Y&amp;date=Z&amp;category=W
/// Supports search by patient reference, LOINC code, date range, and category.
/// </summary>
[HttpGet]
public async Task<IActionResult> Search(
[FromQuery] string? patient,
[FromQuery] string? code,
[FromQuery] string? date,
[FromQuery] string? category,
[FromQuery] string? encounter,
[FromQuery(Name = "_count")] int count = 50,
[FromQuery(Name = "_offset")] int offset = 0)
{
var bundle = await _fhir.SearchObservationsAsync(
patient, code, date, category, encounter, count, offset);
return Ok(bundle);
}
}
@@ -0,0 +1,65 @@
using Hl7.Fhir.Model;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("fhir/Patient")]
[Produces("application/fhir+json")]
[Authorize]
public class FhirPatientController : ControllerBase
{
private readonly IFhirService _fhir;
public FhirPatientController(IFhirService fhir) => _fhir = fhir;
/// <summary>
/// FHIR read: GET /fhir/Patient/{id}
/// Returns a single Patient resource by logical ID.
/// </summary>
[HttpGet("{id}")]
[ProducesResponseType(typeof(Hl7.Fhir.Model.Patient), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(Hl7.Fhir.Model.OperationOutcome), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Read(string id)
{
var patient = await _fhir.GetPatientAsync(Guid.Parse(id));
if (patient is null)
return NotFound(FhirErrorHelper.NotFound("Patient", id));
return Ok(patient);
}
/// <summary>
/// FHIR search: GET /fhir/Patient?name=X&amp;birthdate=Y&amp;identifier=Z
/// Supports search by name (contains), birthdate (exact), and MRN identifier.
/// Returns a FHIR Bundle of type searchset.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(Hl7.Fhir.Model.Bundle), StatusCodes.Status200OK)]
public async Task<IActionResult> Search(
[FromQuery] string? name,
[FromQuery] string? birthdate,
[FromQuery] string? identifier,
[FromQuery(Name = "_count")] int count = 20,
[FromQuery(Name = "_offset")] int offset = 0)
{
var bundle = await _fhir.SearchPatientsAsync(name, birthdate, identifier, count, offset);
return Ok(bundle);
}
/// <summary>
/// FHIR $everything: GET /fhir/Patient/{id}/$everything
/// Returns a Bundle containing the Patient resource, all Encounters,
/// and all Observations for the patient.
/// </summary>
[HttpGet("{id}/$everything")]
[ProducesResponseType(typeof(Bundle), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Everything(string id)
{
var bundle = await _fhir.GetPatientEverythingAsync(Guid.Parse(id));
if (bundle is null)
return NotFound(FhirErrorHelper.NotFound("Patient", id));
return Ok(bundle);
}
}
+2
View File
@@ -23,6 +23,8 @@ public class AppDbContext : DbContext
public DbSet<LiveEncounter> LiveEncounters => Set<LiveEncounter>(); public DbSet<LiveEncounter> LiveEncounters => Set<LiveEncounter>();
public DbSet<LiveObservation> LiveObservations => Set<LiveObservation>(); public DbSet<LiveObservation> LiveObservations => Set<LiveObservation>();
public DbSet<PromotionAttempt> PromotionAttempts => Set<PromotionAttempt>(); public DbSet<PromotionAttempt> PromotionAttempts => Set<PromotionAttempt>();
public DbSet<CoverSheet> CoverSheets => Set<CoverSheet>();
public DbSet<OcrResult> OcrResults => Set<OcrResult>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class CoverSheetConfiguration : IEntityTypeConfiguration<CoverSheet>
{
public void Configure(EntityTypeBuilder<CoverSheet> builder)
{
builder.ToTable("cover_sheets");
builder.HasKey(c => c.Id);
builder.Property(c => c.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(c => c.Code).HasColumnName("code").HasMaxLength(20).IsRequired();
builder.Property(c => c.PatientId).HasColumnName("patient_id");
builder.Property(c => c.BatchType).HasColumnName("batch_type")
.HasConversion(v => v.ToDbString(), v => BatchTypeExtensions.FromDbString(v))
.HasMaxLength(30).IsRequired();
builder.Property(c => c.Track).HasColumnName("track")
.HasConversion(v => v.ToDbString(), v => BatchTrackExtensions.FromDbString(v))
.HasMaxLength(20).IsRequired();
builder.Property(c => c.AssignToUserId).HasColumnName("assign_to_user_id");
builder.Property(c => c.GeneratedByUserId).HasColumnName("generated_by_user_id").IsRequired();
builder.Property(c => c.IsUsed).HasColumnName("is_used").HasDefaultValue(false);
builder.Property(c => c.BatchId).HasColumnName("batch_id");
builder.Property(c => c.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.Property(c => c.UsedAt).HasColumnName("used_at");
builder.HasIndex(c => c.Code).IsUnique().HasDatabaseName("ix_cover_sheets_code");
builder.HasIndex(c => new { c.IsUsed, c.CreatedAt })
.HasFilter("is_used = false")
.HasDatabaseName("ix_cover_sheets_unused");
builder.HasOne(c => c.Patient)
.WithMany().HasForeignKey(c => c.PatientId).OnDelete(DeleteBehavior.SetNull);
builder.HasOne(c => c.AssignToUser)
.WithMany().HasForeignKey(c => c.AssignToUserId).OnDelete(DeleteBehavior.SetNull);
builder.HasOne(c => c.GeneratedByUser)
.WithMany().HasForeignKey(c => c.GeneratedByUserId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(c => c.Batch)
.WithMany().HasForeignKey(c => c.BatchId).OnDelete(DeleteBehavior.SetNull);
}
}
@@ -8,7 +8,7 @@ public class DigitizationEventConfiguration : IEntityTypeConfiguration<Digitizat
builder.ToTable("digitization_events", t => builder.ToTable("digitization_events", t =>
{ {
t.HasCheckConstraint("chk_digitization_events_event_type", t.HasCheckConstraint("chk_digitization_events_event_type",
"event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted')"); "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted', 'ocr_started', 'ocr_completed', 'ocr_failed')");
}); });
builder.HasKey(e => e.Id); builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class OcrResultConfiguration : IEntityTypeConfiguration<OcrResult>
{
public void Configure(EntityTypeBuilder<OcrResult> builder)
{
builder.ToTable("ocr_results");
builder.HasKey(o => o.Id);
builder.Property(o => o.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(o => o.BatchId).HasColumnName("batch_id").IsRequired();
builder.Property(o => o.Provider).HasColumnName("provider").HasMaxLength(20).IsRequired();
builder.Property(o => o.FieldConfidencesJson).HasColumnName("field_confidences_json").HasColumnType("jsonb").IsRequired();
builder.Property(o => o.RawText).HasColumnName("raw_text");
builder.Property(o => o.DurationMs).HasColumnName("duration_ms").IsRequired();
builder.Property(o => o.ProcessedAt).HasColumnName("processed_at").HasDefaultValueSql("NOW()");
builder.HasIndex(o => o.BatchId).IsUnique();
builder.HasOne(o => o.Batch)
.WithOne()
.HasForeignKey<OcrResult>(o => o.BatchId)
.OnDelete(DeleteBehavior.Restrict);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,100 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareRecordsAPI.Data.Migrations
{
/// <inheritdoc />
public partial class AddCoverSheets : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "cover_sheets",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
code = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
patient_id = table.Column<Guid>(type: "uuid", nullable: true),
batch_type = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
track = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
assign_to_user_id = table.Column<Guid>(type: "uuid", nullable: true),
generated_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
is_used = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
batch_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
used_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_cover_sheets", x => x.id);
table.ForeignKey(
name: "FK_cover_sheets_digitization_batches_batch_id",
column: x => x.batch_id,
principalTable: "digitization_batches",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_cover_sheets_patients_patient_id",
column: x => x.patient_id,
principalSchema: "clinical",
principalTable: "patients",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_cover_sheets_users_assign_to_user_id",
column: x => x.assign_to_user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_cover_sheets_users_generated_by_user_id",
column: x => x.generated_by_user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_cover_sheets_assign_to_user_id",
table: "cover_sheets",
column: "assign_to_user_id");
migrationBuilder.CreateIndex(
name: "IX_cover_sheets_batch_id",
table: "cover_sheets",
column: "batch_id");
migrationBuilder.CreateIndex(
name: "ix_cover_sheets_code",
table: "cover_sheets",
column: "code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_cover_sheets_generated_by_user_id",
table: "cover_sheets",
column: "generated_by_user_id");
migrationBuilder.CreateIndex(
name: "IX_cover_sheets_patient_id",
table: "cover_sheets",
column: "patient_id");
migrationBuilder.CreateIndex(
name: "ix_cover_sheets_unused",
table: "cover_sheets",
columns: new[] { "is_used", "created_at" },
filter: "is_used = false");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "cover_sheets");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareRecordsAPI.Data.Migrations
{
/// <inheritdoc />
public partial class AddOcrResult : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropCheckConstraint(
name: "chk_digitization_events_event_type",
table: "digitization_events");
migrationBuilder.CreateTable(
name: "ocr_results",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
batch_id = table.Column<Guid>(type: "uuid", nullable: false),
provider = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
field_confidences_json = table.Column<string>(type: "jsonb", nullable: false),
raw_text = table.Column<string>(type: "text", nullable: true),
duration_ms = table.Column<int>(type: "integer", nullable: false),
processed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
},
constraints: table =>
{
table.PrimaryKey("PK_ocr_results", x => x.id);
table.ForeignKey(
name: "FK_ocr_results_digitization_batches_batch_id",
column: x => x.batch_id,
principalTable: "digitization_batches",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.AddCheckConstraint(
name: "chk_digitization_events_event_type",
table: "digitization_events",
sql: "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted', 'ocr_started', 'ocr_completed', 'ocr_failed')");
migrationBuilder.CreateIndex(
name: "IX_ocr_results_batch_id",
table: "ocr_results",
column: "batch_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ocr_results");
migrationBuilder.DropCheckConstraint(
name: "chk_digitization_events_event_type",
table: "digitization_events");
migrationBuilder.AddCheckConstraint(
name: "chk_digitization_events_event_type",
table: "digitization_events",
sql: "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted')");
}
}
}
@@ -230,6 +230,85 @@ namespace VigilCareRecordsAPI.Data.Migrations
}); });
}); });
modelBuilder.Entity("CoverSheet", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid?>("AssignToUserId")
.HasColumnType("uuid")
.HasColumnName("assign_to_user_id");
b.Property<Guid?>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("BatchType")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("batch_type");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid>("GeneratedByUserId")
.HasColumnType("uuid")
.HasColumnName("generated_by_user_id");
b.Property<bool>("IsUsed")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_used");
b.Property<Guid?>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<string>("Track")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("track");
b.Property<DateTimeOffset?>("UsedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("used_at");
b.HasKey("Id");
b.HasIndex("AssignToUserId");
b.HasIndex("BatchId");
b.HasIndex("Code")
.IsUnique()
.HasDatabaseName("ix_cover_sheets_code");
b.HasIndex("GeneratedByUserId");
b.HasIndex("PatientId");
b.HasIndex("IsUsed", "CreatedAt")
.HasDatabaseName("ix_cover_sheets_unused")
.HasFilter("is_used = false");
b.ToTable("cover_sheets", (string)null);
});
modelBuilder.Entity("DigitizationBatch", b => modelBuilder.Entity("DigitizationBatch", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -397,7 +476,7 @@ namespace VigilCareRecordsAPI.Data.Migrations
b.ToTable("digitization_events", null, t => b.ToTable("digitization_events", null, t =>
{ {
t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted')"); t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted', 'ocr_started', 'ocr_completed', 'ocr_failed')");
}); });
}); });
@@ -928,6 +1007,51 @@ namespace VigilCareRecordsAPI.Data.Migrations
b.ToTable("observations", "clinical"); b.ToTable("observations", "clinical");
}); });
modelBuilder.Entity("OcrResult", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<int>("DurationMs")
.HasColumnType("integer")
.HasColumnName("duration_ms");
b.Property<string>("FieldConfidencesJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("field_confidences_json");
b.Property<DateTimeOffset>("ProcessedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("provider");
b.Property<string>("RawText")
.HasColumnType("text")
.HasColumnName("raw_text");
b.HasKey("Id");
b.HasIndex("BatchId")
.IsUnique();
b.ToTable("ocr_results", (string)null);
});
modelBuilder.Entity("OutboxEvent", b => modelBuilder.Entity("OutboxEvent", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1269,6 +1393,38 @@ namespace VigilCareRecordsAPI.Data.Migrations
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("CoverSheet", b =>
{
b.HasOne("User", "AssignToUser")
.WithMany()
.HasForeignKey("AssignToUserId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("DigitizationBatch", "Batch")
.WithMany()
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("User", "GeneratedByUser")
.WithMany()
.HasForeignKey("GeneratedByUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Patient", "Patient")
.WithMany()
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("AssignToUser");
b.Navigation("Batch");
b.Navigation("GeneratedByUser");
b.Navigation("Patient");
});
modelBuilder.Entity("DigitizationBatch", b => modelBuilder.Entity("DigitizationBatch", b =>
{ {
b.HasOne("User", "ApprovedByUser") b.HasOne("User", "ApprovedByUser")
@@ -1395,6 +1551,17 @@ namespace VigilCareRecordsAPI.Data.Migrations
b.Navigation("Patient"); b.Navigation("Patient");
}); });
modelBuilder.Entity("OcrResult", b =>
{
b.HasOne("DigitizationBatch", "Batch")
.WithOne()
.HasForeignKey("OcrResult", "BatchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("PromotionAttempt", b => modelBuilder.Entity("PromotionAttempt", b =>
{ {
b.HasOne("DigitizationBatch", "Batch") b.HasOne("DigitizationBatch", "Batch")
@@ -94,4 +94,16 @@ public static class DiagnosticsMetrics
{ {
LabelNames = new[] { "outcome" } LabelNames = new[] { "outcome" }
}); });
public static readonly Gauge PromotionPendingRetries = Metrics.CreateGauge(
"digitization_promotion_pending_retries",
"Count of promotion attempts with a scheduled retry that have not yet succeeded.");
public static readonly Gauge PromotionExhaustedTotal = Metrics.CreateGauge(
"digitization_promotion_exhausted_total",
"Count of APPROVED batches where all retry attempts are exhausted.");
public static readonly Gauge ApprovalQueueAgeSeconds = Metrics.CreateGauge(
"digitization_approval_queue_age_seconds",
"Age in seconds of the oldest batch in APPROVED status awaiting promotion retry.");
} }
+58
View File
@@ -0,0 +1,58 @@
# Build context MUST be the repo root:
# docker build -f VigilCareRecordsAPI/Dockerfile -t vigilcare-records-api .
# Single-project solution today, but keeping the repo-root context matches the
# CI/CD guide's convention and avoids a context change if a shared library is
# ever extracted alongside VigilCareRecordsAPI.Tests.
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Restore first with only the project file so the NuGet layer caches
# independently of source changes.
COPY VigilCareRecordsAPI/VigilCareRecordsAPI.csproj VigilCareRecordsAPI/
RUN dotnet restore VigilCareRecordsAPI/VigilCareRecordsAPI.csproj
COPY VigilCareRecordsAPI/ VigilCareRecordsAPI/
RUN dotnet publish VigilCareRecordsAPI/VigilCareRecordsAPI.csproj \
-c Release -o /app/publish --no-restore
# Dev appsettings.json ships with placeholder secrets (Jwt:Secret, Minio:SecretKey).
# Blank them so a misconfigured production deploy fails fast instead of running
# with a known, publicly-committed secret.
RUN sed -i \
-e 's/"Secret": "[^"]*"/"Secret": ""/' \
-e 's/"SecretKey": "[^"]*"/"SecretKey": ""/' \
/app/publish/appsettings.json
# ---- optional target: EF migration bundle, built and extracted by the CD
# migrate job (docker build --target migrate + docker cp). Never shipped in the
# runtime image below. ----
FROM build AS migrate
RUN dotnet tool install --global dotnet-ef --version 8.*
ENV PATH="$PATH:/root/.dotnet/tools"
RUN dotnet ef migrations bundle \
--project VigilCareRecordsAPI/VigilCareRecordsAPI.csproj \
--self-contained -r linux-x64 \
--output /out/migrate-api
# ---- runtime ----
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/publish .
RUN useradd --uid 1654 --user-group --no-create-home appuser \
&& chown -R appuser:appuser /app
USER appuser
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS http://localhost:8080/health/live || exit 1
ENTRYPOINT ["dotnet", "VigilCareRecordsAPI.dll"]
@@ -0,0 +1,19 @@
public class CoverSheet
{
public Guid Id { get; set; }
public string Code { get; set; } = null!;
public Guid? PatientId { get; set; }
public BatchType BatchType { get; set; }
public BatchTrack Track { get; set; }
public Guid? AssignToUserId { get; set; }
public Guid GeneratedByUserId { get; set; }
public bool IsUsed { get; set; }
public Guid? BatchId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? UsedAt { get; set; }
public Patient? Patient { get; set; }
public User? AssignToUser { get; set; }
public User GeneratedByUser { get; set; } = null!;
public DigitizationBatch? Batch { get; set; }
}
@@ -0,0 +1,12 @@
public class OcrResult
{
public Guid Id { get; set; }
public Guid BatchId { get; set; }
public string Provider { get; set; } = null!; // "azure" or "tesseract"
public string FieldConfidencesJson { get; set; } = null!; // serialized Dictionary<string, double>
public string? RawText { get; set; }
public int DurationMs { get; set; }
public DateTimeOffset ProcessedAt { get; set; }
public DigitizationBatch Batch { get; set; } = null!;
}
@@ -22,7 +22,10 @@ public enum DigitizationEventType
DocumentAccessed, DocumentAccessed,
Cancelled, Cancelled,
DraftFieldUpdated, DraftFieldUpdated,
DraftObservationDeleted DraftObservationDeleted,
OcrStarted,
OcrCompleted,
OcrFailed
} }
public static class DigitizationEventTypeExtensions public static class DigitizationEventTypeExtensions
@@ -52,6 +55,9 @@ public static class DigitizationEventTypeExtensions
DigitizationEventType.Cancelled => "cancelled", DigitizationEventType.Cancelled => "cancelled",
DigitizationEventType.DraftFieldUpdated => "draft_field_updated", DigitizationEventType.DraftFieldUpdated => "draft_field_updated",
DigitizationEventType.DraftObservationDeleted => "draft_observation_deleted", DigitizationEventType.DraftObservationDeleted => "draft_observation_deleted",
DigitizationEventType.OcrStarted => "ocr_started",
DigitizationEventType.OcrCompleted => "ocr_completed",
DigitizationEventType.OcrFailed => "ocr_failed",
_ => throw new ArgumentOutOfRangeException(nameof(t)) _ => throw new ArgumentOutOfRangeException(nameof(t))
}; };
@@ -80,6 +86,9 @@ public static class DigitizationEventTypeExtensions
"cancelled" => DigitizationEventType.Cancelled, "cancelled" => DigitizationEventType.Cancelled,
"draft_field_updated" => DigitizationEventType.DraftFieldUpdated, "draft_field_updated" => DigitizationEventType.DraftFieldUpdated,
"draft_observation_deleted" => DigitizationEventType.DraftObservationDeleted, "draft_observation_deleted" => DigitizationEventType.DraftObservationDeleted,
"ocr_started" => DigitizationEventType.OcrStarted,
"ocr_completed" => DigitizationEventType.OcrCompleted,
"ocr_failed" => DigitizationEventType.OcrFailed,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown digitization event type: '{v}'") _ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown digitization event type: '{v}'")
}; };
} }
@@ -0,0 +1,18 @@
using Hl7.Fhir.Model;
public static class FhirErrorHelper
{
public static OperationOutcome NotFound(string resourceType, string id) =>
new()
{
Issue =
{
new OperationOutcome.IssueComponent
{
Severity = OperationOutcome.IssueSeverity.Error,
Code = OperationOutcome.IssueType.NotFound,
Diagnostics = $"{resourceType}/{id} not found",
}
}
};
}
@@ -0,0 +1,31 @@
using Hl7.Fhir.Model;
using Hl7.Fhir.Serialization;
using Microsoft.AspNetCore.Mvc.Formatters;
using System.Text;
using Task = System.Threading.Tasks.Task;
public class FhirJsonOutputFormatter : TextOutputFormatter
{
public FhirJsonOutputFormatter()
{
SupportedMediaTypes.Add("application/fhir+json");
SupportedMediaTypes.Add("application/json");
SupportedEncodings.Add(Encoding.UTF8);
}
protected override bool CanWriteType(Type? type)
{
return type != null && typeof(Resource).IsAssignableFrom(type);
}
public override async Task WriteResponseBodyAsync(
OutputFormatterWriteContext context, Encoding selectedEncoding)
{
var resource = context.Object as Resource;
if (resource is null) return;
var serializer = new FhirJsonSerializer(new SerializerSettings { Pretty = true });
var json = serializer.SerializeToString(resource);
await context.HttpContext.Response.WriteAsync(json, selectedEncoding);
}
}
@@ -0,0 +1,90 @@
using Hl7.Fhir.Model;
using FhirEncounter = Hl7.Fhir.Model.Encounter;
public static class EncounterMapper
{
/// <summary>
/// Maps a VigilCare Encounter entity to a FHIR R4 Encounter resource.
///
/// Mapping decisions:
/// - VigilCare encounter Status ("active", "discharged") maps to FHIR
/// Encounter.Status (in-progress, finished).
/// - Department maps to Encounter.serviceType using a local CodeSystem.
/// Facilities should map to their own department OID or SNOMED CT codes.
/// - SourceBatchId is preserved as an extension for traceability.
/// </summary>
public static FhirEncounter ToFhir(Encounter entity, string baseUrl)
{
var encounter = new FhirEncounter
{
Id = entity.Id.ToString(),
Meta = new Meta
{
VersionId = "1",
LastUpdated = entity.UpdatedAt,
},
Status = entity.Status?.ToLowerInvariant() switch
{
"active" => FhirEncounter.EncounterStatus.InProgress,
"discharged" => FhirEncounter.EncounterStatus.Finished,
"cancelled" => FhirEncounter.EncounterStatus.Cancelled,
_ => FhirEncounter.EncounterStatus.Unknown,
},
Class = new Coding(
"http://terminology.hl7.org/CodeSystem/v3-ActCode",
"IMP",
"inpatient encounter"),
Subject = new ResourceReference($"Patient/{entity.PatientId}"),
};
if (entity.AdmissionDate.HasValue)
{
encounter.Period = new Period
{
StartElement = new FhirDateTime(entity.AdmissionDate.Value),
};
}
if (entity.Department.HasValue)
{
encounter.ServiceType = new CodeableConcept(
"urn:vigilcare:department",
entity.Department.Value.ToString(),
entity.Department.Value.ToString());
}
if (!string.IsNullOrEmpty(entity.RoomBed))
{
encounter.Location.Add(new FhirEncounter.LocationComponent
{
Location = new ResourceReference { Display = entity.RoomBed },
Status = FhirEncounter.EncounterLocationStatus.Active,
});
}
if (!string.IsNullOrEmpty(entity.AdmissionReason))
{
encounter.ReasonCode.Add(new CodeableConcept { Text = entity.AdmissionReason });
}
if (!string.IsNullOrEmpty(entity.DischargeDiagnosis))
{
encounter.Diagnosis.Add(new FhirEncounter.DiagnosisComponent
{
Condition = new ResourceReference { Display = entity.DischargeDiagnosis },
Use = new CodeableConcept(
"http://terminology.hl7.org/CodeSystem/diagnosis-role",
"DD", "Discharge diagnosis"),
});
}
if (entity.SourceBatchId.HasValue)
{
encounter.Extension.Add(new Extension(
"urn:vigilcare:source-batch-id",
new FhirString(entity.SourceBatchId.Value.ToString())));
}
return encounter;
}
}
@@ -0,0 +1,137 @@
using Hl7.Fhir.Model;
using FhirObservation = Hl7.Fhir.Model.Observation;
public static class ObservationMapper
{
private static readonly Dictionary<string, string> LoincToVigilCare = new()
{
["8867-4"] = "HEART_RATE",
["8310-5"] = "TEMP_C",
["8480-6"] = "BP_SYSTOLIC",
["8462-4"] = "BP_DIASTOLIC",
["9279-1"] = "RESP_RATE",
["2708-6"] = "SPO2",
["2345-7"] = "GLUCOSE_MG_DL",
["2823-3"] = "POTASSIUM_MEQ_L",
["2951-2"] = "SODIUM_MEQ_L",
["2524-7"] = "LACTATE_MMOL_L",
["6690-2"] = "WBC_K_UL",
["718-7"] = "HEMOGLOBIN_G_DL",
["2160-0"] = "CREATININE_MG_DL",
};
private static readonly HashSet<string> VitalSignCodes =
[
"HEART_RATE", "TEMP_C", "BP_SYSTOLIC", "BP_DIASTOLIC", "RESP_RATE", "SPO2"
];
/// <summary>
/// Maps a VigilCare Observation entity to a FHIR R4 Observation resource.
///
/// Mapping decisions:
/// - ObservationCode maps to LOINC codes where a standard mapping exists.
/// Unknown codes use a local CodeSystem with the original code as display.
/// - Value + Unit maps to Observation.valueQuantity with UCUM unit codes.
/// - RecordedAt maps to effectiveDateTime (when the observation was clinically
/// relevant), not issued (when the system recorded it).
/// - Source and SourceBatchId are preserved as extensions for provenance.
/// </summary>
public static FhirObservation ToFhir(Observation entity, string baseUrl)
{
var observation = new FhirObservation
{
Id = entity.Id.ToString(),
Meta = new Meta
{
VersionId = "1",
LastUpdated = entity.CreatedAt,
},
Status = ObservationStatus.Final,
Subject = new ResourceReference($"Patient/{entity.PatientId}"),
Encounter = new ResourceReference($"Encounter/{entity.EncounterId}"),
Effective = new FhirDateTime(entity.RecordedAt),
Issued = entity.CreatedAt,
};
observation.Code = MapObservationCode(entity.ObservationCode);
observation.Value = new Quantity
{
Value = entity.Value,
Unit = entity.Unit,
System = "http://unitsofmeasure.org",
Code = MapToUcum(entity.Unit),
};
var category = IsVitalSign(entity.ObservationCode) ? "vital-signs" : "laboratory";
observation.Category.Add(new CodeableConcept(
"http://terminology.hl7.org/CodeSystem/observation-category",
category));
if (!string.IsNullOrEmpty(entity.Note))
{
observation.Note.Add(new Annotation { Text = new Markdown(entity.Note) });
}
observation.Extension.Add(new Extension(
"urn:vigilcare:source",
new FhirString(entity.Source)));
if (entity.SourceBatchId.HasValue)
{
observation.Extension.Add(new Extension(
"urn:vigilcare:source-batch-id",
new FhirString(entity.SourceBatchId.Value.ToString())));
}
return observation;
}
/// <summary>
/// Reverse LOINC → VigilCare code mapping for FHIR search by LOINC code.
/// </summary>
public static IReadOnlyDictionary<string, string> GetReverseLoincMapping() => LoincToVigilCare;
/// <summary>
/// VigilCare observation codes that represent vital signs (used for category search).
/// </summary>
public static IReadOnlyCollection<string> GetVitalSignCodes() => VitalSignCodes;
private static CodeableConcept MapObservationCode(string code) => code switch
{
"HEART_RATE" => Loinc("8867-4", "Heart rate"),
"TEMP_C" => Loinc("8310-5", "Body temperature"),
"BP_SYSTOLIC" => Loinc("8480-6", "Systolic blood pressure"),
"BP_DIASTOLIC" => Loinc("8462-4", "Diastolic blood pressure"),
"RESP_RATE" => Loinc("9279-1", "Respiratory rate"),
"SPO2" => Loinc("2708-6", "Oxygen saturation"),
"GLUCOSE_MG_DL" => Loinc("2345-7", "Glucose [Mass/volume] in Serum or Plasma"),
"POTASSIUM_MEQ_L" => Loinc("2823-3", "Potassium [Moles/volume] in Serum or Plasma"),
"SODIUM_MEQ_L" => Loinc("2951-2", "Sodium [Moles/volume] in Serum or Plasma"),
"LACTATE_MMOL_L" => Loinc("2524-7", "Lactate [Moles/volume] in Serum or Plasma"),
"WBC_K_UL" => Loinc("6690-2", "Leukocytes [#/volume] in Blood"),
"HEMOGLOBIN_G_DL" => Loinc("718-7", "Hemoglobin [Mass/volume] in Blood"),
"CREATININE_MG_DL" => Loinc("2160-0", "Creatinine [Mass/volume] in Serum or Plasma"),
_ => new CodeableConcept("urn:vigilcare:observation-code", code, code),
};
private static CodeableConcept Loinc(string code, string display) =>
new("http://loinc.org", code, display);
private static string MapToUcum(string unit) => unit switch
{
"bpm" => "/min",
"C" => "Cel",
"mmHg" => "mm[Hg]",
"breaths/min" => "/min",
"%" => "%",
"mg/dL" => "mg/dL",
"mEq/L" => "meq/L",
"mmol/L" => "mmol/L",
"K/uL" => "10*3/uL",
"g/dL" => "g/dL",
_ => unit,
};
private static bool IsVitalSign(string code) => VitalSignCodes.Contains(code);
}
@@ -0,0 +1,83 @@
using Hl7.Fhir.Model;
using FhirPatient = Hl7.Fhir.Model.Patient;
public static class PatientMapper
{
/// <summary>
/// Maps a VigilCare Patient entity to a FHIR R4 Patient resource.
///
/// Mapping decisions:
/// - VigilCare stores full name as a single string; FHIR splits into family/given.
/// We use HumanName.Text for the full string and attempt to split on the last
/// space for family/given when possible.
/// - MRN maps to Identifier with system "urn:oid:2.16.840.1.113883.19.5" (example OID).
/// Facilities should configure their own OID.
/// - BloodType maps to an extension (no standard FHIR element for blood type).
/// - AllergiesJson is NOT mapped here — allergies should use AllergyIntolerance
/// resources, which are out of scope for Phase 11 v1.
/// </summary>
public static FhirPatient ToFhir(Patient entity, string baseUrl)
{
var patient = new FhirPatient
{
Id = entity.Id.ToString(),
Meta = new Meta
{
VersionId = "1",
LastUpdated = entity.UpdatedAt,
},
Active = true,
};
patient.Identifier.Add(new Identifier
{
System = "urn:oid:2.16.840.1.113883.19.5",
Value = entity.Mrn,
Use = Identifier.IdentifierUse.Official,
Type = new CodeableConcept("http://terminology.hl7.org/CodeSystem/v2-0203", "MR", "Medical Record Number"),
});
var name = new HumanName { Text = entity.FullName, Use = HumanName.NameUse.Official };
var parts = entity.FullName.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
{
name.Family = parts[^1];
name.Given = parts[..^1].ToList();
}
else
{
name.Family = entity.FullName;
}
patient.Name.Add(name);
if (entity.DateOfBirth.HasValue)
{
patient.BirthDate = entity.DateOfBirth.Value.ToString("yyyy-MM-dd");
}
if (!string.IsNullOrEmpty(entity.Sex))
{
patient.Gender = entity.Sex.ToLowerInvariant() switch
{
"male" => AdministrativeGender.Male,
"female" => AdministrativeGender.Female,
"other" => AdministrativeGender.Other,
_ => AdministrativeGender.Unknown,
};
}
if (!string.IsNullOrEmpty(entity.EmergencyContact))
{
patient.Contact.Add(new FhirPatient.ContactComponent
{
Relationship = new List<CodeableConcept>
{
new("http://terminology.hl7.org/CodeSystem/v2-0131", "C", "Emergency Contact")
},
Name = new HumanName { Text = entity.EmergencyContact },
});
}
return patient;
}
}
@@ -7,6 +7,7 @@ public record BatchDetailResponse(
string Status, string Status,
string BatchType, string BatchType,
string Track, string Track,
BatchTypeFieldRequirements FieldRequirements,
Guid? PatientId, Guid? PatientId,
string DocumentRef, string DocumentRef,
string? DocumentUrl, string? DocumentUrl,
@@ -34,6 +35,7 @@ public record BatchDetailResponse(
batch.Status.ToDbString(), batch.Status.ToDbString(),
batch.BatchType.ToDbString(), batch.BatchType.ToDbString(),
batch.Track.ToDbString(), batch.Track.ToDbString(),
BatchTypeFieldRequirements.ForBatchType(batch.BatchType),
batch.PatientId, batch.PatientId,
batch.DocumentRef, batch.DocumentRef,
documentUrl, documentUrl,
@@ -0,0 +1,70 @@
public record BatchTypeFieldRequirements(
bool ShowPatientDemographics,
bool ShowEncounterContext,
bool ShowEncounterSummaryFields,
bool ShowObservations,
bool ShowAllergies,
bool ShowMedications
)
{
public static BatchTypeFieldRequirements ForBatchType(BatchType batchType) => batchType switch
{
BatchType.PatientRegistration => new(
ShowPatientDemographics: true,
ShowEncounterContext: false,
ShowEncounterSummaryFields: false,
ShowObservations: false,
ShowAllergies: false,
ShowMedications: false),
BatchType.VitalsSheet => new(
ShowPatientDemographics: true,
ShowEncounterContext: true,
ShowEncounterSummaryFields: false,
ShowObservations: true,
ShowAllergies: false,
ShowMedications: false),
BatchType.LabResults => new(
ShowPatientDemographics: true,
ShowEncounterContext: true,
ShowEncounterSummaryFields: false,
ShowObservations: true,
ShowAllergies: false,
ShowMedications: false),
BatchType.AllergyUpdate => new(
ShowPatientDemographics: true,
ShowEncounterContext: false,
ShowEncounterSummaryFields: false,
ShowObservations: false,
ShowAllergies: true,
ShowMedications: false),
BatchType.EncounterSummary => new(
ShowPatientDemographics: true,
ShowEncounterContext: true,
ShowEncounterSummaryFields: true,
ShowObservations: false,
ShowAllergies: false,
ShowMedications: false),
BatchType.MedicationList => new(
ShowPatientDemographics: true,
ShowEncounterContext: false,
ShowEncounterSummaryFields: false,
ShowObservations: false,
ShowAllergies: false,
ShowMedications: true),
BatchType.Mixed => new(
ShowPatientDemographics: true,
ShowEncounterContext: true,
ShowEncounterSummaryFields: true,
ShowObservations: true,
ShowAllergies: true,
ShowMedications: true),
_ => throw new ArgumentOutOfRangeException(nameof(batchType))
};
}
@@ -5,8 +5,10 @@ public class CreateBatchForm
[Required] [Required]
public IFormFile File { get; set; } = null!; public IFormFile File { get; set; } = null!;
[Required] /// <summary>
public string BatchType { get; set; } = null!; /// Required unless <see cref="CoverSheetCode"/> is provided; cover sheet values override when both are sent.
/// </summary>
public string? BatchType { get; set; }
public string? Track { get; set; } public string? Track { get; set; }
@@ -15,5 +17,8 @@ public class CreateBatchForm
public Guid? SupersedesBatchId { get; set; } public Guid? SupersedesBatchId { get; set; }
public CreateBatchRequest ToMetadata() => public CreateBatchRequest ToMetadata() =>
new(BatchType, Track, PatientId, SupersedesBatchId); new(BatchType ?? throw new InvalidOperationException("BatchType is required."),
Track, PatientId, SupersedesBatchId);
public string? CoverSheetCode { get; set; }
} }
@@ -2,6 +2,8 @@ public record DraftPayloadResponse(
Guid BatchId, Guid BatchId,
string Status, string Status,
string BatchType, string BatchType,
BatchTypeFieldRequirements FieldRequirements,
OcrConfidenceMap? OcrConfidence,
DraftPatientDto? Patient, DraftPatientDto? Patient,
DraftEncounterDto? Encounter, DraftEncounterDto? Encounter,
List<DraftObservationDto> Observations List<DraftObservationDto> Observations
@@ -0,0 +1,6 @@
public record OcrConfidenceMap(
string Provider,
DateTimeOffset ProcessedAt,
int DurationMs,
Dictionary<string, double> FieldConfidences
);
@@ -0,0 +1 @@
public record BatchPdfRequest(List<Guid> CoverSheetIds);
@@ -0,0 +1,15 @@
public record CoverSheetResponse(
Guid Id,
string Code,
string BatchType,
string Track,
Guid? PatientId,
string? PatientName,
string? PatientMrn,
Guid? AssignToUserId,
string? AssignToUserName,
bool IsUsed,
Guid? BatchId,
DateTimeOffset CreatedAt,
DateTimeOffset? UsedAt
);
@@ -0,0 +1,7 @@
public record GenerateCoverSheetsRequest(
int Count,
string BatchType,
string Track,
Guid? PatientId,
Guid? AssignToUserId
);
@@ -0,0 +1,5 @@
public record OcrExtractedField(
string FieldName, // e.g. "patient.fullName", "observation.HEART_RATE.value"
string RawValue, // raw text as extracted
double Confidence // 0.0 1.0
);
@@ -0,0 +1,5 @@
public record OcrExtractionResult(
List<OcrExtractedField> Fields,
string RawText,
int DurationMs
);
@@ -2,29 +2,30 @@
/// Aggregate work queue health metrics for the supervisor dashboard. /// Aggregate work queue health metrics for the supervisor dashboard.
/// Returned by GET /api/v1/work-queue/overview. /// Returned by GET /api/v1/work-queue/overview.
/// </summary> /// </summary>
public record WorkQueueOverviewResponse( public record WorkQueueOverviewResponse
{
/// <summary> /// <summary>
/// Count of batches per status. Key is the DB status string /// Count of batches per status. Key is the DB status string
/// (e.g. "UPLOADED", "IN_ENTRY", "PENDING_VERIFICATION"). /// (e.g. "UPLOADED", "IN_ENTRY", "PENDING_VERIFICATION").
/// All 8 statuses are always present, even if count is 0. /// All 8 statuses are always present, even if count is 0.
/// </summary> /// </summary>
Dictionary<string, int> StatusCounts, public required Dictionary<string, int> StatusCounts { get; init; }
/// <summary> /// <summary>
/// Average time in minutes that batches currently in PendingVerification /// Average time in minutes that batches currently in PendingVerification
/// have been waiting. Zero if no batches are pending. /// have been waiting. Zero if no batches are pending.
/// </summary> /// </summary>
double AverageTimeInQueueMinutes, public required double AverageTimeInQueueMinutes { get; init; }
/// <summary> /// <summary>
/// Rejection rate as a decimal (0.0 to 1.0). Calculated as /// Rejection rate as a decimal (0.0 to 1.0). Calculated as
/// rejections / (rejections + verifications) over the last 24 hours. /// rejections / (rejections + verifications) over the last 24 hours.
/// </summary> /// </summary>
double RejectRate, public required double RejectRate { get; init; }
/// <summary> /// <summary>
/// Age in minutes of the oldest batch in PendingVerification status. /// Age in minutes of the oldest batch in PendingVerification status.
/// Zero if no batches are pending. /// Zero if no batches are pending.
/// </summary> /// </summary>
double OldestPendingVerificationMinutes public required double OldestPendingVerificationMinutes { get; init; }
); }
+25 -1
View File
@@ -51,6 +51,25 @@ try
builder.Services.Configure<PromotionRetryOptions>( builder.Services.Configure<PromotionRetryOptions>(
builder.Configuration.GetSection(PromotionRetryOptions.Section)); builder.Configuration.GetSection(PromotionRetryOptions.Section));
builder.Services.Configure<FhirOptions>(builder.Configuration.GetSection(FhirOptions.Section));
builder.Services.Configure<OcrOptions>(builder.Configuration.GetSection(OcrOptions.Section));
var ocrOptions = builder.Configuration.GetSection(OcrOptions.Section).Get<OcrOptions>();
if (ocrOptions?.Enabled == true)
{
builder.Services.AddSingleton<ImagePreprocessor>();
if (ocrOptions.Provider == "azure")
builder.Services.AddScoped<IOcrService, AzureDocumentOcrService>();
else
builder.Services.AddScoped<IOcrService, TesseractOcrService>();
builder.Services.AddScoped<OcrDraftPreFiller>();
builder.Services.AddHostedService<OcrProcessingService>();
}
// JWT Authentication // JWT Authentication
var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get<JwtOptions>()!; var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get<JwtOptions>()!;
@@ -121,6 +140,8 @@ try
builder.Services.AddScoped<IAttestationService, AttestationService>(); builder.Services.AddScoped<IAttestationService, AttestationService>();
builder.Services.AddScoped<ILiveCaptureService, LiveCaptureService>(); builder.Services.AddScoped<ILiveCaptureService, LiveCaptureService>();
builder.Services.AddScoped<IBatchEventService, BatchEventService>(); builder.Services.AddScoped<IBatchEventService, BatchEventService>();
builder.Services.AddScoped<ICoverSheetService, CoverSheetService>();
builder.Services.AddScoped<IFhirService, FhirService>();
builder.Services.AddHostedService<MetricsCollectorService>(); builder.Services.AddHostedService<MetricsCollectorService>();
builder.Services.AddHostedService<PromotionRetryService>(); builder.Services.AddHostedService<PromotionRetryService>();
@@ -142,7 +163,10 @@ try
builder.Services.AddValidatorsFromAssemblyContaining<Program>(); builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddScoped<ValidationFilter>(); builder.Services.AddScoped<ValidationFilter>();
builder.Services.AddControllers(options => builder.Services.AddControllers(options =>
options.Filters.AddService<ValidationFilter>()); {
options.OutputFormatters.Insert(0, new FhirJsonOutputFormatter());
options.Filters.AddService<ValidationFilter>();
});
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddVigilCareRecordsSwagger(); builder.Services.AddVigilCareRecordsSwagger();
+2 -2
View File
@@ -69,7 +69,8 @@ public class BatchService : IBatchService
patientId ??= supersededBatch.PatientId; patientId ??= supersededBatch.PatientId;
} }
var (objectKey, sha256, fileSize) = await _storage.UploadAsync(fileStream, contentType, Guid.NewGuid()); var batchId = Guid.NewGuid();
var (objectKey, sha256, fileSize) = await _storage.UploadAsync(fileStream, contentType, batchId);
// Duplicate detection: same SHA-256 for same patient within 24 hours // Duplicate detection: same SHA-256 for same patient within 24 hours
if (patientId.HasValue) if (patientId.HasValue)
@@ -101,7 +102,6 @@ public class BatchService : IBatchService
} }
} }
var batchId = Guid.NewGuid();
var batch = new DigitizationBatch var batch = new DigitizationBatch
{ {
Id = batchId, Id = batchId,
@@ -0,0 +1,204 @@
using System.Globalization;
using System.IO.Compression;
using System.Text;
using QRCoder;
/// <summary>
/// Builds printable cover sheet PDFs using raw PDF 1.4 objects and QRCoder for QR images.
/// Each page includes a header, QR code, human-readable metadata, and a footer instruction line.
/// </summary>
public static class CoverSheetPdfGenerator
{
private const float PageWidth = 612f;
private const float PageHeight = 792f;
private const float QrDisplaySize = 200f;
private const float LeftMargin = 72f;
public static byte[] Generate(CoverSheet sheet) =>
GenerateBatch(new[] { sheet });
public static byte[] GenerateBatch(IReadOnlyList<CoverSheet> sheets)
{
if (sheets.Count == 0)
throw new ArgumentException("At least one cover sheet is required.", nameof(sheets));
var pages = sheets.Select(BuildPage).ToList();
return BuildPdf(pages);
}
private sealed record PageData(string ContentStream, byte[] ImageRgb, int ImageWidth, int ImageHeight);
private static PageData BuildPage(CoverSheet sheet)
{
var (rgb, width, height) = GenerateQrRgb(sheet.Code);
var content = BuildContentStream(sheet);
return new PageData(content, rgb, width, height);
}
private static (byte[] Rgb, int Width, int Height) GenerateQrRgb(string code)
{
using var generator = new QRCodeGenerator();
using var data = generator.CreateQrCode(code, QRCodeGenerator.ECCLevel.M);
var modules = data.ModuleMatrix.Count;
const int scale = 8;
const int quiet = 2;
var size = (modules + quiet * 2) * scale;
var rgb = new byte[size * size * 3];
for (var y = 0; y < size; y++)
{
for (var x = 0; x < size; x++)
{
var mx = x / scale - quiet;
var my = y / scale - quiet;
var dark = mx >= 0 && my >= 0 && mx < modules && my < modules && data.ModuleMatrix[my][mx];
var color = dark ? (byte)0 : (byte)255;
var i = (y * size + x) * 3;
rgb[i] = color;
rgb[i + 1] = color;
rgb[i + 2] = color;
}
}
return (rgb, size, size);
}
private static string BuildContentStream(CoverSheet sheet)
{
var sb = new StringBuilder();
var qrX = (PageWidth - QrDisplaySize) / 2f;
const float qrY = 470f;
sb.AppendLine(CultureInfo.InvariantCulture, $"q {QrDisplaySize} 0 0 {QrDisplaySize} {qrX} {qrY} cm /Im1 Do Q");
WriteTextLine(sb, 18f, LeftMargin, 740f, "VigilCare Records - Cover Sheet");
var y = 430f;
y = WriteTextLine(sb, 12f, LeftMargin, y, $"Code: {sheet.Code}");
y = WriteTextLine(sb, 12f, LeftMargin, y, $"Batch Type: {sheet.BatchType.ToDbString()}");
y = WriteTextLine(sb, 12f, LeftMargin, y, $"Track: {sheet.Track.ToDbString()}");
if (sheet.Patient is not null)
{
y = WriteTextLine(sb, 12f, LeftMargin, y,
$"Patient: {sheet.Patient.FullName} (MRN {sheet.Patient.Mrn})");
}
if (sheet.AssignToUser is not null)
{
y = WriteTextLine(sb, 12f, LeftMargin, y,
$"Assigned To: {sheet.AssignToUser.FullName}");
}
var generated = sheet.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
WriteTextLine(sb, 12f, LeftMargin, y, $"Generated: {generated}");
WriteTextLine(sb, 10f, LeftMargin, 48f,
"Attach to front of chart section. Scanner reads QR code automatically.");
return sb.ToString();
}
private static float WriteTextLine(StringBuilder sb, float fontSize, float x, float y, string text)
{
sb.AppendLine("BT");
sb.AppendLine(CultureInfo.InvariantCulture, $"/F1 {fontSize} Tf");
sb.AppendLine(CultureInfo.InvariantCulture, $"{x} {y} Td");
sb.AppendLine(CultureInfo.InvariantCulture, $"({EscapePdfString(text)}) Tj");
sb.AppendLine("ET");
return y - fontSize - 8f;
}
private static byte[] BuildPdf(IReadOnlyList<PageData> pages)
{
using var ms = new MemoryStream();
ms.Write("%PDF-1.4\n"u8);
var offsets = new List<long>();
const int catalogId = 1;
const int pagesId = 2;
const int fontId = 3;
var pageIds = Enumerable.Range(0, pages.Count).Select(i => 4 + i * 3).ToArray();
var contentIds = pageIds.Select(id => id + 1).ToArray();
var imageIds = pageIds.Select(id => id + 2).ToArray();
offsets.Add(ms.Position);
WriteAscii(ms, $"{catalogId} 0 obj\n<< /Type /Catalog /Pages {pagesId} 0 R >>\nendobj\n");
offsets.Add(ms.Position);
var kids = string.Join(" ", pageIds.Select(id => $"{id} 0 R"));
WriteAscii(ms, $"{pagesId} 0 obj\n<< /Type /Pages /Kids [{kids}] /Count {pages.Count} >>\nendobj\n");
offsets.Add(ms.Position);
WriteAscii(ms, $"{fontId} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n");
for (var i = 0; i < pages.Count; i++)
{
var page = pages[i];
var pageId = pageIds[i];
var contentId = contentIds[i];
var imageId = imageIds[i];
var contentBytes = Encoding.ASCII.GetBytes(page.ContentStream);
var imageBytes = FlateEncode(page.ImageRgb);
offsets.Add(ms.Position);
WriteAscii(ms,
$"{pageId} 0 obj\n" +
$"<< /Type /Page /Parent {pagesId} 0 R " +
$"/MediaBox [0 0 {PageWidth} {PageHeight}] " +
$"/Contents {contentId} 0 R " +
$"/Resources << /Font << /F1 {fontId} 0 R >> /XObject << /Im1 {imageId} 0 R >> >> >>\n" +
"endobj\n");
offsets.Add(ms.Position);
WriteAscii(ms,
$"{contentId} 0 obj\n<< /Length {contentBytes.Length} >>\nstream\n");
ms.Write(contentBytes);
WriteAscii(ms, "\nendstream\nendobj\n");
offsets.Add(ms.Position);
WriteAscii(ms,
$"{imageId} 0 obj\n" +
$"<< /Type /XObject /Subtype /Image " +
$"/Width {page.ImageWidth} /Height {page.ImageHeight} " +
"/ColorSpace /DeviceRGB /BitsPerComponent 8 " +
$"/Filter /FlateDecode /Length {imageBytes.Length} >>\nstream\n");
ms.Write(imageBytes);
WriteAscii(ms, "\nendstream\nendobj\n");
}
var xrefOffset = ms.Position;
WriteAscii(ms, "xref\n");
WriteAscii(ms, $"0 {offsets.Count + 1}\n");
WriteAscii(ms, "0000000000 65535 f \n");
foreach (var offset in offsets)
WriteAscii(ms, $"{offset:D10} 00000 n \n");
WriteAscii(ms,
"trailer\n" +
$"<< /Size {offsets.Count + 1} /Root {catalogId} 0 R >>\n" +
"startxref\n" +
$"{xrefOffset}\n" +
"%%EOF\n");
return ms.ToArray();
}
private static void WriteAscii(Stream stream, string text) =>
stream.Write(Encoding.ASCII.GetBytes(text));
private static byte[] FlateEncode(byte[] data)
{
using var output = new MemoryStream();
using (var deflate = new ZLibStream(output, CompressionLevel.Optimal, leaveOpen: true))
deflate.Write(data, 0, data.Length);
return output.ToArray();
}
private static string EscapePdfString(string value) =>
value.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("(", "\\(", StringComparison.Ordinal)
.Replace(")", "\\)", StringComparison.Ordinal);
}
@@ -0,0 +1,138 @@
using Microsoft.EntityFrameworkCore;
public class CoverSheetService : ICoverSheetService
{
private readonly AppDbContext _db;
private readonly ILogger<CoverSheetService> _logger;
public CoverSheetService(AppDbContext db, ILogger<CoverSheetService> logger)
{
_db = db;
_logger = logger;
}
public async Task<List<CoverSheet>> GenerateAsync(
GenerateCoverSheetsRequest request, Guid actorUserId)
{
var count = Math.Clamp(request.Count, 1, 100);
var batchType = BatchTypeExtensions.FromDbString(request.BatchType.ToUpperInvariant());
var track = string.IsNullOrEmpty(request.Track)
? BatchTrack.Backfill
: BatchTrackExtensions.FromDbString(request.Track.ToUpperInvariant());
if (request.PatientId.HasValue)
{
var patientExists = await _db.Patients.AnyAsync(p => p.Id == request.PatientId.Value);
if (!patientExists)
throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
}
if (request.AssignToUserId.HasValue)
{
var user = await _db.Users.FindAsync(request.AssignToUserId.Value);
if (user is null || !user.IsActive)
throw new NotFoundException("User not found or inactive.", "USER_NOT_FOUND");
}
var sheets = new List<CoverSheet>(count);
for (var i = 0; i < count; i++)
{
var code = $"VCR-CS-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
sheets.Add(new CoverSheet
{
Id = Guid.NewGuid(),
Code = code,
BatchType = batchType,
Track = track,
PatientId = request.PatientId,
AssignToUserId = request.AssignToUserId,
GeneratedByUserId = actorUserId,
IsUsed = false,
CreatedAt = DateTimeOffset.UtcNow,
});
}
_db.CoverSheets.AddRange(sheets);
await _db.SaveChangesAsync();
_logger.LogInformation(
"Generated {Count} cover sheets for batch type {BatchType}, track {Track}",
count, batchType.ToDbString(), track.ToDbString());
return sheets;
}
public async Task<CoverSheet?> LookupByCodeAsync(string code)
{
return await _db.CoverSheets
.Include(c => c.Patient)
.Include(c => c.AssignToUser)
.FirstOrDefaultAsync(c => c.Code == code.ToUpperInvariant().Trim());
}
public async Task<CoverSheet?> LookupByIdAsync(Guid id)
{
return await _db.CoverSheets
.Include(c => c.Patient)
.Include(c => c.AssignToUser)
.FirstOrDefaultAsync(c => c.Id == id);
}
public async Task<List<CoverSheet>> GetByIdsAsync(IReadOnlyList<Guid> ids)
{
if (ids.Count == 0)
return new List<CoverSheet>();
var idSet = ids.Distinct().ToList();
var sheets = await _db.CoverSheets
.AsNoTracking()
.Include(c => c.Patient)
.Include(c => c.AssignToUser)
.Where(c => idSet.Contains(c.Id))
.ToListAsync();
var byId = sheets.ToDictionary(c => c.Id);
return idSet
.Where(byId.ContainsKey)
.Select(id => byId[id])
.ToList();
}
public async Task RedeemAsync(Guid coverSheetId, Guid batchId)
{
var sheet = await _db.CoverSheets.FindAsync(coverSheetId);
if (sheet is null)
throw new NotFoundException("Cover sheet not found.", "COVER_SHEET_NOT_FOUND");
if (sheet.IsUsed)
throw new ConflictException(
$"Cover sheet {sheet.Code} has already been used for batch {sheet.BatchId}.",
"COVER_SHEET_ALREADY_USED");
sheet.IsUsed = true;
sheet.BatchId = batchId;
sheet.UsedAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync();
}
public async Task<List<CoverSheet>> ListAsync(
bool? isUsed, Guid? patientId, int page, int pageSize)
{
var query = _db.CoverSheets
.AsNoTracking()
.Include(c => c.Patient)
.Include(c => c.AssignToUser)
.AsQueryable();
if (isUsed.HasValue)
query = query.Where(c => c.IsUsed == isUsed.Value);
if (patientId.HasValue)
query = query.Where(c => c.PatientId == patientId.Value);
return await query
.OrderByDescending(c => c.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
}
@@ -58,6 +58,18 @@ public class DocumentStorageService : IDocumentStorageService
.WithExpiry(_options.PresignedUrlExpiryMinutes * 60)); .WithExpiry(_options.PresignedUrlExpiryMinutes * 60));
} }
public async Task<Stream> DownloadAsync(string objectKey)
{
var memStream = new MemoryStream();
await _minio.GetObjectAsync(new GetObjectArgs()
.WithBucket(_options.BucketName)
.WithObject(objectKey)
.WithCallbackStream(stream => stream.CopyTo(memStream)));
memStream.Position = 0;
return memStream;
}
private async Task EnsureBucketAsync() private async Task EnsureBucketAsync()
{ {
var exists = await _minio.BucketExistsAsync(new BucketExistsArgs().WithBucket(_options.BucketName)); var exists = await _minio.BucketExistsAsync(new BucketExistsArgs().WithBucket(_options.BucketName));
@@ -31,10 +31,29 @@ public class DraftService : IDraftService
if (batch is null) if (batch is null)
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND"); throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
var ocrResult = await _db.OcrResults
.AsNoTracking()
.FirstOrDefaultAsync(o => o.BatchId == batchId);
OcrConfidenceMap? ocrConfidence = null;
if (ocrResult is not null)
{
var fieldConfidences = JsonSerializer.Deserialize<Dictionary<string, double>>(
ocrResult.FieldConfidencesJson) ?? new Dictionary<string, double>();
ocrConfidence = new OcrConfidenceMap(
ocrResult.Provider,
ocrResult.ProcessedAt,
ocrResult.DurationMs,
fieldConfidences);
}
return new DraftPayloadResponse( return new DraftPayloadResponse(
batch.Id, batch.Id,
batch.Status.ToDbString(), batch.Status.ToDbString(),
batch.BatchType.ToDbString(), batch.BatchType.ToDbString(),
BatchTypeFieldRequirements.ForBatchType(batch.BatchType),
ocrConfidence,
batch.DraftPatient is not null ? MapPatient(batch.DraftPatient) : null, batch.DraftPatient is not null ? MapPatient(batch.DraftPatient) : null,
batch.DraftEncounter is not null ? MapEncounter(batch.DraftEncounter) : null, batch.DraftEncounter is not null ? MapEncounter(batch.DraftEncounter) : null,
batch.DraftObservations.Select(MapObservation).OrderBy(o => o.RecordedAt).ToList() batch.DraftObservations.Select(MapObservation).OrderBy(o => o.RecordedAt).ToList()
+360
View File
@@ -0,0 +1,360 @@
using Hl7.Fhir.Model;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using System.Globalization;
using FhirPatient = Hl7.Fhir.Model.Patient;
using FhirEncounter = Hl7.Fhir.Model.Encounter;
using FhirObservation = Hl7.Fhir.Model.Observation;
public class FhirService : IFhirService
{
private readonly AppDbContext _db;
private readonly FhirOptions _options;
public FhirService(AppDbContext db, IOptions<FhirOptions> options)
{
_db = db;
_options = options.Value;
}
// --- Patient ---
public async Task<FhirPatient?> GetPatientAsync(Guid id)
{
var entity = await _db.Patients.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id);
return entity is null ? null : PatientMapper.ToFhir(entity, _options.BaseUrl);
}
public async Task<Bundle> SearchPatientsAsync(
string? name, string? birthdate, string? identifier, int count, int offset)
{
count = Math.Clamp(count, 1, 100);
var query = _db.Patients.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(name))
query = query.Where(p => EF.Functions.ILike(p.FullName, $"%{name}%"));
if (!string.IsNullOrWhiteSpace(birthdate) && DateOnly.TryParse(birthdate, out var dob))
query = query.Where(p => p.DateOfBirth == dob);
if (!string.IsNullOrWhiteSpace(identifier))
query = query.Where(p => p.Mrn == identifier);
var total = await query.CountAsync();
var entities = await query.OrderBy(p => p.FullName).Skip(offset).Take(count).ToListAsync();
return BuildSearchBundle(
entities.Select(e => PatientMapper.ToFhir(e, _options.BaseUrl)).Cast<Resource>().ToList(),
total, count, offset, "Patient");
}
// --- Encounter ---
public async Task<FhirEncounter?> GetEncounterAsync(Guid id)
{
var entity = await _db.Encounters.AsNoTracking().FirstOrDefaultAsync(e => e.Id == id);
return entity is null ? null : EncounterMapper.ToFhir(entity, _options.BaseUrl);
}
public async Task<Bundle> SearchEncountersAsync(
string? patient, string? status, string? date, int count, int offset)
{
count = Math.Clamp(count, 1, 100);
var query = _db.Encounters.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(patient) && Guid.TryParse(patient, out var patientId))
query = query.Where(e => e.PatientId == patientId);
if (!string.IsNullOrWhiteSpace(status))
{
var fhirStatus = status.ToLowerInvariant();
query = fhirStatus switch
{
"in-progress" => query.Where(e => e.Status == "active"),
"finished" => query.Where(e => e.Status == "discharged"),
_ => query.Where(e => e.Status == status),
};
}
if (!string.IsNullOrWhiteSpace(date) && TryParseUtcDate(date, out var encounterDay))
{
var dayEnd = encounterDay.AddDays(1);
query = query.Where(e =>
e.AdmissionDate != null &&
e.AdmissionDate.Value >= encounterDay &&
e.AdmissionDate.Value < dayEnd);
}
var total = await query.CountAsync();
var entities = await query.OrderByDescending(e => e.AdmissionDate).Skip(offset).Take(count).ToListAsync();
return BuildSearchBundle(
entities.Select(e => EncounterMapper.ToFhir(e, _options.BaseUrl)).Cast<Resource>().ToList(),
total, count, offset, "Encounter");
}
// --- Observation ---
public async Task<FhirObservation?> GetObservationAsync(Guid id)
{
var entity = await _db.Observations.AsNoTracking().FirstOrDefaultAsync(o => o.Id == id);
return entity is null ? null : ObservationMapper.ToFhir(entity, _options.BaseUrl);
}
public async Task<Bundle> SearchObservationsAsync(
string? patient, string? code, string? date,
string? category, string? encounter, int count, int offset)
{
count = Math.Clamp(count, 1, 200);
var query = _db.Observations.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(patient) && Guid.TryParse(patient, out var patientId))
query = query.Where(o => o.PatientId == patientId);
if (!string.IsNullOrWhiteSpace(encounter) && Guid.TryParse(encounter, out var encId))
query = query.Where(o => o.EncounterId == encId);
if (!string.IsNullOrWhiteSpace(code))
{
// Accept both LOINC codes (e.g., "8867-4") and VigilCare codes (e.g., "HEART_RATE")
var loincToVigilCare = ObservationMapper.GetReverseLoincMapping();
var vigilCareCode = loincToVigilCare.GetValueOrDefault(code, code);
query = query.Where(o => o.ObservationCode == vigilCareCode);
}
if (!string.IsNullOrWhiteSpace(category))
{
var isVitalSigns = category.Equals("vital-signs", StringComparison.OrdinalIgnoreCase);
var vitalCodes = ObservationMapper.GetVitalSignCodes();
query = isVitalSigns
? query.Where(o => vitalCodes.Contains(o.ObservationCode))
: query.Where(o => !vitalCodes.Contains(o.ObservationCode));
}
if (!string.IsNullOrWhiteSpace(date))
query = ApplyObservationDateFilter(query, date);
var total = await query.CountAsync();
var entities = await query.OrderByDescending(o => o.RecordedAt).Skip(offset).Take(count).ToListAsync();
return BuildSearchBundle(
entities.Select(e => ObservationMapper.ToFhir(e, _options.BaseUrl)).Cast<Resource>().ToList(),
total, count, offset, "Observation");
}
public async Task<Bundle?> GetPatientEverythingAsync(Guid patientId)
{
var patient = await _db.Patients.AsNoTracking().FirstOrDefaultAsync(p => p.Id == patientId);
if (patient is null) return null;
var encounters = await _db.Encounters.AsNoTracking()
.Where(e => e.PatientId == patientId)
.ToListAsync();
var observations = await _db.Observations.AsNoTracking()
.Where(o => o.PatientId == patientId)
.OrderByDescending(o => o.RecordedAt)
.ToListAsync();
var bundle = new Bundle
{
Type = Bundle.BundleType.Searchset,
Total = 1 + encounters.Count + observations.Count,
Meta = new Meta { LastUpdated = DateTimeOffset.UtcNow },
};
bundle.Entry.Add(new Bundle.EntryComponent
{
FullUrl = $"{_options.BaseUrl}/Patient/{patient.Id}",
Resource = PatientMapper.ToFhir(patient, _options.BaseUrl),
Search = new Bundle.SearchComponent { Mode = Bundle.SearchEntryMode.Match },
});
foreach (var enc in encounters)
{
bundle.Entry.Add(new Bundle.EntryComponent
{
FullUrl = $"{_options.BaseUrl}/Encounter/{enc.Id}",
Resource = EncounterMapper.ToFhir(enc, _options.BaseUrl),
Search = new Bundle.SearchComponent { Mode = Bundle.SearchEntryMode.Include },
});
}
foreach (var obs in observations)
{
bundle.Entry.Add(new Bundle.EntryComponent
{
FullUrl = $"{_options.BaseUrl}/Observation/{obs.Id}",
Resource = ObservationMapper.ToFhir(obs, _options.BaseUrl),
Search = new Bundle.SearchComponent { Mode = Bundle.SearchEntryMode.Include },
});
}
return bundle;
}
// --- Bundle builder ---
private Bundle BuildSearchBundle(
List<Resource> resources, int total, int count, int offset, string resourceType)
{
var bundle = new Bundle
{
Type = Bundle.BundleType.Searchset,
Total = total,
Meta = new Meta { LastUpdated = DateTimeOffset.UtcNow },
};
foreach (var resource in resources)
{
bundle.Entry.Add(new Bundle.EntryComponent
{
FullUrl = $"{_options.BaseUrl}/{resourceType}/{resource.Id}",
Resource = resource,
Search = new Bundle.SearchComponent { Mode = Bundle.SearchEntryMode.Match },
});
}
// Pagination links
var selfUrl = $"{_options.BaseUrl}/{resourceType}?_count={count}&_offset={offset}";
bundle.Link.Add(new Bundle.LinkComponent { Relation = "self", Url = selfUrl });
if (offset + count < total)
{
var nextUrl = $"{_options.BaseUrl}/{resourceType}?_count={count}&_offset={offset + count}";
bundle.Link.Add(new Bundle.LinkComponent { Relation = "next", Url = nextUrl });
}
if (offset > 0)
{
var prevOffset = Math.Max(0, offset - count);
var prevUrl = $"{_options.BaseUrl}/{resourceType}?_count={count}&_offset={prevOffset}";
bundle.Link.Add(new Bundle.LinkComponent { Relation = "previous", Url = prevUrl });
}
return bundle;
}
// --- CapabilityStatement ---
public CapabilityStatement GetCapabilityStatement()
{
return new CapabilityStatement
{
Status = PublicationStatus.Active,
Date = "2026-06-27",
Kind = CapabilityStatementKind.Instance,
FhirVersion = FHIRVersion.N4_0_1,
Format = new[] { "json" },
Software = new CapabilityStatement.SoftwareComponent
{
Name = _options.PublisherName,
Version = _options.ServerVersion,
},
Implementation = new CapabilityStatement.ImplementationComponent
{
Description = "VigilCare Records FHIR R4 API — read-only access to promoted clinical data",
Url = _options.BaseUrl,
},
Rest = new List<CapabilityStatement.RestComponent>
{
new()
{
Mode = CapabilityStatement.RestfulCapabilityMode.Server,
Resource = new List<CapabilityStatement.ResourceComponent>
{
FhirResource("Patient", new[] { "read", "search-type" },
new[] { "name", "birthdate", "identifier" }),
FhirResource("Encounter", new[] { "read", "search-type" },
new[] { "patient", "status", "date" }),
FhirResource("Observation", new[] { "read", "search-type" },
new[] { "patient", "code", "date", "category", "encounter" }),
},
}
}
};
}
private static CapabilityStatement.ResourceComponent FhirResource(
string type, string[] interactions, string[] searchParams)
{
var resource = new CapabilityStatement.ResourceComponent
{
Type = type,
};
foreach (var interaction in interactions)
{
resource.Interaction.Add(new CapabilityStatement.ResourceInteractionComponent
{
Code = Enum.Parse<CapabilityStatement.TypeRestfulInteraction>(
interaction.Replace("-", ""), ignoreCase: true),
});
}
foreach (var param in searchParams)
{
resource.SearchParam.Add(new CapabilityStatement.SearchParamComponent
{
Name = param,
Type = SearchParamType.String,
});
}
return resource;
}
private static IQueryable<Observation> ApplyObservationDateFilter(
IQueryable<Observation> query, string date)
{
if (date.StartsWith("gt", StringComparison.OrdinalIgnoreCase)
&& TryParseUtcDate(date[2..], out var gt))
{
return query.Where(o => o.RecordedAt > gt);
}
if (date.StartsWith("lt", StringComparison.OrdinalIgnoreCase)
&& TryParseUtcDate(date[2..], out var lt))
{
return query.Where(o => o.RecordedAt < lt);
}
if (date.StartsWith("ge", StringComparison.OrdinalIgnoreCase)
&& TryParseUtcDate(date[2..], out var ge))
{
return query.Where(o => o.RecordedAt >= ge);
}
if (date.StartsWith("le", StringComparison.OrdinalIgnoreCase)
&& TryParseUtcDate(date[2..], out var le))
{
return query.Where(o => o.RecordedAt <= le);
}
if (TryParseUtcDate(date, out var dayStart))
{
var dayEnd = dayStart.AddDays(1);
return query.Where(o => o.RecordedAt >= dayStart && o.RecordedAt < dayEnd);
}
return query;
}
private static bool TryParseUtcDate(string value, out DateTimeOffset utc)
{
if (DateTimeOffset.TryParse(
value,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out utc))
{
return true;
}
utc = default;
return false;
}
}
@@ -22,7 +22,7 @@ public class IdempotencyService : IIdempotencyService
return record; return record;
} }
public async Task SaveAsync(string idempotencyKey, string operationName, Guid resourceId, public Task SaveAsync(string idempotencyKey, string operationName, Guid resourceId,
int httpStatusCode, object responseBody, TimeSpan? ttl = null) int httpStatusCode, object responseBody, TimeSpan? ttl = null)
{ {
var effectiveTtl = ttl ?? DefaultTtl; var effectiveTtl = ttl ?? DefaultTtl;
@@ -46,5 +46,6 @@ public class IdempotencyService : IIdempotencyService
_db.IdempotencyRecords.Add(record); _db.IdempotencyRecords.Add(record);
// SaveChanges is called by the caller (within the same transaction) // SaveChanges is called by the caller (within the same transaction)
return Task.CompletedTask;
} }
} }
@@ -0,0 +1,9 @@
public interface ICoverSheetService
{
Task<List<CoverSheet>> GenerateAsync(GenerateCoverSheetsRequest request, Guid actorUserId);
Task<CoverSheet?> LookupByCodeAsync(string code);
Task<CoverSheet?> LookupByIdAsync(Guid id);
Task<List<CoverSheet>> GetByIdsAsync(IReadOnlyList<Guid> ids);
Task RedeemAsync(Guid coverSheetId, Guid batchId);
Task<List<CoverSheet>> ListAsync(bool? isUsed, Guid? patientId, int page, int pageSize);
}
@@ -2,4 +2,5 @@ public interface IDocumentStorageService
{ {
Task<(string objectKey, string sha256, long fileSize)> UploadAsync(Stream fileStream, string contentType, Guid batchId); Task<(string objectKey, string sha256, long fileSize)> UploadAsync(Stream fileStream, string contentType, Guid batchId);
Task<string> GetPresignedUrlAsync(string objectKey); Task<string> GetPresignedUrlAsync(string objectKey);
Task<Stream> DownloadAsync(string objectKey);
} }
@@ -0,0 +1,22 @@
using Hl7.Fhir.Model;
using FhirPatient = Hl7.Fhir.Model.Patient;
using FhirEncounter = Hl7.Fhir.Model.Encounter;
using FhirObservation = Hl7.Fhir.Model.Observation;
public interface IFhirService
{
Task<FhirPatient?> GetPatientAsync(Guid id);
Task<Bundle> SearchPatientsAsync(string? name, string? birthdate, string? identifier, int count, int offset);
Task<FhirEncounter?> GetEncounterAsync(Guid id);
Task<Bundle> SearchEncountersAsync(string? patient, string? status, string? date, int count, int offset);
Task<FhirObservation?> GetObservationAsync(Guid id);
Task<Bundle> SearchObservationsAsync(
string? patient, string? code, string? date,
string? category, string? encounter, int count, int offset);
Task<Bundle?> GetPatientEverythingAsync(Guid patientId);
CapabilityStatement GetCapabilityStatement();
}
@@ -0,0 +1,4 @@
public interface IOcrService
{
Task<OcrExtractionResult> ExtractAsync(Stream documentStream, string contentType);
}
@@ -0,0 +1,264 @@
using Azure;
using Azure.AI.DocumentIntelligence;
using Microsoft.Extensions.Options;
public class AzureDocumentOcrService : IOcrService
{
private const double DefaultTableConfidence = 0.75;
private static readonly Dictionary<string, string> ClinicalLabelMap =
new(StringComparer.OrdinalIgnoreCase)
{
["patient name"] = "patient.fullName",
["name"] = "patient.fullName",
["full name"] = "patient.fullName",
["date of birth"] = "patient.dateOfBirth",
["dob"] = "patient.dateOfBirth",
["birth date"] = "patient.dateOfBirth",
["sex"] = "patient.sex",
["gender"] = "patient.sex",
["admission date"] = "encounter.admissionDate",
["admitted"] = "encounter.admissionDate",
["department"] = "encounter.department",
["ward"] = "encounter.department",
["unit"] = "encounter.department",
["room"] = "encounter.roomBed",
["bed"] = "encounter.roomBed",
["room/bed"] = "encounter.roomBed",
["hr"] = "observation.HEART_RATE.value",
["heart rate"] = "observation.HEART_RATE.value",
["pulse"] = "observation.HEART_RATE.value",
["temp"] = "observation.TEMP_C.value",
["temperature"] = "observation.TEMP_C.value",
["bp sys"] = "observation.BP_SYSTOLIC.value",
["systolic"] = "observation.BP_SYSTOLIC.value",
["bp dia"] = "observation.BP_DIASTOLIC.value",
["diastolic"] = "observation.BP_DIASTOLIC.value",
["rr"] = "observation.RESP_RATE.value",
["resp rate"] = "observation.RESP_RATE.value",
["respiratory rate"] = "observation.RESP_RATE.value",
["spo2"] = "observation.SPO2.value",
["o2 sat"] = "observation.SPO2.value",
["oxygen saturation"] = "observation.SPO2.value",
};
private static readonly HashSet<string> TimestampHeaders = new(StringComparer.OrdinalIgnoreCase)
{
"time", "date", "datetime", "date/time", "recorded", "recorded at", "timestamp"
};
private static readonly HashSet<string> GenericTableHeaders = new(StringComparer.OrdinalIgnoreCase)
{
"label", "name", "parameter", "field", "item", "value", "result", "reading"
};
private readonly DocumentIntelligenceClient _client;
private readonly ILogger<AzureDocumentOcrService> _logger;
public AzureDocumentOcrService(
IOptions<OcrOptions> options,
ILogger<AzureDocumentOcrService> logger)
{
var opts = options.Value.Azure;
_client = new DocumentIntelligenceClient(
new Uri(opts.Endpoint),
new AzureKeyCredential(opts.ApiKey));
_logger = logger;
}
public async Task<OcrExtractionResult> ExtractAsync(
Stream documentStream, string contentType)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var content = BinaryData.FromStream(documentStream);
var operation = await _client.AnalyzeDocumentAsync(
WaitUntil.Completed,
"prebuilt-document",
content);
var result = operation.Value;
var fields = new List<OcrExtractedField>();
foreach (var kv in result.KeyValuePairs ?? [])
{
if (kv.Key?.Content is null || kv.Value?.Content is null) continue;
var fieldName = MapAzureKeyToFieldName(kv.Key.Content);
if (fieldName is null) continue;
fields.Add(new OcrExtractedField(
fieldName,
kv.Value.Content,
kv.Confidence));
}
foreach (var table in result.Tables ?? [])
{
fields.AddRange(ExtractTableObservations(table));
}
var rawText = string.Join("\n", (result.Pages ?? [])
.SelectMany(p => p.Lines?.Select(l => l.Content) ?? []));
sw.Stop();
return new OcrExtractionResult(fields, rawText, (int)sw.ElapsedMilliseconds);
}
private static string? MapAzureKeyToFieldName(string key)
{
var normalized = key.Trim();
if (normalized.Length == 0)
return null;
return ClinicalLabelMap.GetValueOrDefault(normalized);
}
private static List<OcrExtractedField> ExtractTableObservations(DocumentTable table)
{
var fields = new List<OcrExtractedField>();
if (table.Cells is null || table.Cells.Count == 0)
return fields;
if (table.ColumnCount == 2)
{
var labelValueFields = ExtractLabelValueTable(table);
if (labelValueFields.Count > 0)
return labelValueFields;
}
return ExtractGridTable(table);
}
private static List<OcrExtractedField> ExtractLabelValueTable(DocumentTable table)
{
var fields = new List<OcrExtractedField>();
var dataStartRow = HasGenericHeaderRow(table.Cells) ? 1 : 0;
for (var row = dataStartRow; row < table.RowCount; row++)
{
var key = GetCellContent(table.Cells, row, 0);
var value = GetCellContent(table.Cells, row, 1);
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
continue;
var fieldName = MapAzureKeyToFieldName(key);
if (fieldName is null)
continue;
fields.Add(new OcrExtractedField(fieldName, value.Trim(), DefaultTableConfidence));
}
return fields;
}
private static List<OcrExtractedField> ExtractGridTable(DocumentTable table)
{
var fields = new List<OcrExtractedField>();
var headerCells = table.Cells
.Where(c => c.RowIndex == 0)
.OrderBy(c => c.ColumnIndex)
.ToList();
if (headerCells.Count == 0)
return fields;
var columnMappings = new Dictionary<int, string?>();
foreach (var headerCell in headerCells)
{
columnMappings[headerCell.ColumnIndex] = MapTableHeader(headerCell.Content);
}
var observationCodes = columnMappings.Values
.Where(v => v is not null and not "recordedAt")
.Cast<string>()
.Distinct()
.ToList();
if (observationCodes.Count == 0)
return fields;
for (var row = 1; row < table.RowCount; row++)
{
string? recordedAt = null;
foreach (var cell in table.Cells.Where(c => c.RowIndex == row))
{
if (!columnMappings.TryGetValue(cell.ColumnIndex, out var mapping)
|| mapping is null
|| string.IsNullOrWhiteSpace(cell.Content))
{
continue;
}
var content = cell.Content.Trim();
if (mapping == "recordedAt")
{
recordedAt = content;
continue;
}
fields.Add(new OcrExtractedField(
$"observation.{mapping}.value",
content,
DefaultTableConfidence));
}
if (recordedAt is null)
continue;
foreach (var code in observationCodes)
{
fields.Add(new OcrExtractedField(
$"observation.{code}.recordedAt",
recordedAt,
DefaultTableConfidence));
}
}
return fields;
}
private static string? MapTableHeader(string? header)
{
if (string.IsNullOrWhiteSpace(header))
return null;
var normalized = header.Trim();
if (TimestampHeaders.Contains(normalized))
return "recordedAt";
var fieldName = MapAzureKeyToFieldName(normalized);
if (fieldName is null)
return null;
const string observationPrefix = "observation.";
const string valueSuffix = ".value";
if (fieldName.StartsWith(observationPrefix, StringComparison.Ordinal)
&& fieldName.EndsWith(valueSuffix, StringComparison.Ordinal))
{
return fieldName[observationPrefix.Length..^valueSuffix.Length];
}
return null;
}
private static bool HasGenericHeaderRow(IReadOnlyList<DocumentTableCell> cells)
{
var headerCells = cells.Where(c => c.RowIndex == 0).ToList();
if (headerCells.Count != 2)
return false;
return headerCells.All(c =>
GenericTableHeaders.Contains(c.Content?.Trim() ?? string.Empty));
}
private static string? GetCellContent(
IReadOnlyList<DocumentTableCell> cells, int row, int column)
{
return cells
.FirstOrDefault(c => c.RowIndex == row && c.ColumnIndex == column)
?.Content;
}
}
@@ -0,0 +1,66 @@
using Docnet.Core;
using Docnet.Core.Models;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
public class ImagePreprocessor
{
// US Letter at 300 DPI — sufficient for Tesseract on clinical scans.
private const int PdfRenderWidth = 2550;
private const int PdfRenderHeight = 3300;
public Stream Preprocess(Stream input, string contentType)
{
Stream? pdfRenderStream = null;
try
{
if (contentType == "application/pdf")
{
pdfRenderStream = RenderPdfFirstPage(input);
input = pdfRenderStream;
}
using var image = Image.Load(input);
image.Mutate(ctx => ctx
.Grayscale()
.GaussianSharpen(1.5f)
.BinaryThreshold(0.5f));
var output = new MemoryStream();
image.SaveAsPng(output);
output.Position = 0;
return output;
}
finally
{
pdfRenderStream?.Dispose();
}
}
private static Stream RenderPdfFirstPage(Stream pdfStream)
{
using var buffer = new MemoryStream();
pdfStream.CopyTo(buffer);
var pdfBytes = buffer.ToArray();
using var docReader = DocLib.Instance.GetDocReader(
pdfBytes,
new PageDimensions(PdfRenderWidth, PdfRenderHeight));
if (docReader.GetPageCount() == 0)
throw new InvalidOperationException("PDF contains no pages.");
using var pageReader = docReader.GetPageReader(0);
var rawBytes = pageReader.GetImage();
var width = pageReader.GetPageWidth();
var height = pageReader.GetPageHeight();
using var image = Image.LoadPixelData<Bgra32>(rawBytes, width, height);
var output = new MemoryStream();
image.SaveAsPng(output);
output.Position = 0;
return output;
}
}
@@ -0,0 +1,252 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
public class OcrDraftPreFiller
{
private static readonly Dictionary<string, string> DefaultUnits = new(StringComparer.Ordinal)
{
["HEART_RATE"] = "bpm",
["TEMP_C"] = "C",
["BP_SYSTOLIC"] = "mmHg",
["BP_DIASTOLIC"] = "mmHg",
["RESP_RATE"] = "breaths/min",
["SPO2"] = "%",
["POTASSIUM_MEQ_L"] = "mEq/L",
["WBC_K_UL"] = "K/uL",
["GLUCOSE_MG_DL"] = "mg/dL",
["LACTATE_MMOL_L"] = "mmol/L",
};
private static readonly Regex ObservationValueField =
new(@"^observation\.([^.]+)\.value$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly AppDbContext _db;
private readonly OcrOptions _options;
private readonly ILogger<OcrDraftPreFiller> _logger;
public OcrDraftPreFiller(
AppDbContext db,
IOptions<OcrOptions> options,
ILogger<OcrDraftPreFiller> logger)
{
_db = db;
_options = options.Value;
_logger = logger;
}
public async Task PreFillAsync(
Guid batchId, BatchType batchType, OcrExtractionResult extraction)
{
var requirements = BatchTypeFieldRequirements.ForBatchType(batchType);
var confidentFields = extraction.Fields
.Where(f => f.Confidence >= _options.ConfidenceThreshold)
.GroupBy(f => f.FieldName, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
if (requirements.ShowPatientDemographics)
await PreFillPatientAsync(batchId, confidentFields);
if (requirements.ShowEncounterContext)
await PreFillEncounterAsync(batchId, confidentFields);
if (requirements.ShowObservations)
await PreFillObservationsAsync(batchId, confidentFields);
await _db.SaveChangesAsync();
}
private async Task PreFillPatientAsync(
Guid batchId, Dictionary<string, OcrExtractedField> fields)
{
if (!fields.Keys.Any(k => k.StartsWith("patient.", StringComparison.Ordinal)))
return;
var patient = await _db.DraftPatients.FirstOrDefaultAsync(p => p.BatchId == batchId);
var now = DateTimeOffset.UtcNow;
if (patient is null)
{
patient = new DraftPatient
{
Id = Guid.NewGuid(),
BatchId = batchId,
CreatedAt = now,
UpdatedAt = now
};
_db.DraftPatients.Add(patient);
}
if (fields.TryGetValue("patient.fullName", out var name))
patient.FullName = name.RawValue.Trim();
if (fields.TryGetValue("patient.dateOfBirth", out var dob)
&& TryParseDate(dob.RawValue, out var parsedDob))
patient.DateOfBirth = parsedDob;
if (fields.TryGetValue("patient.sex", out var sex))
patient.Sex = NormalizeSex(sex.RawValue);
patient.UpdatedAt = now;
}
private async Task PreFillEncounterAsync(
Guid batchId, Dictionary<string, OcrExtractedField> fields)
{
if (!fields.Keys.Any(k => k.StartsWith("encounter.", StringComparison.Ordinal)))
return;
var encounter = await _db.DraftEncounters.FirstOrDefaultAsync(e => e.BatchId == batchId);
var now = DateTimeOffset.UtcNow;
if (encounter is null)
{
encounter = new DraftEncounter
{
Id = Guid.NewGuid(),
BatchId = batchId,
CreatedAt = now,
UpdatedAt = now
};
_db.DraftEncounters.Add(encounter);
}
if (fields.TryGetValue("encounter.admissionDate", out var admissionDate)
&& TryParseDateTime(admissionDate.RawValue, out var parsedAdmission))
encounter.AdmissionDate = parsedAdmission;
if (fields.TryGetValue("encounter.department", out var department)
&& TryParseDepartment(department.RawValue, out var parsedDepartment))
encounter.Department = parsedDepartment;
if (fields.TryGetValue("encounter.roomBed", out var roomBed))
encounter.RoomBed = roomBed.RawValue.Trim();
if (fields.TryGetValue("encounter.admissionReason", out var admissionReason))
encounter.AdmissionReason = admissionReason.RawValue.Trim();
encounter.UpdatedAt = now;
}
private async Task PreFillObservationsAsync(
Guid batchId, Dictionary<string, OcrExtractedField> fields)
{
var observationCodes = fields.Keys
.Select(k => ObservationValueField.Match(k))
.Where(m => m.Success)
.Select(m => m.Groups[1].Value)
.Distinct(StringComparer.Ordinal)
.ToList();
if (observationCodes.Count == 0)
return;
var existingCodes = await _db.DraftObservations
.Where(o => o.BatchId == batchId)
.Select(o => o.ObservationCode)
.ToListAsync();
var existing = existingCodes.ToHashSet(StringComparer.Ordinal);
var now = DateTimeOffset.UtcNow;
foreach (var code in observationCodes)
{
if (existing.Contains(code))
continue;
if (!fields.TryGetValue($"observation.{code}.value", out var valueField))
continue;
if (!decimal.TryParse(
valueField.RawValue,
NumberStyles.Number,
CultureInfo.InvariantCulture,
out var value)
&& !decimal.TryParse(valueField.RawValue, out value))
{
_logger.LogDebug(
"Skipping OCR observation {Code} for batch {BatchId}: unparsable value '{Value}'",
code, batchId, valueField.RawValue);
continue;
}
if (!PlausibilityValidator.IsPlausible(code, value, out var reason))
{
_logger.LogDebug(
"Skipping OCR observation {Code} for batch {BatchId}: {Reason}",
code, batchId, reason);
continue;
}
var unit = fields.TryGetValue($"observation.{code}.unit", out var unitField)
? unitField.RawValue.Trim()
: DefaultUnits.GetValueOrDefault(code, string.Empty);
var recordedAt = now;
if (fields.TryGetValue($"observation.{code}.recordedAt", out var recordedAtField)
&& TryParseDateTime(recordedAtField.RawValue, out var parsedRecordedAt))
recordedAt = parsedRecordedAt;
_db.DraftObservations.Add(new DraftObservation
{
Id = Guid.NewGuid(),
BatchId = batchId,
ObservationCode = code,
Value = value,
Unit = unit,
RecordedAt = recordedAt,
CreatedAt = now
});
}
}
private static bool TryParseDate(string raw, out DateOnly result)
{
if (DateOnly.TryParse(raw.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.None, out result))
return true;
return DateOnly.TryParse(raw.Trim(), out result);
}
private static bool TryParseDateTime(string raw, out DateTimeOffset result)
{
if (DateTimeOffset.TryParse(raw.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out result))
return true;
if (TryParseDate(raw, out var dateOnly))
{
result = new DateTimeOffset(dateOnly.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
return true;
}
return DateTimeOffset.TryParse(raw.Trim(), out result);
}
private static bool TryParseDepartment(string raw, out Department result)
{
var trimmed = raw.Trim();
if (DepartmentExtensions.TryFromDbString(trimmed, out result))
return true;
foreach (Department department in Enum.GetValues<Department>())
{
if (department.ToDbString().Equals(trimmed, StringComparison.OrdinalIgnoreCase))
{
result = department;
return true;
}
}
result = default;
return false;
}
private static string NormalizeSex(string raw) =>
raw.Trim().ToLowerInvariant() switch
{
"m" or "male" => "Male",
"f" or "female" => "Female",
_ => raw.Trim()
};
}
@@ -0,0 +1,90 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
using Tesseract;
public class TesseractOcrService : IOcrService
{
private static readonly (Regex Pattern, string FieldName)[] ClinicalPatterns =
[
(new Regex(@"(?:Patient\s+)?Name[:\s]+(.+)", RegexOptions.IgnoreCase | RegexOptions.Multiline), "patient.fullName"),
(new Regex(@"DOB[:\s]+(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})", RegexOptions.IgnoreCase), "patient.dateOfBirth"),
(new Regex(@"(?:Date of Birth|Birth Date)[:\s]+(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})", RegexOptions.IgnoreCase), "patient.dateOfBirth"),
(new Regex(@"(?:Sex|Gender)[:\s]+(\S+)", RegexOptions.IgnoreCase), "patient.sex"),
(new Regex(@"(?:Admission Date|Admitted)[:\s]+(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})", RegexOptions.IgnoreCase), "encounter.admissionDate"),
(new Regex(@"(?:Department|Ward|Unit)[:\s]+(.+)", RegexOptions.IgnoreCase | RegexOptions.Multiline), "encounter.department"),
(new Regex(@"(?:Room(?:/Bed)?|Bed)[:\s]+(.+)", RegexOptions.IgnoreCase | RegexOptions.Multiline), "encounter.roomBed"),
(new Regex(@"HR[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.HEART_RATE.value"),
(new Regex(@"(?:Heart Rate|Pulse)[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.HEART_RATE.value"),
(new Regex(@"(?:Temp|Temperature)[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.TEMP_C.value"),
(new Regex(@"(?:BP Sys|Systolic)[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.BP_SYSTOLIC.value"),
(new Regex(@"(?:BP Dia|Diastolic)[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.BP_DIASTOLIC.value"),
(new Regex(@"(?:RR|Resp(?:iratory)?\s*Rate)[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.RESP_RATE.value"),
(new Regex(@"(?:SpO2|O2 Sat)[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.SPO2.value"),
];
private readonly TesseractOcrOptions _options;
private readonly ImagePreprocessor _preprocessor;
private readonly ILogger<TesseractOcrService> _logger;
public TesseractOcrService(
IOptions<OcrOptions> options,
ImagePreprocessor preprocessor,
ILogger<TesseractOcrService> logger)
{
_options = options.Value.Tesseract;
_preprocessor = preprocessor;
_logger = logger;
}
public async Task<OcrExtractionResult> ExtractAsync(
Stream documentStream, string contentType)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
using var preprocessed = _preprocessor.Preprocess(documentStream, contentType);
using var memStream = new MemoryStream();
await preprocessed.CopyToAsync(memStream);
var imageBytes = memStream.ToArray();
using var engine = new TesseractEngine(
_options.DataPath, _options.Language, EngineMode.Default);
using var pix = Pix.LoadFromMemory(imageBytes);
using var page = engine.Process(pix);
var rawText = page.GetText();
var meanConfidence = page.GetMeanConfidence();
var fields = ParseClinicalText(rawText, meanConfidence);
sw.Stop();
return new OcrExtractionResult(fields, rawText, (int)sw.ElapsedMilliseconds);
}
private static List<OcrExtractedField> ParseClinicalText(string text, float meanConfidence)
{
var fields = new List<OcrExtractedField>();
if (string.IsNullOrWhiteSpace(text))
return fields;
var baseConfidence = Math.Clamp(meanConfidence / 100f, 0.0, 1.0);
var matchedFields = new HashSet<string>(StringComparer.Ordinal);
foreach (var (pattern, fieldName) in ClinicalPatterns)
{
if (matchedFields.Contains(fieldName))
continue;
var match = pattern.Match(text);
if (!match.Success)
continue;
var value = match.Groups[1].Value.Trim();
if (value.Length == 0)
continue;
fields.Add(new OcrExtractedField(fieldName, value, baseConfidence));
matchedFields.Add(fieldName);
}
return fields;
}
}
@@ -526,7 +526,7 @@ public class PromotionService : IPromotionService
return encounter; return encounter;
} }
private async Task<(Guid[] observationIds, int outboxCount)> PromoteObservationsAsync( private Task<(Guid[] observationIds, int outboxCount)> PromoteObservationsAsync(
DigitizationBatch batch, Guid patientId, Guid encounterId, DigitizationBatch batch, Guid patientId, Guid encounterId,
bool enableRetroactiveAlerts, DateTimeOffset now) bool enableRetroactiveAlerts, DateTimeOffset now)
{ {
@@ -600,7 +600,7 @@ public class PromotionService : IPromotionService
"{OutboxCount} outbox events (shouldAlert={ShouldAlert}, track={Track})", "{OutboxCount} outbox events (shouldAlert={ShouldAlert}, track={Track})",
observationIds.Count, batch.Id, outboxCount, shouldAlert, batch.Track.ToDbString()); observationIds.Count, batch.Id, outboxCount, shouldAlert, batch.Track.ToDbString());
return (observationIds.ToArray(), outboxCount); return Task.FromResult((observationIds.ToArray(), outboxCount));
} }
public async Task<PromotionResult> PromoteAsync(Guid batchId, Guid actorUserId) public async Task<PromotionResult> PromoteAsync(Guid batchId, Guid actorUserId)
@@ -179,10 +179,12 @@ public class WorkQueueService : IWorkQueueService
"Work queue overview: {PendingCount} pending, avg queue {AvgMinutes:F1}m, reject rate {RejectRate:P1}", "Work queue overview: {PendingCount} pending, avg queue {AvgMinutes:F1}m, reject rate {RejectRate:P1}",
pendingBatches.Count, avgTimeInQueueMinutes, rejectRate); pendingBatches.Count, avgTimeInQueueMinutes, rejectRate);
return new WorkQueueOverviewResponse( return new WorkQueueOverviewResponse
StatusCounts: statusCounts, {
AverageTimeInQueueMinutes: Math.Round(avgTimeInQueueMinutes, 1), StatusCounts = statusCounts,
RejectRate: Math.Round(rejectRate, 4), AverageTimeInQueueMinutes = Math.Round(avgTimeInQueueMinutes, 1),
OldestPendingVerificationMinutes: Math.Round(oldestPendingMinutes, 1)); RejectRate = Math.Round(rejectRate, 4),
OldestPendingVerificationMinutes = Math.Round(oldestPendingMinutes, 1)
};
} }
} }
@@ -11,8 +11,11 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" /> <PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" /> <PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />
<PackageReference Include="Azure.AI.DocumentIntelligence" Version="1.0.0" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Docnet.Core" Version="2.6.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" /> <PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Hl7.Fhir.R4" Version="5.11.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.4" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
@@ -23,11 +26,14 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="prometheus-net" Version="8.2.1" /> <PackageReference Include="prometheus-net" Version="8.2.1" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" /> <PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" /> <PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" /> <PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" /> <PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="StackExchange.Redis" Version="3.0.0" /> <PackageReference Include="StackExchange.Redis" Version="3.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Tesseract" Version="5.2.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,27 @@
{
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.Seq" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"WriteTo": [
{ "Name": "Console" },
{ "Name": "Seq", "Args": { "serverUrl": "http://localhost:5341" } }
],
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ],
"Properties": {
"Application": "VigilCareRecordsAPI"
}
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
}
}
@@ -0,0 +1,57 @@
{
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.Seq" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"WriteTo": [
{ "Name": "Console", "Args": { "formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact" } },
{ "Name": "Seq", "Args": { "serverUrl": "http://localhost:5341" } }
],
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ],
"Properties": {
"Application": "VigilCareClinicalAPI"
}
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"Kafka": {
"ReplicationFactor": 3,
"NumPartitions": 6,
"SecurityProtocol": "SaslSsl"
},
"Minio": {
"UseSSL": true
},
"RabbitMq": {
"UseSsl": true
},
"PhiEncryption": {
"LogListAccess": true
},
"Swagger": { "Enabled": false },
"Seeding": { "EnableDemoData": false },
"Simulation": {
// UAT / clinical evaluation: in-app scenario replay is on so testers can
// exercise the ward without a terminal. Patients created here are marked
// IsSimulated; Reset ward only deletes those rows. Do not enable against a
// database that also holds real care data — use a dedicated UAT DB.
"Enabled": true,
"ScenarioDirectory": "Scenarios/List",
"LoopbackBaseUrl": "http://localhost:8080",
"RunnerUsername": "simulation.runner",
"MaxConcurrentRuns": 8,
"MaxSpeed": 600,
"RunHistoryLimit": 50
}
}
+20
View File
@@ -70,5 +70,25 @@
"MaxDelaySeconds": 900, "MaxDelaySeconds": 900,
"MaxRetryAttempts": 10, "MaxRetryAttempts": 10,
"BackoffMultiplier": 2.0 "BackoffMultiplier": 2.0
},
"Fhir": {
"BaseUrl": "http://localhost:5271/fhir",
"PublisherName": "VigilCare Records",
"PublisherUrl": "https://vigilcare.local",
"ServerVersion": "1.0.0"
},
"Ocr": {
"Enabled": false,
"Provider": "azure",
"ConfidenceThreshold": 0.7,
"PollIntervalSeconds": 15,
"Azure": {
"Endpoint": "",
"ApiKey": ""
},
"Tesseract": {
"DataPath": "/usr/share/tessdata",
"Language": "eng"
}
} }
} }
+69
View File
@@ -0,0 +1,69 @@
# Production overlay. Runs ONLY the two application containers — Postgres,
# Redis, MinIO, and Seq are pre-existing services shared with VigilCareClinical,
# reached through the external "shared-services" Docker network. This project
# does not create that network; VigilCareClinical's own compose stack must
# already be running the first time this stack is deployed.
#
# docker compose -f docker-compose.prod.yml --env-file .env up -d
#
# Does not extend docker-compose.yml. Local-dev infra stays in that file; this
# one ships only the deployable apps.
name: vigilcare-records
services:
api:
image: ${REGISTRY}/vigilcare-records-api:${IMAGE_TAG}
container_name: vigilcare_records_api
restart: unless-stopped
ports:
- "${API_PORT:-5217}:8080"
environment:
ASPNETCORE_ENVIRONMENT: Production
ConnectionStrings__DefaultConnection: "${PG_CONNECTION}"
Redis__ConnectionString: "${REDIS_CONNECTION}"
Seq__ServerUrl: "${SEQ_URL}"
Serilog__WriteTo__1__Args__serverUrl: "${SEQ_URL}"
Serilog__WriteTo__1__Args__apiKey: "${SEQ_API_KEY}"
Minio__Endpoint: "${MINIO_ENDPOINT}"
Minio__AccessKey: "${MINIO_ACCESS_KEY}"
Minio__SecretKey: "${MINIO_SECRET_KEY}"
Minio__BucketName: "${MINIO_BUCKET_NAME:-vigilcare-records-scans}"
Minio__UseSsl: "${MINIO_USE_SSL:-false}"
Jwt__Secret: "${JWT_SECRET}"
Jwt__Issuer: "${JWT_ISSUER:-VigilCareRecords}"
Jwt__Audience: "${JWT_AUDIENCE:-VigilCareRecords}"
Cors__AllowedOrigins__0: "${DASHBOARD_ORIGIN}"
networks:
- vigilcare_records_prod
- shared-services
logging:
driver: json-file
options: { max-size: "50m", max-file: "5" }
deploy:
resources:
limits: { memory: 1G }
dashboard:
image: ${REGISTRY}/vigilcare-records-dashboard:${IMAGE_TAG}
container_name: vigilcare_records_dashboard
restart: unless-stopped
ports:
- "${DASHBOARD_PORT:-8089}:80"
depends_on:
api:
condition: service_healthy
networks:
- vigilcare_records_prod
logging:
driver: json-file
options: { max-size: "20m", max-file: "3" }
networks:
vigilcare_records_prod:
driver: bridge
# Owned by the Postgres/Redis/MinIO/Seq compose project shared with
# VigilCareClinical (it defines and creates "shared-services" via its own
# `docker compose up`). This project only consumes it.
shared-services:
external: true
+417
View File
@@ -0,0 +1,417 @@
# Guide 25: Gitea CI/CD with Docker Compose
How to wire a multi-service app for continuous integration and deployment on **Gitea Actions**, using VigilCare Clinical as a concrete example. The same layout works for any stack: keep local infra in one Compose file, ship only app images in production, put secrets on the host (not in git), and let workflows build, migrate, and deploy on version tags.
Related docs in this repo:
- [`.gitea/workflows/ci.yml`](../../.gitea/workflows/ci.yml) / [`.gitea/workflows/cd.yml`](../../.gitea/workflows/cd.yml)
- [`docker-compose.yml`](../../docker-compose.yml) (local + CI dependencies)
- [`docker-compose.prod.yml`](../../docker-compose.prod.yml) (production app stack)
- [`.env.example`](../../.env.example)
- [`docs/ops/cd-deploy-ssh-setup.md`](../ops/cd-deploy-ssh-setup.md)
- [`docs/instructions-for-env.md`](../instructions-for-env.md)
---
## Mental model
| Layer | What it is | Who owns it |
|-------|------------|-------------|
| **Source** | App code, Dockerfiles, Compose files, workflow YAML | Git repo |
| **CI** | On every push/PR: start deps, build, test | Gitea Actions + `act_runner` |
| **CD** | On `v*` tags: build/push images, migrate DB, SSH deploy | Same runner + container registry |
| **Secrets on host** | Production `.env` with DB URLs, JWT keys, etc. | Deploy VM only (never committed, never SCPd by CD) |
| **Runtime** | Pulled images + Compose prod overlay | Deploy VM (`/opt/<app>/`) |
```
Developer push/PR ──► CI (compose deps + tests)
Developer git tag v1.2.3 ──► CD
├─ build & push images → Gitea registry
├─ apply EF migrations (DDL user)
└─ SSH → pull images, up -d, smoke test
```
Gitea Actions is largely compatible with GitHub Actions syntax (`on:`, `jobs:`, `uses: actions/checkout@v4`, etc.). Jobs run on a self-hosted **act_runner** that needs Docker, curl, ssh, scp, and bash, labeled `ubuntu-latest` (or whatever label you set in the workflow).
---
## 1. Split Compose: local/CI vs production
Do **not** reuse the same Compose file for laptop and production.
### Local / CI — `docker-compose.yml`
Defines **infrastructure** (Postgres, Redis, Kafka, RabbitMQ, Elasticsearch, MinIO, …) and optional app profiles. Developers and CI start only what tests need:
```bash
docker compose up -d postgres redis rabbitmq kafka elasticsearch minio
docker compose --profile ward-gateway up -d ward-gateway-db ward-gateway-redis ward-gateway-rabbitmq
```
Useful patterns (as in this repo):
- **Published ports** so host processes (or CI job containers via `host.docker.internal`) can reach brokers.
- **Profiles** (`ward-gateway`, `full`) so optional services stay off by default.
- **CI-friendly Kafka advertising** — override advertised host for runners:
```yaml
KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:29092,EXTERNAL://${KAFKA_EXTERNAL_HOST:-localhost}:9092
```
CI sets `KAFKA_EXTERNAL_HOST: host.docker.internal` so the test process in a container reconnects to the published port on the Docker host, not to itself.
### Production — `docker-compose.prod.yml`
Runs **only the deployable apps** (here: `api`, `gateway`, `dashboard`). Databases and brokers are assumed to already exist; Compose wires them through environment variables from `.env`.
```yaml
services:
api:
image: ${REGISTRY}/clinical-api:${IMAGE_TAG}
environment:
ConnectionStrings__DefaultConnection: "${PG_CONNECTION}"
# … Jwt, Kafka, Redis, etc. from .env
volumes:
- dp_keys:/app/data-protection-keys # durable secrets / keyrings
networks:
- vigilcare_prod
- shared-services # external network owned by infra compose
```
Principles:
1. **Image coordinates** via `REGISTRY` + `IMAGE_TAG` — CD updates only `IMAGE_TAG`.
2. **External networks** for shared Postgres/Redis stacks already running on the host.
3. **No build:** on the VM — `docker compose pull` then `up -d`.
4. **Named volumes** for anything that must survive recreate (e.g. Data Protection keys).
CD copies **only** this file to the server each release; it does not copy `.env`.
---
## 2. Dockerfiles that CI and CD can trust
### Multi-stage builds
Keep a **SDK/build** stage and a thin **runtime** stage. Example (API):
```dockerfile
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS src
# restore with project files only → cache NuGet layer
# then COPY source, publish
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
COPY --from=build /app/publish .
USER app
HEALTHCHECK CMD curl -fsS http://localhost:8080/health/live || exit 1
ENTRYPOINT ["dotnet", "VigilCareClinicalAPI.dll"]
```
Frontend (Vite) must bake public API URLs at **build** time:
```dockerfile
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
```
CD passes `--build-arg VITE_API_URL="${{ vars.PROD_API_URL }}"`.
### Build context
Match Compose and CD:
| Image | Dockerfile | Context | Why |
|-------|------------|---------|-----|
| clinical-api | `VigilCareClinicalAPI/Dockerfile` | **repo root** | Sibling `ProjectReference`s |
| ward-gateway | `VigilCare.WardGateway/Dockerfile` | **repo root** | Same |
| dashboard | `vigilcare-dashboard/Dockerfile` | `vigilcare-dashboard/` | `package.json`, `nginx.conf` |
Wrong context is the most common “works on my machine, fails in CD” failure.
### Scrub secrets from published config
Dev `appsettings.json` often contains placeholder keys. Strip them in the image so production **must** supply env vars:
```dockerfile
RUN sed -i \
-e 's/"SigningKey": "[^"]*"/"SigningKey": ""/' \
-e 's/"Gateway": "dev-[^"]*"/"Gateway": ""/' \
/app/publish/appsettings.json
```
### Optional: migration target in the same Dockerfile
```dockerfile
FROM src AS migrate
RUN dotnet ef migrations bundle ... --output /out/migrate-api
```
CD builds `--target migrate`, copies the binary out, and runs it with a **DDL** connection string that never enters the API container. Prefer `docker build` + `docker cp` over `docker run -v` on act_runner — bind mounts resolve on the Docker host, not the job workspace.
### `.dockerignore`
Exclude `bin/`, `obj/`, `node_modules/`, tests, `.env`, docs noise. Keep anything the image must ship (e.g. scenario JSON under `VigilCare.Simulator/Scenarios/`).
---
## 3. `.env.example` vs production `.env`
| File | In git? | Purpose |
|------|---------|---------|
| `.env.example` | Yes | Document every key; safe placeholders |
| `.env` (laptop) | No (`.gitignore`) | Local experimentation only |
| `/opt/vigilcare/.env` on VM | No | **Source of truth** for production |
`.gitignore` pattern used here:
```
.env
.env.*
!.env.example
```
Group keys clearly in `.env.example`:
1. `REGISTRY` / `IMAGE_TAG`
2. Host ports (`API_PORT`, …)
3. External service connection strings
4. App secrets (`JWT_SIGNING_KEY`, API keys) — generate with `openssl rand -base64 48`
5. Bootstrap / seed users
**Privilege split:** runtime `PG_CONNECTION` (DML app user) vs `PG_CONNECTION_DDL` (migrator only). The DDL string is a Gitea secret for the migrate job, not an API container env var.
### One-time place `.env` on the VM
CD never uploads `.env`. Before the first tag deploy:
```bash
ssh deploy@YOUR_HOST "mkdir -p /opt/vigilcare"
scp .env deploy@YOUR_HOST:/opt/vigilcare/.env
ssh deploy@YOUR_HOST "chmod 600 /opt/vigilcare/.env"
```
Later secret rotations: edit `/opt/vigilcare/.env` on the server (or replace your secret-management process). See [`docs/instructions-for-env.md`](../instructions-for-env.md).
---
## 4. Gitea Actions — CI workflow
Path: `.gitea/workflows/ci.yml`
### Triggers
```yaml
on:
push:
branches: [master]
pull_request:
branches: [master]
```
### Backend job pattern
1. Checkout
2. `docker compose up -d` for dependencies
3. Wait loops (`pg_isready`, Kafka broker API, Redis `PING`, RabbitMQ diagnostics as the `rabbitmq` user — avoid root creating a bad `.erlang.cookie`)
4. Create test databases
5. `dotnet restore` / `build` / `test` with connection env vars pointing at `host.docker.internal` and published ports
6. Upload test artifacts; always tear down with `docker compose ... down -v`
### Frontend job pattern
```yaml
frontend:
runs-on: ubuntu-latest
container:
image: node:22-alpine
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run test && npm run build
working-directory: vigilcare-dashboard
```
### Runner requirement for Compose-based tests
The job (or its Docker sibling) must reach published ports. On Docker Desktop / many Linux runners, that means `host.docker.internal` and runner `extra_hosts: host-gateway`. Wire that into your act_runner config if tests hang on “connection refused”.
---
## 5. Gitea Actions — CD workflow
Path: `.gitea/workflows/cd.yml`
### Triggers
```yaml
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
image_tag:
description: "Image tag to deploy"
required: false
```
Release flow: merge to `master``git tag v1.2.3 && git push origin v1.2.3`.
### Jobs
```
build-and-push ──► migrate ──► deploy (smoke + rollback on failure)
```
**build-and-push**
1. Resolve tag (`GITHUB_REF_NAME` or `workflow_dispatch` input) and optional `vars.REGISTRY`
2. `docker login` to the Gitea package registry with `REGISTRY_USERNAME` / `REGISTRY_TOKEN`
3. Build each image; tag both `:v1.2.3` and `:latest`; push
**migrate**
1. Build `--target migrate`
2. Extract `migrate-api`
3. `./migrate-api --connection "${{ secrets.PG_CONNECTION_DDL }}"`
Migrations must be **backwards-compatible** with the still-running previous image (expand-then-contract). Rollback restores the old image tag only — it does not reverse schema.
**deploy**
1. Configure SSH from `DEPLOY_SSH_KEY` (see [`cd-deploy-ssh-setup.md`](../ops/cd-deploy-ssh-setup.md))
2. `scp docker-compose.prod.yml``/opt/vigilcare/`
3. On the host: save previous `IMAGE_TAG`, set new tag in `.env`, `pull`, `up -d`, prune dangling images
4. Smoke: curl `/health/ready`, gateway live, dashboard `/`
5. On failure: restore `.env.previous` and `up -d` again
Do not `source .env` in bash — Compose env files are not shell (semicolons in connection strings, spaces, CRLF). Read individual keys with `sed` if needed.
---
## 6. Gitea secrets and variables
Repo → **Settings****Actions**
### Secrets (sensitive)
| Secret | Used by |
|--------|---------|
| `REGISTRY_USERNAME` | `docker login` |
| `REGISTRY_TOKEN` | `docker login` (access token / PAT with package write) |
| `PG_CONNECTION_DDL` | migrate job only |
| `DEPLOY_HOST` | SSH / SCP |
| `DEPLOY_USER` | SSH / SCP |
| `DEPLOY_SSH_KEY` | Private key PEM / OpenSSH private key body |
### Variables (non-secret config)
| Variable | Used by |
|----------|---------|
| `PROD_API_URL` | Dashboard image build-arg (public API origin) |
| `REGISTRY` | Optional override of default `gitea.example.com/org` |
Enable **Packages** (container registry) for the org/user that owns `REGISTRY`.
---
## 7. One-time infrastructure checklist
Use this when cloning the pattern onto a new project or a fresh Gitea instance.
### Gitea + runner
- [ ] Gitea with Actions enabled
- [ ] act_runner registered, label matches `runs-on:` (e.g. `ubuntu-latest`)
- [ ] Runner can run Docker (socket or DinD) and has `docker compose`, `ssh`, `scp`, `curl`, `bash`
- [ ] Container registry reachable from runner and from the deploy host
### Repo layout
- [ ] `.gitea/workflows/ci.yml` and `cd.yml`
- [ ] Dockerfile(s) with runtime HEALTHCHECK
- [ ] `docker-compose.yml` for local/CI deps
- [ ] `docker-compose.prod.yml` for app-only deploy
- [ ] `.env.example` + `.gitignore` excluding `.env`
- [ ] `.dockerignore` with correct exceptions
### Deploy host
- [ ] User with Docker rights and home for SSH keys
- [ ] Directory e.g. `/opt/<app>/` with filled `.env` (`chmod 600`)
- [ ] External networks / infra Compose already up if prod overlay declares `external: true`
- [ ] Public DNS / reverse proxy pointing at published ports
- [ ] Deploy SSH key installed ([setup guide](../ops/cd-deploy-ssh-setup.md))
### First release
- [ ] Secrets and variables set in Gitea
- [ ] CI green on `master`
- [ ] Tag `v0.1.0` (or `workflow_dispatch` with `image_tag`)
- [ ] Confirm images in registry, migrate succeeded, smoke passed
---
## 8. Adapting this to another project
Strip VigilCare-specific names; keep the skeleton:
1. **CI:** start your test deps → wait healthy → run unit/integration tests with host-reachable URLs.
2. **CD build:** one `docker build`/`push` per deployable service; fix contexts.
3. **CD migrate:** optional job if you have schema (EF bundle, Flyway, Prisma migrate, etc.) using a privileged secret.
4. **CD deploy:** SCP prod Compose → patch `IMAGE_TAG` → pull/up → health curl → rollback tag on failure.
5. **Host `.env`:** all runtime config; CD touches only the image tag line.
Minimal prod Compose for a single API:
```yaml
services:
api:
image: ${REGISTRY}/my-api:${IMAGE_TAG}
ports:
- "${API_PORT:-8080}:8080"
environment:
ConnectionStrings__Default: "${PG_CONNECTION}"
restart: unless-stopped
```
Minimal CD deploy fragment:
```bash
sed -i "s|^IMAGE_TAG=.*|IMAGE_TAG=${IMAGE_TAG}|" .env
docker compose -f docker-compose.prod.yml --env-file .env pull
docker compose -f docker-compose.prod.yml --env-file .env up -d --remove-orphans
```
---
## 9. Common pitfalls
| Symptom | Likely cause |
|---------|----------------|
| Kafka / broker tests hang in CI | Advertised listener still `localhost` inside a job container — set `KAFKA_EXTERNAL_HOST=host.docker.internal` |
| RabbitMQ dies after health probe | Probing as root created a root-owned `.erlang.cookie` — probe as `rabbitmq` |
| `docker build` missing project references | Context not repo root |
| Dashboard calls wrong API in prod | Forgot `PROD_API_URL` / `VITE_*` build-arg |
| First CD fails at `cd /opt/...` | `.env` / directory never created on VM |
| Rollback “worked” but app errors | Schema already migrated; ensure expand-then-contract migrations |
| `source .env` breaks deploy scripts | Connection strings arent valid bash — dont source Compose env files |
---
## 10. Day-to-day commands
```bash
# Local deps
docker compose up -d
# Production-shaped run (on the VM, after images exist)
docker compose -f docker-compose.prod.yml --env-file .env up -d
# Cut a release (after CI is green)
git tag v1.4.0
git push origin v1.4.0
# Manual redeploy of an existing tag (Gitea UI → Actions → CD → Run workflow)
```
That is the full loop this repository uses: Compose for deps and for prod apps, env files only on the host, Gitea workflows for test and tagged releases.
+83
View File
@@ -0,0 +1,83 @@
# Guide 26: VigilCareRecords CI/CD (Gitea Actions + Docker Compose)
How VigilCareRecords is built, tested, and deployed on the same Gitea Actions + `act_runner` + container registry setup already used by VigilCareClinical. See [`docs/25-gitea-cicd-docker-deploy.md`](25-gitea-cicd-docker-deploy.md) for the general pattern this guide instantiates; this doc only covers what is specific to this repo.
Related files:
- [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml) / [`.gitea/workflows/cd.yml`](../.gitea/workflows/cd.yml)
- [`docker-compose.yml`](../docker-compose.yml) (local + CI dependencies)
- [`docker-compose.prod.yml`](../docker-compose.prod.yml) (production app stack)
- [`.env.example`](../.env.example)
- [`VigilCareRecordsAPI/Dockerfile`](../VigilCareRecordsAPI/Dockerfile) / [`vigilcare-records-web/Dockerfile`](../vigilcare-records-web/Dockerfile)
---
## 1. Topology
VigilCareRecords ships two images:
| Image | Dockerfile | Build context |
|---|---|---|
| `vigilcare-records-api` | `VigilCareRecordsAPI/Dockerfile` | repo root |
| `vigilcare-records-dashboard` | `vigilcare-records-web/Dockerfile` | `vigilcare-records-web/` |
Unlike VigilCareClinical, there is no gateway service, and the frontend does not bake in an absolute API URL at build time — `src/api/client.ts` and `src/api/fhirClient.ts` use relative base URLs (`/api/v1`, `/fhir`). The dashboard's `nginx.conf` reverse-proxies those paths to the `api` container, so the same image works behind any hostname without a build-arg.
## 2. Shared production infrastructure
Per the "Integrated Database Deployment" decision documented in [`vigilcare-records-clinical-overview.md`](vigilcare-records-clinical-overview.md), VigilCareRecords does **not** run its own Postgres/Redis/MinIO/Seq in production. It joins the external `shared-services` Docker network already created by VigilCareClinical's own compose project and connects to those same containers, using:
- Its own logical Postgres database (`vigilcare_records`, separate schema/tables from VigilCareClinical's `patients`/`encounters`/`observations` — see the promotion service for how the two connect at the application layer, not the infrastructure layer)
- A dedicated Redis logical database (`defaultDatabase=2` in `.env.example`) so batch-assignment locks never collide with VigilCareClinical's keys
- A dedicated MinIO bucket (`vigilcare-records-scans`) separate from any clinical document buckets
**Before the first deploy**, confirm the VigilCareClinical infra stack (or whatever compose project owns `shared-services`) is already running on the deploy host — `docker network ls | grep shared-services` should show it. `docker compose -f docker-compose.prod.yml up` will fail to find the network otherwise.
## 3. One-time host setup
```bash
ssh deploy@YOUR_HOST "mkdir -p /opt/vigilcare-records"
scp .env deploy@YOUR_HOST:/opt/vigilcare-records/.env
ssh deploy@YOUR_HOST "chmod 600 /opt/vigilcare-records/.env"
```
Fill in `.env` from [`.env.example`](../.env.example) first — generate `JWT_SECRET` with `openssl rand -base64 48`, and get real credentials for the shared Postgres/Redis/MinIO/Seq services from whoever manages that stack. CD never uploads or overwrites `.env`; only the `IMAGE_TAG` line is patched automatically on each release.
## 4. Gitea secrets and variables
Repo → **Settings****Actions**.
### Secrets
| Secret | Used by |
|---|---|
| `REGISTRY_USERNAME` | `docker login` |
| `REGISTRY_TOKEN` | `docker login` (access token / PAT with package write) |
| `PG_CONNECTION_DDL` | migrate job only — DDL-privileged connection to the shared Postgres, never given to the API container |
| `DEPLOY_HOST` | SSH / SCP |
| `DEPLOY_USER` | SSH / SCP |
| `DEPLOY_SSH_KEY` | Private key PEM / OpenSSH private key body |
### Variables
| Variable | Used by |
|---|---|
| `REGISTRY` | Optional override of the default `git.vectur45.com/trent/vigilcare-records` |
## 5. Release flow
```bash
# CI green on master, then:
git tag v1.0.0
git push origin v1.0.0
```
This triggers `.gitea/workflows/cd.yml`: build & push both images → apply EF Core migrations against `PG_CONNECTION_DDL` → SSH deploy (`scp` the prod compose file, patch `IMAGE_TAG`, `pull` + `up -d`) → smoke test (`/health/ready`, dashboard `/`) → automatic rollback to the previous `IMAGE_TAG` on failure (schema changes are not reverted; see the expand/contract note in `cd.yml`).
Manual redeploy of an existing tag: Gitea UI → Actions → CD → Run workflow, with `image_tag` input.
## 6. Pre-production checklist (application-level, not part of this CI/CD change)
- **Demo data seeding runs unconditionally on startup.** `Program.cs` calls `DataSeeder.SeedAsync(db)` whenever `ASPNETCORE_ENVIRONMENT` is not `Testing` — including `Production`. Unlike the VigilCareClinical example (`Seeding__EnableDemoData=false`), this app has no seeding toggle. Before a real clinical deploy, either add a guard around `DataSeeder.SeedAsync` for `Production`, or confirm the twelve seeded demo accounts (all password `password`) are acceptable/rotated before go-live.
- Confirm `Cors:AllowedOrigins` / `DASHBOARD_ORIGIN` matches the real public dashboard origin if the dashboard is ever served from a different origin than the API (not the default same-origin nginx-proxy setup described above).
- Rotate `JWT_SECRET` from any value used during testing before the first real deploy.
+650
View File
@@ -0,0 +1,650 @@
# VigilCare Records
## A Clinical Guide to Paper Chart Digitization and Governance
**Audience:** Physicians, nurse leaders, hospital administrators, and healthcare partners evaluating the platform
**Purpose:** Explain how paper-based clinical records become trusted digital clinical data—without requiring software engineering knowledge
---
## Table of Contents
1. [Executive Summary](#1-executive-summary)
2. [Why Clinical Digitization Matters](#2-why-clinical-digitization-matters)
3. [End-to-End Clinical Workflow](#3-end-to-end-clinical-workflow)
4. [System Data Flow](#4-system-data-flow)
5. [Safety and Governance](#5-safety-and-governance)
6. [Dashboard and Workstation Walkthrough](#6-dashboard-and-workstation-walkthrough)
7. [Clinical Examples](#7-clinical-examples)
8. [High-Level Architecture](#8-high-level-architecture)
9. [Relationship to Clinical Care](#9-relationship-to-clinical-care)
10. [Complete Patient Journey](#10-complete-patient-journey)
---
## 1. Executive Summary
**VigilCare Records** is a clinical records digitization and governance platform for hospitals and clinics that still rely on paper charts. It converts handwritten and printed clinical documents into structured electronic patient data—but only after that data has been checked by people, not machines alone.
### What problem does it solve?
Many district hospitals, island health systems, and smaller facilities still keep vital signs, laboratory results, allergies, and medication lists on paper. When those facilities introduce electronic monitoring or early warning systems, they face a hard question:
> *How do we know the numbers in the computer are the same numbers that were written on the chart?*
Without a governed digitization process, transcription errors, incomplete identity matching, and unreviewed OCR guesses can flow into clinical alerts and decision support. That puts patients at risk and undermines clinician trust.
### What VigilCare Records does
The platform provides a controlled pathway from paper to permanent electronic record:
1. A paper chart section is **scanned and stored securely**
2. A data entry clerk **transcribes** demographics, encounters, and observations into structured drafts
3. A second person **verifies** every field against the original scan
4. For high-stakes content (vitals, labs, medications, encounters), a **clinical approver** authorizes promotion
5. Only then does the information become part of the patients **live clinical record**
**Unapproved drafts never reach clinical monitoring, early warning scores, or clinician dashboards.**
VigilCare Records is not a full electronic medical record (EMR) and does not diagnose patients. It is the governed intake layer that ensures clinical information entering downstream systems is complete, verified, and trustworthy.
---
## 2. Why Clinical Digitization Matters
### Challenges of paper charts
| Challenge | Clinical impact |
|---|---|
| Charts live in folders, ward books, or index cards | Information is hard to find across shifts and transfers |
| Handwriting varies in clarity | Values may be misread under time pressure |
| Timestamps are often imprecise or missing | Trends and deterioration timelines become unreliable |
| Encounter boundaries are unclear | “The patient” is not distinguished from “this admission” |
| Charts can be misplaced between wards | Continuity of care is interrupted |
### Risks of transcription errors
Moving from paper to digital without safeguards introduces new hazards:
- **Decimal misplacement** — potassium recorded as `52` instead of `5.2`
- **Wrong patient folder** — values linked to the incorrect medical record
- **Unit confusion** — temperature or glucose entered in the wrong scale
- **Silent “correction”** — someone overwrites a value with no record of what changed
These errors matter most when digital values drive alerts, scoring, or handover decisions.
### Delayed access to patient information
Paper charts travel with the patient or sit on the ward. Consultants, night staff, and remote reviewers may wait for physical retrieval. Digitized, verified records make the same information available wherever clinical monitoring and care coordination require it—without discarding the original scan as the legal source document.
### Regulatory and audit requirements
Healthcare facilities must often demonstrate:
- Who entered a clinical value
- Who checked it
- When it became part of the permanent record
- Whether it was later corrected—and how
VigilCare Records maintains an **append-only audit history** for every digitization batch, so reviewers can reconstruct the chain from scan to live record.
### How structured digital records improve care
Once verified information is promoted into the live clinical database:
| Benefit | Why it matters |
|---|---|
| **Continuity of care** | Prior vitals, labs, allergies, and medications follow the patient across encounters |
| **Clinical monitoring** | Downstream systems can score early warning signals on trustworthy observations |
| **Interoperability** | Standard FHIR representations support exchange with other clinical systems |
| **Research and quality** | Structured, timestamped data supports audit, morbidity review, and outcome analysis |
Structured records do not replace clinical judgment. They give judgment better raw material.
---
## 3. End-to-End Clinical Workflow
### Overview of the pathway
Paper chart digitization in VigilCare Records follows a deliberate sequence. Each stage has a clear clinical purpose.
```
Paper chart received
Scan uploaded (secure storage)
Digitization batch created
Data entry (structured drafts)
Verification (second human checks against scan)
Clinical approval (when required for the document type)
Promotion into the live clinical record
Available for clinical monitoring and ongoing care
```
There are two operational tracks:
| Track | When used | Gate model |
|---|---|---|
| **Track A — Backfill** | Historical charts, bulk digitization, past encounters | Full dual-human verification, then clinical approval |
| **Track B — Live capture** | Bedside vitals entered by a credentialed clinician | Clinician attestation replaces the dual queue; still fully audited |
The remainder of this section focuses on Track A, which is the primary pathway for paper charts.
### Stage-by-stage walkthrough
#### 1. Paper chart received
Ward or medical records staff deliver a chart section—for example a nursing observation sheet, laboratory printout, admission face sheet, allergy note, or medication list.
#### 2. Scan uploaded
An **Intake Clerk** scans the page(s) and uploads the image or PDF. The system:
- Stores the scan in secure document storage
- Computes a fingerprint (hash) so the same document is not accidentally uploaded twice for the same patient within a short window
- Creates a **digitization batch**—one unit of work for that chart section
Optional cover sheets with QR codes can pre-classify batch type and patient for high-volume intake.
#### 3. Batch created and assigned
The batch is typed according to clinical content:
| Batch type | Typical content |
|---|---|
| Patient registration | Demographics, contacts, blood type |
| Encounter summary | Admission/discharge context, department, bed |
| Vitals sheet | Heart rate, blood pressure, SpO₂, temperature, etc. |
| Lab results | Laboratory observations with chart timestamps |
| Medication list | Current medications |
| Allergy update | Documented allergies and sensitivities |
| Mixed | Combined chart sections |
A data entry clerk is assigned so two people do not work the same scan at once.
#### 4. Data entry
The **Data Entry Clerk** works in a split-pane workstation:
- **Left:** the scan (zoom, pan, rotate)
- **Right:** structured forms for patient, encounter, and observations
Timestamps for observations come from the **chart**, not the time of scanning. Built-in plausibility checks catch many transcription mistakes (for example, an impossible potassium value) before the batch leaves the entry desk.
If optional OCR assistance is enabled, the system may pre-fill fields with confidence scores. The clerk still reviews every value against the image. OCR never replaces human entry or verification.
#### 5. Verification
A **Verifier**—a different person from the entry clerk—compares each drafted field to the scan. Fields are checked one by one. If anything is wrong, the batch is **rejected** with a reason and returns for correction. If everything matches, verification passes.
#### 6. Clinical approval (when required)
Site configuration decides which document types need an additional clinical gate after verification:
| Usually requires clinical sign-off | Usually proceeds after verify to approver queue without the extended physician review path |
|---|---|
| Encounter summaries | Patient registration |
| Vitals sheets | Allergy updates |
| Lab results | |
| Medication lists | |
| Mixed batches | |
In all Track A cases, a **Clinical Approver** must still authorize promotion. For high-stakes types, that review is explicit in a dedicated clinical approval queue.
#### 7. Promotion into the live clinical record
On approval, draft patient, encounter, and observation data are written into the live clinical database in a single, all-or-nothing step (**atomic promotion**). Either the whole approved set becomes live, or nothing does—avoiding half-updated patient records.
#### 8. Availability for monitoring and care
Promoted observations become visible to the companion clinical monitoring platform (VigilCareClinical). Historical backfill typically **does not** page clinicians for old critical values unless the facility deliberately enables retroactive alerts for that batch. Live bedside capture, by contrast, can alert immediately when clinically appropriate.
### Roles and responsibilities
| Role | Primary responsibilities | Clinical accountability |
|---|---|---|
| **Intake Clerk** | Receive charts, scan/upload, classify batch type, optionally link patient or cover sheet, assign entry work | Ensures the correct document enters the pipeline and is stored intact |
| **Data Entry Clerk** | Transcribe demographics, encounters, observations; correct rejected batches | Ensures structured fields faithfully represent the scan |
| **Verifier** | Field-level comparison of draft vs scan; pass or reject | Independent quality check—cannot verify own entry |
| **Clinical Approver** | Authorize promotion for verified batches; review high-stakes content | Clinical governance gate before data becomes permanent |
| **Clinician** | Track B live capture with attestation; may use promoted data in care | Attests that bedside values are accurate at the time of entry |
| **Administrator** | Users, site routing, queues, cancellation, supervisor oversight, interoperability browsing | Operational integrity of the digitization program |
**Staffing note:** In a small facility one person may perform intake and entry. The system still **never** allows the same person to both enter and verify the same batch. Minimum staffing for Track A is two people.
---
## 4. System Data Flow
The diagrams below show how information moves through VigilCare Records and into clinical care systems.
### Diagram 1 — Paper Record Digitization
```mermaid
flowchart LR
A[Paper Chart] --> B[Scan Upload]
B --> C[Secure Storage]
C --> D[Draft Patient Data]
D --> E[Draft Encounter]
E --> F[Draft Observations]
F --> G[Verification]
G --> H[Clinical Approval]
H --> I[Promotion]
I --> J[Live Clinical Database]
```
**Reading the diagram clinically:** Everything to the left of Promotion is **working copy / draft**. It can be corrected, rejected, or cancelled. Only after promotion does information become part of the patients permanent electronic clinical record used by monitoring systems.
### Diagram 2 — Governance Workflow
```mermaid
flowchart TD
U[Upload] --> AS[Assignment]
AS --> DE[Data Entry]
DE --> V[Verification]
V -->|Pass| AP[Approval]
V -->|Reject| DE
AP --> AT[Audit Trail]
AP --> PCR[Permanent Clinical Record]
AT --> PCR
```
Every transition records **who acted, what changed, and when**. Rejection sends work back to entry with a reason; it does not erase the scan or the audit history.
### Diagram 3 — Downstream Integration
```mermaid
flowchart LR
L[Live Patient Record] --> M[Clinical Monitoring System]
M --> E[Early Warning Scores]
E --> A[Alert Generation]
A --> D[Clinician Dashboard]
```
### Critical invariant
```
Draft / unverified / unapproved information
never reaches
Clinical monitoring · Early warning scores · Alert generation · Clinician dashboards
```
This separation is intentional. Digitization speed must not outrun clinical trust.
---
## 5. Safety and Governance
Patient safety in digitization is less about “faster scanning” and more about **who may change what, and whether the chain of custody is reconstructable**.
### Separation of duties
The person who enters a batch cannot verify it. The person who entered or verified cannot approve promotion of that same batch. The system blocks these conflicts automatically.
**Why it matters:** Dual control reduces the chance that a single transcription error—or a single persons assumption about illegible handwriting—becomes permanent clinical fact.
### Dual-human verification
Track A requires two humans: one to enter, one to check against the original image. Verification is field-level, not a rubber stamp on the whole form.
### Clinical approval routing
High-stakes document types can be routed through an explicit clinical approval queue so a designated approver reviews content before it becomes live. Facilities configure which batch types require this extended path.
### Immutable audit history
Status changes, document views, rejections, OCR assists, and promotions are recorded in an append-only event history. Auditors and clinical leaders can answer: *What was entered? Who checked it? When did it go live?*
### Controlled corrections using supersession
Once promoted, values are **not silently edited**. A correction creates a new digitization batch linked to the original. After the new batch passes the full pipeline, corrected observations are added and originals are marked **superseded**—still visible historically, no longer treated as current.
**Why it matters:** Regulators and morbidity reviews need the correction story, not a rewritten past.
### Document integrity verification
Each scan is stored with an integrity fingerprint. Rejecting a batch does not delete the scan. Duplicate uploads of the same document for the same patient within a defined window are blocked to reduce accidental double-entry.
### Patient matching and duplicate detection
On promotion, the system matches patients carefully (for example by name and date of birth) and warns when a near-duplicate may already exist. New patients receive a stable medical record number (MRN). This reduces split charts and merged-identity hazards.
### Atomic promotion into live records
Patient, encounter, and observation updates for an approved batch succeed together or not at all. Partial promotion—which could leave an encounter without its labs, or labs without a patient—is avoided by design.
### Plausibility checks at entry
Numeric ranges catch physically impossible values early (for example SpO₂ above 100%, or potassium far outside a plausible laboratory range). These are **digitization safety nets**, not clinical alert thresholds.
### Why these controls are essential
In clinical environments:
- Alerts and scores amplify whatever data they receive
- Silent edits destroy forensic and educational value of the record
- Single-person pipelines concentrate risk on the most fatigued shift
- Paper remains the source of truth until humans have reconciled digital drafts to the scan
Governance is not bureaucracy for its own sake—it is how a paper-to-digital program earns the right to drive monitoring and care coordination.
---
## 6. Dashboard and Workstation Walkthrough
Each workstation is designed around one job. Users see only the queues and actions appropriate to their role.
### Intake workstation
**Who:** Intake Clerk, Administrator
**What they see:** Upload form, batch type selection, track (backfill vs live), optional patient search, optional cover-sheet barcode entry.
**Decisions:**
- Is this the correct document type?
- Should this link to an existing patient MRN or wait for registration?
- Who should perform data entry?
**Outcome:** A batch in secure storage, ready for transcription.
### Data entry screen
**Who:** Data Entry Clerk, Administrator
**What they see:** Split view—scan on one side, structured fields on the other. Observation rows with codes, values, units, and chart timestamps. Optional OCR confidence highlights when enabled.
**Decisions:**
- What does the handwriting actually say?
- Are encounter details complete for this visit?
- Are all required sections for this batch type filled?
**Outcome:** Draft patient / encounter / observations submitted for verification—or returned to entry after rejection.
### Verification screen
**Who:** Verifier (and roles permitted to review the verification queue)
**What they see:** Same scan viewer plus checkboxes or field-level status for each drafted value, with progress toward complete review.
**Decisions:**
- Does each field match the scan?
- If not, what rejection reason will help the entry clerk fix it?
**Outcome:** Verified (or awaiting clinical approval) — or rejected for rework.
### Clinical approval queue
**Who:** Clinical Approver, Administrator
**What they see:** Batches awaiting promotion authorization; scan plus structured summary; option related to retroactive alerting for backfill when clinically appropriate.
**Decisions:**
- Is this content ready to become part of the permanent record?
- For historical values, should downstream alerting be enabled?
**Outcome:** Approval triggers promotion, or rejection returns the work with reason.
### Patient history
**Who:** Authenticated clinical and administrative users
**What they see:** Timeline of digitization batches for a patient, correction chains (which batch superseded which), and audit events.
**Decisions:**
- Has this patients paper record been fully backfilled?
- Was a value corrected, and when?
**Outcome:** Situational awareness for governance and continuity—not a replacement for the full clinical charting EMR.
### Supervisor dashboard
**Who:** Administrator
**What they see:** Work-queue overview—counts by status, aging work, rejection patterns, bottlenecks in entry vs verification vs approval.
**Decisions:**
- Where should staffing be redirected today?
- Is rejection rate signaling scan quality or training issues?
- Which batches need cancellation or reassignment?
### FHIR Explorer
**Who:** Administrator
**What they see:** A browser for promoted clinical data exposed in HL7 FHIR R4 form (Patient, Encounter, Observation), including standard search and patient “everything” views.
**Decisions:**
- Can partner systems consume our promoted record correctly?
- Do observation codes map as expected for interoperability testing?
**Outcome:** Technical assurance for exchange—still based only on **promoted** data.
### Live capture (clinician workstation)
**Who:** Clinician
**What they see:** Bedside entry for current encounter vitals/observations, with attestation and password confirmation.
**Decisions:**
- Are these values measured now, for this active encounter?
- Do I attest that they are accurate?
**Outcome:** Immediate promotion with full audit; may trigger synchronous clinical alerts when thresholds are crossed.
---
## 7. Clinical Examples
### Example A — New patient registration
**Situation:** A new admission arrives with a handwritten face sheet.
1. Intake scans the face sheet as **Patient registration**
2. Entry clerk transcribes name, date of birth, sex, blood type, emergency contact
3. Verifier confirms each demographic field against the scan
4. Clinical approver authorizes promotion
5. Live patient record is created with a new MRN (for example `VCR-000042`)
Subsequent vitals and labs can now link to a stable identity.
### Example B — Historical paper chart backfill
**Situation:** Before turning on ward alerting, the hospital digitizes 200 active paper charts.
1. Intake uploads vitals sheets and lab reports as **Backfill** batches
2. Entry and verification proceed through the dual-human gate
3. High-stakes types route through clinical approval
4. On promotion, historical observations enter the live database with **chart timestamps**
5. By default, a potassium of 6.2 from three days ago does **not** page todays on-call clinician
Backfill builds a trustworthy baseline without flooding the ward with retrospective noise.
### Example C — Laboratory result digitization
**Situation:** A paper lab report shows potassium `5.2 mEq/L`.
1. Intake uploads as **Lab results**
2. Entry clerk records potassium with the laboratorys reported time
3. If `52` is typed by mistake, plausibility validation blocks the impossible value
4. Verifier confirms `5.2` against the scan
5. Clinical approver authorizes promotion
6. The observation becomes available to monitoring—still subject to facility alert policy for backfill vs live
### Example D — Vital signs entry
**Situation:** Nursing observation chart for an inpatient with pneumonia.
1. Vitals sheet batch created and assigned
2. Entry clerk records heart rate, SpO₂, temperature, respiratory rate, blood pressure with times from the chart
3. Verifier checks each row
4. Clinical approval and promotion follow
5. Scores such as NEWS2 in the companion monitoring system can use these observations once live
### Example E — Medication reconciliation (list digitization)
**Situation:** Ward medication list must be captured before transfer.
1. Intake uploads **Medication list**
2. Entry captures the structured medication set from the scan
3. Verification and clinical approval treat this as high-stakes content
4. On promotion, medications merge into the live patient context used for ongoing care review
### Example F — Allergy updates
**Situation:** Chart documents Codeine, iodine contrast, and latex allergies.
1. **Allergy update** batch is digitized
2. Entry and verification confirm the allergy list against the scan
3. After approval and promotion, allergies are part of the live patient record visible to downstream care workflows
(Default site routing may treat allergy updates as lower complexity than labs/vitals for the extended clinical queue, but promotion still requires authorized approval.)
### Example G — Correcting an already promoted record
**Situation:** SpO₂ was promoted as `94%`; the chart clearly shows `95%`.
1. A **correction batch** is created linked to the original promoted batch
2. Correct values are entered, verified, and approved through the full pipeline
3. New live observations are inserted; originals are marked **superseded**
4. Patient history shows both the original error and the correction, with actors and times
No one “quietly fixes” the permanent record. The clinical story remains auditable.
---
## 8. High-Level Architecture
Think of VigilCare Records as several cooperating parts, each with a clinical reason to exist.
| Component | What it is (plain language) | Why it exists |
|---|---|---|
| **Secure document storage** | Locked vault for scans (PDF/JPEG/PNG) | Preserves the image everyone verifies against; supports integrity checks |
| **Structured clinical database** | Organized tables for drafts and live patients, encounters, observations | Separates “working drafts” from “permanent clinical facts” |
| **Digitization workflow engine** | Rules for statuses, roles, queues, and allowed transitions | Prevents skipping safety gates or approving out of order |
| **User workstations** | Role-specific screens for intake, entry, verify, approve, live capture | Puts the right decisions in front of the right people |
| **Audit system** | Permanent diary of actions and document access | Supports governance, training, and regulatory review |
| **Optional OCR assistance** | Computer suggestion of text from the scan | Speeds entry when enabled; never replaces human review |
| **HL7 FHIR interface** | Standard read-only clinical data format | Lets other systems retrieve promoted Patient / Encounter / Observation data |
| **Integration with clinical monitoring** | Handoff of promoted data to VigilCareClinical | Powers early warning, alerts, and ward views—only after approval |
```
┌──────────────────────────────────────────────────────────┐
│ VigilCare Records │
│ Scan → Enter → Verify → Approve → Promote │
│ Drafts (never alert) │
└───────────────────────────┬──────────────────────────────┘
│ approved data only
┌──────────────────────────────────────────────────────────┐
│ Clinical Monitoring Platform │
│ Live record → Scores → Alerts → Clinician dashboard │
└──────────────────────────────────────────────────────────┘
```
Implementation details (servers, containers, programming languages) matter to IT teams. Clinicians need only know that **drafts and live records are separated by design**, and that promotion is the controlled bridge between them.
---
## 9. Relationship to Clinical Care
### What VigilCare Records supports
| Clinical need | How the platform helps |
|---|---|
| **Accurate patient histories** | Demographics and prior observations are verified against source documents before they become permanent |
| **Reliable clinical observations** | Dual review, plausibility checks, and approval reduce garbage-in / garbage-out |
| **Better continuity of care** | Structured encounters and observations remain available across shifts and services |
| **Clinical decision support systems** | Downstream scoring and alerts receive only promoted data |
| **Future interoperability** | FHIR representations of promoted records support partner exchange |
| **Research-quality data** | Timestamped, coded observations with known provenance |
| **Regulatory compliance** | Audit trails, supersession, separation of duties, retained scans |
### What the platform does not do
VigilCare Records:
- Does **not** diagnose disease
- Does **not** replace bedside clinical judgment
- Does **not** replace a full EMR for billing, scheduling, or comprehensive charting
- Does **not** allow unapproved drafts to drive alerts or surveillance
Its clinical contribution is foundational: **ensure that what enters electronic care systems is complete, verified, and trustworthy.**
---
## 10. Complete Patient Journey
The following narrative follows one patient from paper chart to ongoing digital care.
### Maria Santos — pneumonia admission
**Day 0 — Paper chart creation**
Maria Santos is admitted to Internal Medicine with community-acquired pneumonia. Nursing staff record vital signs on a paper observation chart. The laboratory prints a white blood cell count onto a paper report. The face sheet lists demographics and emergency contacts. All of this lives in a physical folder at the bedside and nursing station.
**Hospital intake for digitization**
As the facility prepares to activate electronic clinical monitoring, Medical Records and ward clerks assemble Marias active chart sections for digitization. An Intake Clerk scans the face sheet, a recent vitals page, and the lab report. Each upload becomes its own digitization batch in secure storage. Where a patient already exists, intake links the MRN; for first-time digitization, registration proceeds first.
**Scanning and identity**
The scans are fingerprint-checked and retained. Cover sheets may accelerate classification on busy digitization days. Nothing clinical has entered the live database yet—only documents and empty or pre-filled draft shells awaiting human work.
**Data entry**
A Data Entry Clerk opens Marias vitals batch. On the left, the handwritten chart; on the right, structured fields. Heart rate, SpO₂, temperature, and other observations are entered with the times written on the chart. A separate lab batch captures the white blood cell count. If OCR suggested values, the clerk corrects any misreads before submitting.
**Verification**
A Verifier who did not enter the data opens the same scans. Each field is compared. A misread SpO₂ would be rejected with a clear reason. When all fields match, verification passes. Vitals and labs route into the clinical approval pathway appropriate for high-stakes observations.
**Clinical approval**
A Clinical Approver reviews the structured content against the scans and authorizes promotion. For historical backfill, retroactive alerting remains off unless the facility explicitly chooses otherwise—Marias yesterday values should not create todays false urgency.
**Promotion into the electronic record**
In one governed step, Marias patient identity, encounter context, and observations become live clinical data. She now has a stable MRN and a digitization history that records every actor in the chain.
**Availability in the clinical monitoring platform**
Promoted observations are visible to VigilCareClinical. Early warning scores and alert logic can use them according to facility policy. Unfinished drafts from other patients still in the entry queue remain invisible to scoring.
**Use during ongoing patient care**
On subsequent rounds, physicians and nurses consult the clinician dashboard and patient history with greater confidence that electronic vitals and labs match the paper source that was verified. If SpO₂ was later found to be one point off, a correction batch supersedes the error without erasing the audit trail. When a clinician measures new vitals at the bedside (Track B), attestation promotes them immediately for real-time monitoring.
**Closing the loop**
Marias care still depends on clinical judgment. What VigilCare Records contributed was quieter but essential: her paper chart did not leap unchecked into electronic alerting. It crossed a governed bridge—scan, entry, verification, approval, promotion—so that digital care rests on information the organization is prepared to defend.
---
## Appendix A — Status vocabulary (clinical reading)
| Status | Meaning in practice |
|---|---|
| Uploaded | Scan received; awaiting or ready for entry |
| In entry | Clerk actively drafting structured data |
| Pending verification | Submitted; waiting for second-person check |
| Rejected | Returned to entry with a reason |
| Verified | Passed data-quality check; awaiting promotion authorization |
| Awaiting clinical approval | High-stakes path; in clinical approver queue |
| Approved | Authorized; promotion in progress or completing |
| Promoted | Live in the permanent clinical database (terminal success) |
| Cancelled | Permanently stopped before promotion (terminal) |
---
## Appendix B — Quick reference for clinical leaders
| Question | Answer |
|---|---|
| Can OCR alone put labs in the chart? | No. Humans must review; verification still required. |
| Can the entry clerk verify their own work? | No. Separation of duties is enforced. |
| Do drafts trigger NEWS2 or sepsis screens? | No. Only promoted live observations do. |
| Can we fix a promoted wrong value by editing it? | No. Use a correction (supersession) batch. |
| Will backfilling old critical labs page the ward? | Not by default. Retroactive alerts are opt-in per batch. |
| Is this a full EMR? | No. It is the governed digitization precursor to clinical monitoring. |
---
*This guide is intended for clinical and administrative audiences evaluating VigilCare Records. For operator step-by-step instructions, see [digitization-workstation-guide.md](digitization-workstation-guide.md). For product requirements and implementation status, see [vigilcare-records-prd.md](vigilcare-records-prd.md).*
+4 -4
View File
@@ -667,7 +667,7 @@ Clinical data entry is high-stakes work. Entry clerks need immediate confirmatio
--- ---
## P4 — No frontend tests ## ~~P4 — No frontend tests~~ DONE
### Problem ### Problem
@@ -816,10 +816,10 @@ A batch stuck in `APPROVED` with exhausted retries is invisible in Prometheus da
| 19 | EntryForm missing allergy/med fields | P4 | E | Open | | 19 | EntryForm missing allergy/med fields | P4 | E | Open |
| 20 | No corrections/supersession UI | P4 | E | Open | | 20 | No corrections/supersession UI | P4 | E | Open |
| 21 | No toast/notification system | P4 | E | Open | | 21 | No toast/notification system | P4 | E | Open |
| 22 | No frontend tests | P4 | E | Open | | 22 | No frontend tests | P4 | E | Done |
| 23 | No field-level draft audit | P5 | F | Open | | 23 | No field-level draft audit | P5 | F | Open |
| 24 | Integration test gaps | P5 | F | Done | | 24 | Integration test gaps | P5 | F | Done |
| 25 | MetricsCollector missing retry gauges | P5 | F | Open | | 25 | MetricsCollector missing retry gauges | P5 | F | Done |
--- ---
@@ -905,7 +905,7 @@ For each fix, add or extend tests in `VigilCareRecordsAPI.Tests/`:
## Out of scope (unless explicitly requested) ## Out of scope (unless explicitly requested)
- HL7v2 ADT message support (FHIR inbound only in VigilCareClinical) - HL7v2 ADT message support (FHIR inbound only in VigilCareClinical)
- OCR or automated field extraction (explicitly excluded in PRD v1) - OCR bypassing human review (Phase 13 provides optional pre-fill only; verification and approval gates unchanged)
- Multi-facility federated identity (single-tenant per deployment in v1) - Multi-facility federated identity (single-tenant per deployment in v1)
- Full EMR functionality (billing, pharmacy inventory, scheduling) - Full EMR functionality (billing, pharmacy inventory, scheduling)
- SMART on FHIR authorization (OAuth2 scopes for EHR launch context) - SMART on FHIR authorization (OAuth2 scopes for EHR launch context)
+143 -29
View File
@@ -2,7 +2,7 @@
## Implementation Status ## Implementation Status
**Phases 18 are complete.** Phase 9 is partially complete. **Phases 113 are complete.** Post-phase hardening (health checks, user management, auth rate limiting, document access audit, batch cancellation, list sorting, unified promotion retry, normalized patient deduplication, assignment-time `IN_ENTRY` transitions, API-proxied document streaming, CORS) is also done. See the [gap analysis](vigilcare-records-gap-analysis.md) summary matrix for remaining open items.
| Phase | Scope | Status | | Phase | Scope | Status |
|---|---|---| |---|---|---|
@@ -14,13 +14,15 @@
| 6 | Track B live capture with clinician attestation | Done | | 6 | Track B live capture with clinician attestation | Done |
| 7 | Digitization workstation UI (`vigilcare-records-web`) | Done | | 7 | Digitization workstation UI (`vigilcare-records-web`) | Done |
| 8 | Prometheus metrics, supervisor overview, batch events API, promotion retry | Done | | 8 | Prometheus metrics, supervisor overview, batch events API, promotion retry | Done |
| 9 | E2E verification script, clinical scenario docs, extended seed data | Partial | | 9 | Extended seed data, E2E verification script, clinical scenario docs | Done |
| 10 | Barcode/QR cover sheets for high-volume intake | Done |
| 11 | HL7 FHIR R4 read-only API and FHIR Explorer UI | Done |
| 12 | Backend-driven batch-type field requirements (`fieldRequirements` metadata) | Done |
| 13 | Optional OCR-assisted draft pre-fill (Azure or Tesseract; disabled by default) | Done |
**Delivered in Phase 9 so far:** `scripts/run-vigilcare-records-verification-p9.sh` (full workflow + metrics smoke test), [digitization-workstation-guide.md](digitization-workstation-guide.md) (backfill, live capture, corrections). **Verification scripts:** `./scripts/run-vigilcare-records-verification-p9.sh` (full workflow + metrics), `./scripts/run-vigilcare-records-phase-10-verification.sh`, `./scripts/run-vigilcare-records-phase-11-verification.sh`, `./scripts/run-vigilcare-records-phase-13-verification.sh`.
**Remaining in Phase 9:** Extended `DataSeeder` with demo patients and batches across all statuses/types/tracks for dashboard demos without manual data entry. See [README.md](../README.md) for API reference, quick start, and [digitization-workstation-guide.md](digitization-workstation-guide.md) for operator workflows.
See [README.md](../README.md) for API reference, quick start, and verification scripts.
--- ---
@@ -28,17 +30,19 @@ See [README.md](../README.md) for API reference, quick start, and verification s
A clinical records intake system that converts paper-based patient charts into structured, human-verified digital records before they enter the VigilCareClinical alerting pipeline. Designed for small hospitals, district clinics, and island health systems where the majority of historical and day-to-day records still exist on paper. A clinical records intake system that converts paper-based patient charts into structured, human-verified digital records before they enter the VigilCareClinical alerting pipeline. Designed for small hospitals, district clinics, and island health systems where the majority of historical and day-to-day records still exist on paper.
The workflow is deliberately manual at every extraction step: The workflow is deliberately human-governed at every quality gate:
**Scan / upload → Human data entry → Human verification → Approved patient record** **Scan / upload → Human data entry → Human verification → Approved patient record**
There is **no OCR** in scope. Every structured field is typed by a human who reads the scan. A second human compares the entry against the original image before the record becomes clinically authoritative. Unapproved drafts never trigger alerts, scoring, or surveillance. Every structured field is ultimately verified by a human who reads the scan. A second human compares the entry against the original image before the record becomes clinically authoritative. Unapproved drafts never trigger alerts, scoring, or surveillance.
**Optional OCR assist (Phase 13):** When `Ocr:Enabled` is true, a background service may pre-fill draft fields from the scan after upload. The entry clerk still reviews every value against the image, corrects errors, and submits for verification — OCR converts "type everything" into "review and correct" but does not bypass entry or verification gates. Disabled by default.
VigilCareClinical ([vigilcare-clinical-api-prd.md](vigilcare-clinical-api-prd.md)) remains the downstream intelligence layer: threshold alerting, NEWS2, sepsis detection, ward dashboard, and long-term archival. VigilCare Records is the **precursor** that creates the fuel — patient identity, encounters, and observations — in a governed, auditable way. VigilCareClinical ([vigilcare-clinical-api-prd.md](vigilcare-clinical-api-prd.md)) remains the downstream intelligence layer: threshold alerting, NEWS2, sepsis detection, ward dashboard, and long-term archival. VigilCare Records is the **precursor** that creates the fuel — patient identity, encounters, and observations — in a governed, auditable way.
This project maps to `sd-mid-009` (Outbox Pattern for approval promotion events), `sd-mid-013` (CQRS — draft vs live read models), `sd-junior-004` (RBAC), and senior trade-off conversations around **data quality gates vs time-to-value** in resource-constrained health systems. This project maps to `sd-mid-009` (Outbox Pattern for approval promotion events), `sd-mid-013` (CQRS — draft vs live read models), `sd-junior-004` (RBAC), and senior trade-off conversations around **data quality gates vs time-to-value** in resource-constrained health systems.
**Stack:** .NET 8 Web API, PostgreSQL 16, MinIO (scanned document storage), Redis 7 (batch assignment locks and live-capture threshold cache), Serilog → Seq, Prometheus → Grafana, xUnit, Docker Compose. Vue 3 digitization workstation UI in `vigilcare-records-web/` (Vite, Pinia, Tailwind CSS). **Stack:** .NET 8 Web API, PostgreSQL 16, MinIO (scanned document storage), Redis 7 (batch assignment locks and live-capture threshold cache), Serilog → Seq, Prometheus → Grafana, xUnit, Docker Compose. Vue 3 digitization workstation UI in `vigilcare-records-web/` (Vite, Pinia, Tailwind CSS). Optional OCR via Azure Document Intelligence or Tesseract (`Ocr:Enabled`). Read-only FHIR R4 API at `/fhir` (Firely SDK).
**Prerequisite / companion:** VigilCareClinicalAPI Phases 12 minimum (patient, encounter, observation ingest). Full VigilCare value unlocks as approved observations flow into the existing Kafka alert pipeline. **Prerequisite / companion:** VigilCareClinicalAPI Phases 12 minimum (patient, encounter, observation ingest). Full VigilCare value unlocks as approved observations flow into the existing Kafka alert pipeline.
@@ -46,7 +50,7 @@ This project maps to `sd-mid-009` (Outbox Pattern for approval promotion events)
## Goals ## Goals
- Provide a complete scan-to-approved workflow for paper chart conversion without OCR or machine extraction - Provide a complete scan-to-approved workflow for paper chart conversion with human-verified data quality at every gate (optional OCR pre-fill to accelerate entry, never to replace it)
- Enforce **separation of duties**: the person who enters data cannot verify their own entry - Enforce **separation of duties**: the person who enters data cannot verify their own entry
- Promote approved structured data into VigilCareClinical's live domain model (Patient, Encounter, Observation) atomically - Promote approved structured data into VigilCareClinical's live domain model (Patient, Encounter, Observation) atomically
- Maintain a full audit trail linking every approved field to its source scan, entry clerk, verifier, and approver - Maintain a full audit trail linking every approved field to its source scan, entry clerk, verifier, and approver
@@ -55,8 +59,8 @@ This project maps to `sd-mid-009` (Outbox Pattern for approval promotion events)
## Non-Goals ## Non-Goals
- **OCR or automated field extraction** — explicitly out of scope for v1; may be evaluated in a future phase after human-verified baseline quality is established - **OCR bypassing human review** — OCR may pre-fill draft fields (Phase 13) but never skips entry, verification, or approval; fully automated extraction without human review remains out of scope
- HL7/FHIR compliance or LIS instrument integration - **Full HL7v2 or FHIR write compliance** — Phase 11 delivers read-only FHIR R4; HL7v2 ADT, FHIR write, SMART on FHIR, and LIS instrument integration remain out of scope
- Full EMR functionality (billing, pharmacy inventory, scheduling beyond basic encounter open) - Full EMR functionality (billing, pharmacy inventory, scheduling beyond basic encounter open)
- Replacing VigilCareClinical's alert engine, scoring, or ward dashboard - Replacing VigilCareClinical's alert engine, scoring, or ward dashboard
- HIPAA-certified or jurisdiction-specific medical device registration (model the patterns; certification scoped per deployment) - HIPAA-certified or jurisdiction-specific medical device registration (model the patterns; certification scoped per deployment)
@@ -329,10 +333,10 @@ This keeps digitization history, patient coverage stats, and Prometheus batch me
**Description:** Intake clerk scans or uploads a paper record. System stores the file in MinIO, computes SHA-256, creates a batch in `uploaded` state, and writes a `DigitizationEvent`. **Description:** Intake clerk scans or uploads a paper record. System stores the file in MinIO, computes SHA-256, creates a batch in `uploaded` state, and writes a `DigitizationEvent`.
**Endpoints:** **Endpoints:**
- `POST /api/v1/digitization-batches` — multipart upload: `file` (PDF, JPEG, PNG; max 25 MB), `batchType`, optional `patientId`, optional `track` (default `backfill`) - `POST /api/v1/digitization-batches` — multipart upload: `file` (PDF, JPEG, PNG; max 25 MB), `batchType`, optional `patientId`, optional `track` (default `backfill`), optional `coverSheetCode` (Phase 10 — auto-populates type/track/patient from barcode cover sheet)
- `GET /api/v1/digitization-batches/:id` — batch detail with document presigned URL (15-minute expiry) - `GET /api/v1/digitization-batches/:id` — batch detail with document presigned URL (15-minute expiry)
- `GET /api/v1/digitization-batches` — filter by `status`, `batchType`, `assignedTo`, `track`; paginated - `GET /api/v1/digitization-batches` — filter by `status`, `batchType`, `assignedTo`, `track`; paginated
- `PATCH /api/v1/digitization-batches/:id/assign` — assign to entry clerk (Redis lock prevents double-assignment) - `PATCH /api/v1/digitization-batches/:id/assign` — assign to entry clerk (Redis lock prevents double-assignment; transitions `uploaded → in_entry` immediately)
**Validation:** **Validation:**
- Accepted MIME types: `application/pdf`, `image/jpeg`, `image/png` - Accepted MIME types: `application/pdf`, `image/jpeg`, `image/png`
@@ -346,7 +350,7 @@ This keeps digitization history, patient coverage stats, and Prometheus batch me
### 2. Draft Data Entry — *implemented (Phase 2)* ### 2. Draft Data Entry — *implemented (Phase 2)*
**Description:** Entry clerk views the scan alongside structured form fields. Saves draft patient updates, encounter context, and observations. Batch moves to `in_entry` on first save. **Description:** Entry clerk views the scan alongside structured form fields. Saves draft patient updates, encounter context, and observations. Batch moves to `in_entry` on assignment or first save. Form sections (allergies, medications, encounter summary, observations) are driven by backend `fieldRequirements` metadata (Phase 12). When OCR is enabled, pre-filled fields include confidence indicators (Phase 13).
**Endpoints:** **Endpoints:**
- `GET /api/v1/digitization-batches/:id/draft` — full draft payload: patient fields, encounter, observations[] - `GET /api/v1/digitization-batches/:id/draft` — full draft payload: patient fields, encounter, observations[]
@@ -492,36 +496,95 @@ Docker Compose exposes Prometheus on port **9095** and Grafana on **3013**.
--- ---
### 9. Authentication and Audit — *implemented (Phases 1, 8)* ### 9. Authentication and Audit — *implemented (Phases 1, 8, post-phase hardening)*
**Description:** JWT auth with role claims. Every state transition writes a `DigitizationEvent`. Document access logged. **Description:** JWT auth with role claims. Every state transition writes a `DigitizationEvent`. Document access logged. Auth endpoints rate-limited (10 requests per 5 minutes). Administrators manage users via CRUD endpoints.
**Endpoints:** **Endpoints:**
- `POST /api/v1/auth/login` — returns access token (15 min), refresh token (7 days), and user profile - `POST /api/v1/auth/login` — returns access token (15 min), refresh token (7 days), and user profile
- `POST /api/v1/auth/refresh` — rotates refresh token and issues new access token - `POST /api/v1/auth/refresh` — rotates refresh token and issues new access token
- `POST /api/v1/auth/logout` — revokes refresh token server-side - `POST /api/v1/auth/logout` — revokes refresh token server-side
- `GET /api/v1/auth/me` - `GET /api/v1/auth/me`
- `POST /api/v1/users` — create user (administrator)
- `PATCH /api/v1/users/:id` — update user (administrator)
- `POST /api/v1/users/:id/reset-password` — reset password (administrator)
- `POST /api/v1/users/me/change-password` — self-service password change
**Audit requirements:** **Audit requirements:**
- Auth events (`USER_LOGOUT`, `TOKEN_REFRESHED`) persisted in `auth_audit_events` - Auth events (`USER_LOGOUT`, `TOKEN_REFRESHED`) persisted in `auth_audit_events`
- Who viewed a scan and when - Who viewed a scan and when (`document_accessed` events on batch detail and document download)
- Who changed which draft field (field-level diff in event metadata on save) - Who changed which draft field (field-level diff in event metadata on save)
- Who approved promotion and which live record IDs were created - Who approved promotion and which live record IDs were created
**Health probes (post-phase hardening):** `GET /health/live`, `GET /health/ready` (PostgreSQL, Redis, MinIO), `GET /health/startup`.
--- ---
## Digitization Workstation UI — *implemented (Phase 7)* ### 10. Cover Sheet System — *implemented (Phase 10)*
Vue 3 SPA at `vigilcare-records-web/` (dev server port **3028**, proxies `/api` → API on **5217**). Four primary views: **Description:** Printable barcode/QR cover sheets encode batch type, track, optional patient, and optional entry-clerk pre-assignment. Intake clerks attach cover sheets to paper chart sections before bulk scanning; the workstation reads the code and auto-creates batches without manual classification.
**Endpoints:**
- `POST /api/v1/cover-sheets/generate` — create 1100 cover sheets with unique `VCR-CS-{hex}` codes
- `GET /api/v1/cover-sheets/lookup/{code}` — resolve a cover sheet for intake auto-fill
- `GET /api/v1/cover-sheets` — list cover sheets with `isUsed` and `patientId` filters
- `POST /api/v1/cover-sheets/{id}/pdf` — printable single cover sheet PDF with QR code
- `POST /api/v1/cover-sheets/batch-pdf` — multi-page PDF for batch printing
Cover sheets are single-use; redeemed on batch creation (`409 COVER_SHEET_ALREADY_USED` on reuse).
---
### 11. HL7 FHIR R4 Read API — *implemented (Phase 11)*
**Description:** Read-only FHIR R4 endpoints expose promoted clinical data (`Patient`, `Encounter`, `Observation`) for external EHR and interoperability consumers. Maps VigilCare observation codes to LOINC; search bundles include pagination links.
**Endpoints:**
- `GET /fhir/metadata` — anonymous `CapabilityStatement`
- `GET /fhir/Patient/{id}`, `GET /fhir/Patient?name=`, `GET /fhir/Patient?identifier=` (MRN)
- `GET /fhir/Encounter/{id}`, `GET /fhir/Encounter?patient=`
- `GET /fhir/Observation/{id}`, `GET /fhir/Observation?patient=&code=&category=&date=`
- `GET /fhir/Patient/{id}/$everything` — composite bundle
SMART on FHIR authorization and FHIR write operations remain out of scope.
---
### 12. Batch-Type Field Requirements — *implemented (Phase 12)*
**Description:** `fieldRequirements` metadata on `GET /digitization-batches/:id` and `GET /digitization-batches/:id/draft` tells the workstation which form sections to render per batch type (patient demographics, encounter context, encounter summary fields, observations, allergies, medications). Entry and verification forms consume this metadata instead of hardcoding batch-type rules. Backend `ValidateCompleteness()` remains the enforcement layer.
---
### 13. Optional OCR-Assisted Pre-Fill — *implemented (Phase 13; disabled by default)*
**Description:** When `Ocr:Enabled` is true, `OcrProcessingService` polls uploaded batches, extracts text via Azure Document Intelligence or self-hosted Tesseract, and pre-fills draft fields with per-field confidence scores. Entry clerks review and correct; verification and approval workflows are unchanged.
**Configuration:** `Ocr:Enabled` (default `false`), `Ocr:Provider` (`azure` or `tesseract`), `Ocr:ConfidenceThreshold`, `Ocr:PollIntervalSeconds`.
**Privacy:** Cloud provider requires a signed BAA; local Tesseract keeps PHI on-network.
**Draft response:** `ocrConfidence` map on draft payload; UI shows confidence borders and an "OCR Pre-filled" banner when OCR was used.
---
## Digitization Workstation UI — *implemented (Phases 7, 10, 11, 12, 13)*
Vue 3 SPA at `vigilcare-records-web/` (dev server port **3028**, proxies `/api` and `/fhir` → API on **5217**). Role-based routing with JWT refresh.
| View | User | Purpose | | View | User | Purpose |
|---|---|---| |---|---|---|
| **Intake** | Intake clerk | Upload, assign patient, assign entry clerk | | **Intake** | Intake clerk | Upload, barcode cover sheet lookup, assign patient, assign entry clerk |
| **Entry** | Data entry clerk | Side-by-side scan + form with auto-save | | **Cover sheets** | Intake clerk, administrator | Generate, list, and print QR cover sheets |
| **Entry** | Data entry clerk | Side-by-side scan + form with auto-save, backend-driven field visibility, optional OCR confidence indicators |
| **Verification** | Verifier | Side-by-side with field checkboxes, approve/reject | | **Verification** | Verifier | Side-by-side with field checkboxes, approve/reject |
| **Clinical approval** | Clinical approver | Scan review, approve/reject, retroactive alert toggle |
| **Live capture** | Clinician | Bedside vitals with attestation and password confirm |
| **Patient history** | All roles | Digitization timeline with correction chain and audit trail |
| **Queue dashboard** | Administrator | Backlog metrics from work-queue overview | | **Queue dashboard** | Administrator | Backlog metrics from work-queue overview |
| **FHIR Explorer** | Administrator | Browse and search FHIR resources, inspect JSON, load Patient `$everything` |
Role-based routing and JWT refresh are implemented. Not a full EMR UI — clinical alerting views remain in VigilCareClinical's ward dashboard. Scan viewer loads documents via authenticated `GET /digitization-batches/:id/document` blob URLs (avoids cross-origin MinIO iframe issues). Not a full EMR UI — clinical alerting views remain in VigilCareClinical's ward dashboard.
See [digitization-workstation-guide.md](digitization-workstation-guide.md) for operator workflows and clinical scenarios. See [digitization-workstation-guide.md](digitization-workstation-guide.md) for operator workflows and clinical scenarios.
@@ -576,7 +639,13 @@ If VigilCareClinical is unreachable in split deployment, batch remains `approved
| Supervisor metrics | Work-queue overview and Prometheus gauges reflect live batch counts | Done | | Supervisor metrics | Work-queue overview and Prometheus gauges reflect live batch counts | Done |
| Promotion retry | Transient promotion failure defers to `APPROVED` with automatic retry | Done | | Promotion retry | Transient promotion failure defers to `APPROVED` with automatic retry | Done |
| E2E verification script | `./scripts/run-vigilcare-records-verification-p9.sh` passes against running API | Done | | E2E verification script | `./scripts/run-vigilcare-records-verification-p9.sh` passes against running API | Done |
| Extended demo seed data | Startup seed includes patients/batches across all statuses for dashboard demos | Pending | | Extended demo seed data | Startup seed includes patients/batches across all statuses for dashboard demos | Done |
| Cover sheet intake | Barcode cover sheet auto-creates batch with correct type/track/patient | Done |
| FHIR read API | `GET /fhir/Patient`, `/Encounter`, `/Observation` return valid FHIR R4 JSON | Done |
| Field requirements metadata | Entry/verification forms render sections from `fieldRequirements` on batch/draft responses | Done |
| OCR pre-fill (when enabled) | Uploaded batch gets draft pre-fill with confidence map; disabled by default | Done |
| Health checks | `/health/ready` reports PostgreSQL, Redis, MinIO status | Done |
| Batch cancellation | Administrator can cancel batches in `uploaded`, `in_entry`, or `rejected` | Done |
--- ---
@@ -592,7 +661,11 @@ If VigilCareClinical is unreachable in split deployment, batch remains `approved
| 6 | Track B live capture with clinician attestation | Done | | 6 | Track B live capture with clinician attestation | Done |
| 7 | Digitization workstation UI (entry + verification side-by-side) | Done | | 7 | Digitization workstation UI (entry + verification side-by-side) | Done |
| 8 | Prometheus metrics, supervisor dashboard, promotion retry job | Done | | 8 | Prometheus metrics, supervisor dashboard, promotion retry job | Done |
| 9 | Extended seed data, E2E verification script, clinical scenario documentation | Partial | | 9 | Extended seed data, E2E verification script, clinical scenario documentation | Done |
| 10 | Barcode/QR cover sheet system for high-volume intake | Done |
| 11 | HL7 FHIR R4 read-only API and FHIR Explorer UI | Done |
| 12 | Backend-driven batch-type field requirements | Done |
| 13 | Optional OCR-assisted draft pre-fill | Done |
--- ---
@@ -677,9 +750,49 @@ This phase connects Records to Clinical. Run against a VigilCareClinical Phase 2
**Phase 8 (done):** Prometheus metrics, `GET /work-queue/overview`, cursor-paginated `GET /digitization-batches/:id/events`, promotion deferral (202) and `PromotionRetryService`, Docker Prometheus/Grafana stack. Verification: `./scripts/run-vigilcare-records-phase-8-verification.sh`. **Phase 8 (done):** Prometheus metrics, `GET /work-queue/overview`, cursor-paginated `GET /digitization-batches/:id/events`, promotion deferral (202) and `PromotionRetryService`, Docker Prometheus/Grafana stack. Verification: `./scripts/run-vigilcare-records-phase-8-verification.sh`.
**Phase 9 (partial):** **Phase 9 (done):** Extended `DataSeeder.cs` with 10 demo batches across all statuses/types/tracks, `docs/digitization-workstation-guide.md`, `./scripts/run-vigilcare-records-verification-p9.sh`, project README.
- Done: `docs/digitization-workstation-guide.md`, `./scripts/run-vigilcare-records-verification-p9.sh`, project README
- Remaining: extend `DataSeeder.cs` with demo patients and batches across all statuses, types, and tracks ---
### Phase 10 — Cover Sheet System
**What to do:**
1. Add `CoverSheet` entity and cover sheet CRUD/generate/lookup endpoints.
2. Wire `coverSheetCode` on batch upload to auto-populate type, track, patient, and redeem the sheet.
3. Generate printable PDFs with QR codes; add `/cover-sheets` and barcode-assisted intake in the Vue UI.
**Verification:** `./scripts/run-vigilcare-records-phase-10-verification.sh`
---
### Phase 11 — FHIR R4 Read API
**What to do:**
1. Install Firely SDK; map promoted clinical data to FHIR Patient, Encounter, Observation resources.
2. Implement read and search endpoints with LOINC mapping and pagination bundles.
3. Add administrator FHIR Explorer view at `/fhir-explorer`.
**Verification:** `./scripts/run-vigilcare-records-phase-11-verification.sh`
---
### Phase 12 — Batch-Type Field Requirements
**What to do:**
1. Add `BatchTypeFieldRequirements` record with static mapping per batch type.
2. Include `fieldRequirements` on `DraftPayloadResponse` and `BatchDetailResponse`.
3. Update `EntryForm.vue` and `VerificationForm.vue` to consume metadata instead of hardcoded batch-type switches.
---
### Phase 13 — Optional OCR Pre-Fill
**What to do:**
1. Add `IOcrService` with Azure Document Intelligence and Tesseract providers behind `Ocr:Enabled` config.
2. Implement `OcrProcessingService` background polling and `OcrDraftPreFiller`.
3. Return `ocrConfidence` on draft payload; show confidence indicators in entry and verification forms.
**Verification:** `./scripts/run-vigilcare-records-phase-13-verification.sh` (automated); manual Azure/Tesseract end-to-end when credentials are configured.
--- ---
@@ -725,7 +838,8 @@ This phase connects Records to Clinical. Run against a VigilCareClinical Phase 2
- [README.md](../README.md) — API reference, quick start, verification scripts, data models - [README.md](../README.md) — API reference, quick start, verification scripts, data models
- [digitization-workstation-guide.md](digitization-workstation-guide.md) — clinical scenarios (backfill, live capture, corrections) - [digitization-workstation-guide.md](digitization-workstation-guide.md) — clinical scenarios (backfill, live capture, corrections)
- [plans/](plans/) — phase-by-phase implementation guides (Phases 19) - [plans/](plans/) — phase-by-phase implementation guides (Phases 113)
- [vigilcare-records-gap-analysis.md](vigilcare-records-gap-analysis.md) — post-phase hardening tracker and remaining open items
- [vigilcare-clinical-api-prd.md](vigilcare-clinical-api-prd.md) — downstream alerting and observation ingest - [vigilcare-clinical-api-prd.md](vigilcare-clinical-api-prd.md) — downstream alerting and observation ingest
- [Completed/VigilCareClinicalAPI/VigilCare-Partner-Brief.md](Completed/VigilCareClinicalAPI/VigilCare-Partner-Brief.md) — clinical positioning and scope boundaries - [Completed/VigilCareClinicalAPI/VigilCare-Partner-Brief.md](Completed/VigilCareClinicalAPI/VigilCare-Partner-Brief.md) — clinical positioning and scope boundaries
- [Completed/national-digital-services-architecture.md](Completed/national-digital-services-architecture.md) — scan-and-verify pattern for paper-to-digital government services - [Completed/national-digital-services-architecture.md](Completed/national-digital-services-architecture.md) — scan-and-verify pattern for paper-to-digital government services
@@ -0,0 +1,751 @@
#!/usr/bin/env bash
# Runs Phase 10 verification checks from docs/plans/phase-10-plan.md.
#
# Covers cover sheet generation, lookup, list filters, PDF endpoints,
# barcode-assisted batch upload, redeem/auto-assign, and reuse prevention.
#
# Prerequisites:
# docker compose up -d (PostgreSQL, Redis, MinIO)
# dotnet ef database update --project VigilCareRecordsAPI
# dotnet run --project VigilCareRecordsAPI
# Phase 19 seed data (intake1, entry1, admin1)
#
# Environment overrides:
# VIGILCARE_API_URL default: http://localhost:5217
# VIGILCARE_COMPOSE_FILE default: <repo>/docker-compose.yml
# VIGILCARE_PG_HOST default: localhost
# VIGILCARE_PG_PORT default: 5437
# VIGILCARE_PG_DB default: vigilcare_records
# VIGILCARE_PG_USER default: postgres
# VIGILCARE_PG_PASSWORD default: password
# VIGILCARE_SKIP_DB_CHECKS set to 1 to skip PostgreSQL assertions
# VIGILCARE_SKIP_TEST_CHECKS set to 1 to skip dotnet integration tests
#
# Usage:
# chmod +x scripts/run-vigilcare-records-phase-10-verification.sh
# ./scripts/run-vigilcare-records-phase-10-verification.sh
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
FIXTURE_PDF="$SCRIPT_DIR/fixtures/test-scan.pdf"
API_URL="${VIGILCARE_API_URL:-http://localhost:5217}"
COMPOSE_FILE="${VIGILCARE_COMPOSE_FILE:-$REPO_ROOT/docker-compose.yml}"
COMPOSE=(docker compose -f "$COMPOSE_FILE")
PG_HOST="${VIGILCARE_PG_HOST:-localhost}"
PG_PORT="${VIGILCARE_PG_PORT:-5437}"
PG_DB="${VIGILCARE_PG_DB:-vigilcare_records}"
PG_USER="${VIGILCARE_PG_USER:-postgres}"
PG_PASSWORD="${VIGILCARE_PG_PASSWORD:-password}"
SKIP_DB_CHECKS="${VIGILCARE_SKIP_DB_CHECKS:-0}"
SKIP_TEST_CHECKS="${VIGILCARE_SKIP_TEST_CHECKS:-0}"
# Resolved at runtime from the live API (seed IDs vary per environment)
ENTRY_CLERK1_ID=""
PATIENT1_ID=""
PASS_COUNT=0
FAIL_COUNT=0
FAILED_TESTS=()
# Shared state populated during the run
INTAKE_TOKEN=""
ADMIN_TOKEN=""
ENTRY_TOKEN=""
UPLOAD_CODE=""
UPLOAD_COVER_SHEET_ID=""
UPLOAD_BATCH_ID=""
ASSIGN_CODE=""
PDF_SHEET_IDS=()
log() {
printf '%s\n' "$*"
}
section() {
log ""
log "== $1 =="
}
pass() {
PASS_COUNT=$((PASS_COUNT + 1))
log " PASS: $1"
}
fail() {
FAIL_COUNT=$((FAIL_COUNT + 1))
FAILED_TESTS+=("$1")
log " FAIL: $1"
}
require_cmd() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
log "ERROR: required command not found: $cmd"
exit 1
fi
}
ensure_fixture_pdf() {
if [[ -f "$FIXTURE_PDF" ]]; then
return 0
fi
mkdir -p "$(dirname "$FIXTURE_PDF")"
cat >"$FIXTURE_PDF" <<'EOF'
%PDF-1.0
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R/Resources<<>>>>endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
trailer<</Size 4/Root 1 0 R>>
startxref
206
%%EOF
EOF
}
unique_pdf_path() {
local suffix="$1"
local path="/tmp/vigilcare-p10-${suffix}-${RANDOM}.pdf"
printf '%%PDF-1.4\nphase10-%s-%s\n%%%%EOF\n' "$suffix" "$(date +%s%N)" >"$path"
printf '%s' "$path"
}
http_code() {
curl -sS -o /dev/null -w '%{http_code}' "$@"
}
json_post() {
local url="$1"
local body="$2"
local token="${3:-}"
if [[ -n "$token" ]]; then
curl -sS -X POST "$url" \
-H "Authorization: Bearer $token" \
-H 'Content-Type: application/json' \
-d "$body"
else
curl -sS -X POST "$url" \
-H 'Content-Type: application/json' \
-d "$body"
fi
}
json_get() {
local url="$1"
local token="$2"
curl -sS "$url" -H "Authorization: Bearer $token"
}
login() {
local username="$1"
local password="${2:-password}"
json_post "$API_URL/api/v1/auth/login" \
"{\"username\":\"$username\",\"password\":\"$password\"}"
}
extract_data_field() {
local json="$1"
local field="$2"
jq -er ".data.$field // empty" <<<"$json"
}
extract_bool_field() {
local json="$1"
local field="$2"
jq -er ".data.$field | if . == null then empty else tostring end" <<<"$json"
}
resolve_directory_ids() {
local users_json patients_json
users_json="$(json_get "$API_URL/api/v1/users?role=DATA_ENTRY_CLERK" "$INTAKE_TOKEN")"
patients_json="$(json_get "$API_URL/api/v1/patients/search?q=Patient" "$INTAKE_TOKEN")"
ENTRY_CLERK1_ID="$(jq -er '.data[0].id // empty' <<<"$users_json" 2>/dev/null || true)"
PATIENT1_ID="$(jq -er '.data[0].id // empty' <<<"$patients_json" 2>/dev/null || true)"
if [[ -n "$ENTRY_CLERK1_ID" ]]; then
pass "resolved entry clerk ID for auto-assign test"
else
fail "resolved entry clerk ID for auto-assign test"
fi
if [[ -n "$PATIENT1_ID" ]]; then
pass "resolved patient ID for patient-linked cover sheet test"
else
fail "resolved patient ID for patient-linked cover sheet test"
fi
}
extract_error_code() {
local json="$1"
jq -er '.error.code // empty' <<<"$json" 2>/dev/null || true
}
assert_api_reachable() {
local code
code="$(http_code "$API_URL/swagger/index.html" || true)"
if [[ "$code" != "200" ]]; then
log "ERROR: API not reachable at $API_URL (HTTP $code)."
log "Start infrastructure with: docker compose up -d"
log "Apply migrations with: dotnet ef database update --project VigilCareRecordsAPI"
log "Start API with: dotnet run --project VigilCareRecordsAPI"
exit 1
fi
}
compose_service_running() {
local service="$1"
"${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -qx "$service"
}
psql_available() {
[[ "$SKIP_DB_CHECKS" == "1" ]] && return 1
compose_service_running postgres && return 0
command -v psql >/dev/null 2>&1 && return 0
return 1
}
psql_query() {
if [[ "$SKIP_DB_CHECKS" == "1" ]]; then
return 1
fi
if compose_service_running postgres; then
"${COMPOSE[@]}" exec -T postgres \
psql -U "$PG_USER" -d "$PG_DB" -Atqc "$1"
elif command -v psql >/dev/null 2>&1; then
PGPASSWORD="$PG_PASSWORD" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -Atqc "$1"
else
return 1
fi
}
upload_with_cover_sheet() {
local token="$1"
local code="$2"
local pdf="$3"
curl -sS -X POST "$API_URL/api/v1/digitization-batches" \
-H "Authorization: Bearer $token" \
-F "file=@${pdf};type=application/pdf" \
-F "coverSheetCode=$code"
}
upload_with_cover_sheet_status() {
local token="$1"
local code="$2"
local pdf="$3"
local body_file http_code_val
body_file="$(mktemp)"
http_code_val="$(curl -sS -o "$body_file" -w '%{http_code}' -X POST "$API_URL/api/v1/digitization-batches" \
-H "Authorization: Bearer $token" \
-F "file=@${pdf};type=application/pdf" \
-F "coverSheetCode=$code")"
cat "$body_file"
rm -f "$body_file"
printf '\n__HTTP_STATUS__:%s' "$http_code_val"
}
generate_cover_sheets() {
local token="$1"
local body="$2"
json_post "$API_URL/api/v1/cover-sheets/generate" "$body" "$token"
}
test_authentication() {
section "0. Authentication"
local intake_json admin_json entry_json
intake_json="$(login intake1)"
admin_json="$(login admin1)"
entry_json="$(login entry1)"
INTAKE_TOKEN="$(extract_data_field "$intake_json" token)"
ADMIN_TOKEN="$(extract_data_field "$admin_json" token)"
ENTRY_TOKEN="$(extract_data_field "$entry_json" token)"
if [[ -z "$INTAKE_TOKEN" ]]; then
log "ERROR: intake1 login failed."
log "Response: ${intake_json:-<empty>}"
exit 1
fi
if [[ -n "$INTAKE_TOKEN" ]]; then
pass "intake1 login returns JWT"
else
fail "intake1 login returns JWT"
fi
if [[ -n "$ADMIN_TOKEN" ]]; then
pass "admin1 login returns JWT"
else
fail "admin1 login returns JWT"
fi
if [[ -n "$ENTRY_TOKEN" ]]; then
pass "entry1 login returns JWT"
else
fail "entry1 login returns JWT"
fi
resolve_directory_ids
}
test_database_schema() {
section "1. Database — cover_sheets table"
if ! psql_available; then
log " SKIP: PostgreSQL checks (set VIGILCARE_SKIP_DB_CHECKS=0 and start postgres)"
return
fi
local table_exists index_code index_unused
table_exists="$(psql_query "
SELECT count(*)
FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'cover_sheets';
")"
index_code="$(psql_query "
SELECT count(*)
FROM pg_indexes
WHERE tablename = 'cover_sheets' AND indexname = 'ix_cover_sheets_code';
")"
index_unused="$(psql_query "
SELECT count(*)
FROM pg_indexes
WHERE tablename = 'cover_sheets' AND indexname = 'ix_cover_sheets_unused';
")"
if [[ "$table_exists" == "1" ]]; then
pass "cover_sheets table exists"
else
fail "cover_sheets table exists"
fi
if [[ "$index_code" == "1" ]]; then
pass "unique index ix_cover_sheets_code exists"
else
fail "unique index ix_cover_sheets_code exists"
fi
if [[ "$index_unused" == "1" ]]; then
pass "filtered index ix_cover_sheets_unused exists"
else
fail "filtered index ix_cover_sheets_unused exists"
fi
}
test_cover_sheet_generation() {
section "2. Cover sheet generation (plan §1)"
local gen_json count unique_count bad_format
gen_json="$(generate_cover_sheets "$INTAKE_TOKEN" \
'{"count":5,"batchType":"VITALS_SHEET","track":"BACKFILL"}')"
if [[ "$(jq -er '.success' <<<"$gen_json")" == "true" ]]; then
pass "POST /cover-sheets/generate returns success for intake1"
else
fail "POST /cover-sheets/generate returns success for intake1"
log " response: $gen_json"
return
fi
count="$(jq '.data | length' <<<"$gen_json")"
unique_count="$(jq '[.data[].code] | unique | length' <<<"$gen_json")"
if [[ "$count" == "5" && "$unique_count" == "5" ]]; then
pass "generate creates 5 cover sheets with unique codes"
else
fail "generate creates 5 cover sheets with unique codes (count=$count unique=$unique_count)"
fi
bad_format="$(jq -r '[.data[].code | test("^VCR-CS-[0-9A-F]{8}$")] | all' <<<"$gen_json")"
if [[ "$bad_format" == "true" ]]; then
pass "all codes match VCR-CS-{8-hex} format"
else
fail "all codes match VCR-CS-{8-hex} format"
fi
UPLOAD_CODE="$(jq -r '.data[0].code' <<<"$gen_json")"
UPLOAD_COVER_SHEET_ID="$(jq -r '.data[0].id' <<<"$gen_json")"
PDF_SHEET_IDS=($(jq -r '.data[0].id, .data[1].id' <<<"$gen_json"))
local entry_json entry_token entry_code
entry_json="$(login entry1)"
entry_token="$(extract_data_field "$entry_json" token)"
entry_code="$(http_code -X POST "$API_URL/api/v1/cover-sheets/generate" \
-H "Authorization: Bearer $entry_token" \
-H 'Content-Type: application/json' \
-d '{"count":1,"batchType":"VITALS_SHEET","track":"BACKFILL"}')"
if [[ "$entry_code" == "403" ]]; then
pass "generate denied for DATA_ENTRY_CLERK (403)"
else
fail "generate denied for DATA_ENTRY_CLERK (403) — got HTTP $entry_code"
fi
local admin_json
admin_json="$(generate_cover_sheets "$ADMIN_TOKEN" \
'{"count":1,"batchType":"LAB_RESULTS","track":"BACKFILL"}')"
if [[ "$(jq -er '.success' <<<"$admin_json")" == "true" ]]; then
pass "generate allowed for ADMINISTRATOR"
else
fail "generate allowed for ADMINISTRATOR"
fi
}
test_cover_sheet_lookup_and_list() {
section "3. Cover sheet lookup and list (plan §2)"
if [[ -z "$UPLOAD_CODE" ]]; then
fail "lookup requires generated cover sheet code"
return
fi
local lookup_json is_used batch_type
lookup_json="$(json_get "$API_URL/api/v1/cover-sheets/lookup/$UPLOAD_CODE" "$INTAKE_TOKEN")"
if [[ "$(jq -er '.success' <<<"$lookup_json")" == "true" ]]; then
pass "GET /cover-sheets/lookup/{code} returns success"
else
fail "GET /cover-sheets/lookup/{code} returns success"
return
fi
is_used="$(extract_bool_field "$lookup_json" isUsed)"
batch_type="$(extract_data_field "$lookup_json" batchType)"
if [[ "$is_used" == "false" ]]; then
pass "lookup shows isUsed=false before upload"
else
fail "lookup shows isUsed=false before upload (isUsed=$is_used)"
fi
if [[ "$batch_type" == "VITALS_SHEET" ]]; then
pass "lookup returns batchType VITALS_SHEET"
else
fail "lookup returns batchType VITALS_SHEET (got $batch_type)"
fi
local unknown_json unknown_code
unknown_json="$(json_get "$API_URL/api/v1/cover-sheets/lookup/VCR-CS-DEADBEEF" "$INTAKE_TOKEN")"
unknown_code="$(extract_error_code "$unknown_json")"
if [[ "$unknown_code" == "COVER_SHEET_NOT_FOUND" ]]; then
pass "lookup unknown code returns COVER_SHEET_NOT_FOUND"
else
fail "lookup unknown code returns COVER_SHEET_NOT_FOUND (got ${unknown_code:-<none>})"
fi
local list_json unused_count
list_json="$(json_get "$API_URL/api/v1/cover-sheets?isUsed=false&page=1&pageSize=20" "$INTAKE_TOKEN")"
unused_count="$(jq '.data | length' <<<"$list_json")"
if [[ "$(jq -er '.success' <<<"$list_json")" == "true" && "$unused_count" -ge 5 ]]; then
pass "GET /cover-sheets?isUsed=false lists unused sheets"
else
fail "GET /cover-sheets?isUsed=false lists unused sheets (count=$unused_count)"
fi
}
test_pdf_generation() {
section "4. Cover sheet PDF generation (plan Step 5)"
if [[ -z "$UPLOAD_COVER_SHEET_ID" || ${#PDF_SHEET_IDS[@]} -lt 2 ]]; then
fail "PDF tests require generated cover sheet IDs"
return
fi
local pdf_tmp headers_file pdf_code content_type pdf_header
pdf_tmp="$(mktemp)"
headers_file="$(mktemp)"
pdf_code="$(curl -sS -D "$headers_file" -o "$pdf_tmp" -w '%{http_code}' -X POST \
"$API_URL/api/v1/cover-sheets/${UPLOAD_COVER_SHEET_ID}/pdf" \
-H "Authorization: Bearer $INTAKE_TOKEN")"
content_type="$(awk -F': ' 'tolower($1)=="content-type"{print $2}' "$headers_file" | tr -d '\r' | head -1)"
rm -f "$headers_file"
if [[ "$pdf_code" == "200" ]]; then
pass "POST /cover-sheets/{id}/pdf returns 200"
else
fail "POST /cover-sheets/{id}/pdf returns 200 (got HTTP $pdf_code)"
fi
if [[ "$content_type" == application/pdf* ]]; then
pass "single PDF response Content-Type is application/pdf"
else
fail "single PDF response Content-Type is application/pdf (got ${content_type:-<none>})"
fi
pdf_header="$(head -c 8 "$pdf_tmp" || true)"
if [[ "$pdf_header" == %PDF-1.* ]]; then
pass "single PDF body starts with %PDF header"
else
fail "single PDF body starts with %PDF header"
fi
if grep -aq "$UPLOAD_CODE" "$pdf_tmp" 2>/dev/null; then
pass "single PDF embeds cover sheet code text"
else
fail "single PDF embeds cover sheet code text"
fi
rm -f "$pdf_tmp"
local batch_pdf_tmp batch_pdf_code
batch_pdf_tmp="$(mktemp)"
batch_pdf_code="$(curl -sS -o "$batch_pdf_tmp" -w '%{http_code}' -X POST \
"$API_URL/api/v1/cover-sheets/batch-pdf" \
-H "Authorization: Bearer $INTAKE_TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"coverSheetIds\":[\"${PDF_SHEET_IDS[0]}\",\"${PDF_SHEET_IDS[1]}\"]}")"
if [[ "$batch_pdf_code" == "200" ]]; then
pass "POST /cover-sheets/batch-pdf returns 200"
else
fail "POST /cover-sheets/batch-pdf returns 200 (got HTTP $batch_pdf_code)"
fi
if grep -aq '/Count 2' "$batch_pdf_tmp" 2>/dev/null || strings "$batch_pdf_tmp" | grep -q '/Count 2'; then
pass "batch PDF contains two pages (/Count 2)"
else
fail "batch PDF contains two pages (/Count 2)"
fi
rm -f "$batch_pdf_tmp"
}
test_barcode_assisted_upload() {
section "5. Barcode-assisted upload (plan §3)"
if [[ -z "$UPLOAD_CODE" ]]; then
fail "barcode upload requires generated cover sheet code"
return
fi
local pdf upload_json batch_type track status entered_by
pdf="$(unique_pdf_path upload)"
upload_json="$(upload_with_cover_sheet "$INTAKE_TOKEN" "$UPLOAD_CODE" "$pdf")"
rm -f "$pdf"
if [[ "$(jq -er '.success' <<<"$upload_json")" == "true" ]]; then
pass "POST /digitization-batches with coverSheetCode creates batch"
else
fail "POST /digitization-batches with coverSheetCode creates batch"
log " response: $upload_json"
return
fi
batch_type="$(extract_data_field "$upload_json" batchType)"
track="$(extract_data_field "$upload_json" track)"
status="$(extract_data_field "$upload_json" status)"
UPLOAD_BATCH_ID="$(extract_data_field "$upload_json" id)"
if [[ "$batch_type" == "VITALS_SHEET" && "$track" == "BACKFILL" ]]; then
pass "batch inherits batchType and track from cover sheet"
else
fail "batch inherits batchType and track from cover sheet (type=$batch_type track=$track)"
fi
local redeemed_json redeemed_used redeemed_batch_id
redeemed_json="$(json_get "$API_URL/api/v1/cover-sheets/lookup/$UPLOAD_CODE" "$INTAKE_TOKEN")"
redeemed_used="$(extract_bool_field "$redeemed_json" isUsed)"
redeemed_batch_id="$(extract_data_field "$redeemed_json" batchId)"
if [[ "$redeemed_used" == "true" && "$redeemed_batch_id" == "$UPLOAD_BATCH_ID" ]]; then
pass "cover sheet redeemed and linked to batch after upload"
else
fail "cover sheet redeemed and linked to batch after upload"
fi
if psql_available && [[ -n "$UPLOAD_COVER_SHEET_ID" ]]; then
local db_used db_batch
db_used="$(psql_query "
SELECT is_used FROM cover_sheets WHERE id = '$UPLOAD_COVER_SHEET_ID';
")"
db_batch="$(psql_query "
SELECT batch_id FROM cover_sheets WHERE id = '$UPLOAD_COVER_SHEET_ID';
")"
if [[ "$db_used" == "t" && "$db_batch" == "$UPLOAD_BATCH_ID" ]]; then
pass "database row shows is_used=true with batch_id"
else
fail "database row shows is_used=true with batch_id"
fi
fi
}
test_cover_sheet_reuse_and_unknown() {
section "6. Reuse prevention and unknown code (plan §4)"
if [[ -z "$UPLOAD_CODE" ]]; then
fail "reuse test requires uploaded cover sheet code"
return
fi
local pdf reuse_response reuse_status reuse_code unknown_response unknown_status unknown_code
pdf="$(unique_pdf_path reuse)"
reuse_response="$(upload_with_cover_sheet_status "$INTAKE_TOKEN" "$UPLOAD_CODE" "$pdf")"
rm -f "$pdf"
reuse_status="${reuse_response##*__HTTP_STATUS__:}"
reuse_response="${reuse_response%$'\n'__HTTP_STATUS__:*}"
reuse_code="$(extract_error_code "$reuse_response")"
if [[ "$reuse_status" == "409" && "$reuse_code" == "COVER_SHEET_ALREADY_USED" ]]; then
pass "reused cover sheet code returns 409 COVER_SHEET_ALREADY_USED"
else
fail "reused cover sheet code returns 409 COVER_SHEET_ALREADY_USED (HTTP $reuse_status code=${reuse_code:-<none>})"
fi
pdf="$(unique_pdf_path unknown)"
unknown_response="$(upload_with_cover_sheet_status "$INTAKE_TOKEN" "VCR-CS-DEADBEEF" "$pdf")"
rm -f "$pdf"
unknown_status="${unknown_response##*__HTTP_STATUS__:}"
unknown_response="${unknown_response%$'\n'__HTTP_STATUS__:*}"
unknown_code="$(extract_error_code "$unknown_response")"
if [[ "$unknown_status" == "404" && "$unknown_code" == "COVER_SHEET_NOT_FOUND" ]]; then
pass "unknown cover sheet code on upload returns 404 COVER_SHEET_NOT_FOUND"
else
fail "unknown cover sheet code on upload returns 404 COVER_SHEET_NOT_FOUND (HTTP $unknown_status code=${unknown_code:-<none>})"
fi
}
test_auto_assign_cover_sheet() {
section "7. Pre-assigned cover sheet auto-assigns batch"
if [[ -z "$ENTRY_CLERK1_ID" ]]; then
fail "auto-assign test skipped — no entry clerk ID"
return
fi
local gen_json assign_code pdf upload_json status entered_by
gen_json="$(generate_cover_sheets "$INTAKE_TOKEN" \
"{\"count\":1,\"batchType\":\"LAB_RESULTS\",\"track\":\"BACKFILL\",\"assignToUserId\":\"$ENTRY_CLERK1_ID\"}")"
if [[ "$(jq -er '.success' <<<"$gen_json")" != "true" ]]; then
fail "generate pre-assigned cover sheet"
return
fi
assign_code="$(jq -r '.data[0].code' <<<"$gen_json")"
pdf="$(unique_pdf_path assign)"
upload_json="$(upload_with_cover_sheet "$INTAKE_TOKEN" "$assign_code" "$pdf")"
rm -f "$pdf"
status="$(extract_data_field "$upload_json" status)"
entered_by="$(extract_data_field "$upload_json" enteredByUserId)"
if [[ "$status" == "IN_ENTRY" ]]; then
pass "pre-assigned upload transitions batch to IN_ENTRY"
else
fail "pre-assigned upload transitions batch to IN_ENTRY (status=$status)"
fi
if [[ "$entered_by" == "$ENTRY_CLERK1_ID" ]]; then
pass "pre-assigned upload sets enteredByUserId to entry clerk"
else
fail "pre-assigned upload sets enteredByUserId to entry clerk (got $entered_by)"
fi
}
test_patient_linked_cover_sheet() {
section "8. Patient-linked cover sheet populates batch patient"
if [[ -z "$PATIENT1_ID" ]]; then
fail "patient-linked test skipped — no patient ID"
return
fi
local gen_json patient_code pdf upload_json patient_id
gen_json="$(generate_cover_sheets "$INTAKE_TOKEN" \
"{\"count\":1,\"batchType\":\"MEDICATION_LIST\",\"track\":\"BACKFILL\",\"patientId\":\"$PATIENT1_ID\"}")"
if [[ "$(jq -er '.success' <<<"$gen_json")" != "true" ]]; then
fail "generate patient-linked cover sheet"
return
fi
patient_code="$(jq -r '.data[0].code' <<<"$gen_json")"
pdf="$(unique_pdf_path patient)"
upload_json="$(upload_with_cover_sheet "$INTAKE_TOKEN" "$patient_code" "$pdf")"
rm -f "$pdf"
patient_id="$(extract_data_field "$upload_json" patientId)"
if [[ "$patient_id" == "$PATIENT1_ID" ]]; then
pass "patient-linked cover sheet sets batch patientId"
else
fail "patient-linked cover sheet sets batch patientId (got $patient_id)"
fi
}
test_integration_tests() {
section "9. dotnet integration tests — CoverSheetBatchTests, CoverSheetPdfTests"
if [[ "$SKIP_TEST_CHECKS" == "1" ]]; then
log " SKIP: dotnet integration tests (VIGILCARE_SKIP_TEST_CHECKS=1)"
return
fi
if ! command -v dotnet >/dev/null 2>&1; then
log " SKIP: dotnet not installed"
return
fi
if dotnet test "$REPO_ROOT/VigilCareRecordsAPI.Tests/VigilCareRecordsAPI.Tests.csproj" \
--filter "FullyQualifiedName~CoverSheet" \
--no-restore >/tmp/vigilcare-p10-tests.log 2>&1; then
pass "CoverSheet integration tests passed"
else
fail "CoverSheet integration tests passed"
log " see /tmp/vigilcare-p10-tests.log"
fi
}
print_manual_ui_checklist() {
section "10. Manual Vue UI checks (plan §5)"
log " Login as intake1 → /cover-sheets"
log " - Generate 5 VITALS_SHEET covers"
log " - Print Cover Sheets opens PDF in new tab"
log " - Cover sheet list shows unused sheets"
log " Navigate to /intake"
log " - Scan/type a cover sheet code (Enter triggers lookup)"
log " - Lookup auto-fills batch type, track, patient"
log " - Upload with Cover Sheet marks sheet as Used with linked batch ID"
}
main() {
require_cmd curl
require_cmd jq
ensure_fixture_pdf
log "VigilCare Records — Phase 10 verification"
log "API: $API_URL"
assert_api_reachable
test_authentication
test_database_schema
test_cover_sheet_generation
test_cover_sheet_lookup_and_list
test_pdf_generation
test_barcode_assisted_upload
test_cover_sheet_reuse_and_unknown
test_auto_assign_cover_sheet
test_patient_linked_cover_sheet
test_integration_tests
print_manual_ui_checklist
log ""
log "Results: $PASS_COUNT passed, $FAIL_COUNT failed"
if (( FAIL_COUNT > 0 )); then
log "Failed checks:"
for item in "${FAILED_TESTS[@]}"; do
log " - $item"
done
exit 1
fi
log "All Phase 10 API verification checks passed."
log "Complete the manual Vue UI checklist above if not already done."
}
main "$@"
@@ -0,0 +1,664 @@
#!/usr/bin/env bash
# Runs Phase 11 verification checks from docs/plans/phase-11-plan.md.
#
# Covers FHIR metadata, Patient/Encounter/Observation read & search,
# LOINC mapping, $everything, content-type negotiation, and 404 OperationOutcome.
#
# Prerequisites:
# docker compose up -d (PostgreSQL, Redis, MinIO)
# dotnet ef database update --project VigilCareRecordsAPI
# dotnet run --project VigilCareRecordsAPI
# Phase 110 seed data (admin1)
#
# Environment overrides:
# VIGILCARE_API_URL default: http://localhost:5217
# VIGILCARE_COMPOSE_FILE default: <repo>/docker-compose.yml
# VIGILCARE_PG_HOST default: localhost
# VIGILCARE_PG_PORT default: 5437
# VIGILCARE_PG_DB default: vigilcare_records
# VIGILCARE_PG_USER default: postgres
# VIGILCARE_PG_PASSWORD default: password
# VIGILCARE_SKIP_DB_CHECKS set to 1 to skip PostgreSQL seed/assertions
#
# Usage:
# chmod +x scripts/run-vigilcare-records-phase-11-verification.sh
# ./scripts/run-vigilcare-records-phase-11-verification.sh
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
API_URL="${VIGILCARE_API_URL:-http://localhost:5217}"
FHIR_URL="${API_URL}/fhir"
COMPOSE_FILE="${VIGILCARE_COMPOSE_FILE:-$REPO_ROOT/docker-compose.yml}"
COMPOSE=(docker compose -f "$COMPOSE_FILE")
PG_HOST="${VIGILCARE_PG_HOST:-localhost}"
PG_PORT="${VIGILCARE_PG_PORT:-5437}"
PG_DB="${VIGILCARE_PG_DB:-vigilcare_records}"
PG_USER="${VIGILCARE_PG_USER:-postgres}"
PG_PASSWORD="${VIGILCARE_PG_PASSWORD:-password}"
SKIP_DB_CHECKS="${VIGILCARE_SKIP_DB_CHECKS:-0}"
# Deterministic clinical IDs (match FhirClinicalSeedHelper)
PATIENT1_ID="b1000000-0000-0000-0000-000000000001"
PATIENT2_ID="b1000000-0000-0000-0000-000000000002"
ENCOUNTER1_ID="d1000000-0000-0000-0000-000000000001"
HEART_RATE_OBS_ID="e1000000-0000-0000-0000-000000000001"
WBC_OBS_ID="e1000000-0000-0000-0000-000000000002"
BATCH1_ID="c1000000-0000-0000-0000-000000000001"
ADMIN_TOKEN=""
RESOLVED_PATIENT_ID=""
RESOLVED_ENCOUNTER_ID=""
RESOLVED_HEART_RATE_OBS_ID=""
PASS_COUNT=0
FAIL_COUNT=0
FAILED_TESTS=()
log() {
printf '%s\n' "$*"
}
section() {
log ""
log "== $1 =="
}
pass() {
PASS_COUNT=$((PASS_COUNT + 1))
log " PASS: $1"
}
fail() {
FAIL_COUNT=$((FAIL_COUNT + 1))
FAILED_TESTS+=("$1")
log " FAIL: $1"
}
require_cmd() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
log "ERROR: required command not found: $cmd"
exit 1
fi
}
http_code() {
curl -sS -o /dev/null -w '%{http_code}' "$@"
}
json_post() {
local url="$1"
local body="$2"
curl -sS -X POST "$url" \
-H 'Content-Type: application/json' \
-d "$body"
}
fhir_get() {
local path_query="$1"
local token="${2:-}"
if [[ -n "$token" ]]; then
curl -sS "${FHIR_URL}${path_query}" \
-H "Authorization: Bearer $token" \
-H 'Accept: application/fhir+json'
else
curl -sS "${FHIR_URL}${path_query}" \
-H 'Accept: application/fhir+json'
fi
}
fhir_get_status() {
local path_query="$1"
local token="$2"
local body_file http_status
body_file="$(mktemp)"
http_status="$(curl -sS -o "$body_file" -w '%{http_code}' \
"${FHIR_URL}${path_query}" \
-H "Authorization: Bearer $token" \
-H 'Accept: application/fhir+json')"
cat "$body_file"
rm -f "$body_file"
printf '\n__HTTP_STATUS__:%s' "$http_status"
}
assert_api_reachable() {
local code
code="$(http_code "$API_URL/swagger/index.html" || true)"
if [[ "$code" != "200" ]]; then
log "ERROR: API not reachable at $API_URL (HTTP $code)."
log "Start infrastructure with: docker compose up -d"
log "Apply migrations with: dotnet ef database update --project VigilCareRecordsAPI"
log "Start API with: dotnet run --project VigilCareRecordsAPI"
exit 1
fi
}
compose_service_running() {
local service="$1"
"${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -qx "$service"
}
psql_available() {
[[ "$SKIP_DB_CHECKS" == "1" ]] && return 1
compose_service_running postgres && return 0
command -v psql >/dev/null 2>&1 && return 0
return 1
}
psql_query() {
if [[ "$SKIP_DB_CHECKS" == "1" ]]; then
return 1
fi
if compose_service_running postgres; then
"${COMPOSE[@]}" exec -T postgres \
psql -U "$PG_USER" -d "$PG_DB" -Atqc "$1"
elif command -v psql >/dev/null 2>&1; then
PGPASSWORD="$PG_PASSWORD" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -Atqc "$1"
else
return 1
fi
}
psql_exec() {
if [[ "$SKIP_DB_CHECKS" == "1" ]]; then
return 1
fi
if compose_service_running postgres; then
"${COMPOSE[@]}" exec -T postgres \
psql -U "$PG_USER" -d "$PG_DB" -v ON_ERROR_STOP=1 -q -c "$1"
elif command -v psql >/dev/null 2>&1; then
PGPASSWORD="$PG_PASSWORD" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \
-v ON_ERROR_STOP=1 -q -c "$1"
else
return 1
fi
}
test_authentication() {
section "0. Authentication"
local admin_json
admin_json="$(json_post "$API_URL/api/v1/auth/login" \
'{"username":"admin1","password":"password"}')"
ADMIN_TOKEN="$(jq -er '.data.token // empty' <<<"$admin_json" 2>/dev/null || true)"
if [[ -n "$ADMIN_TOKEN" ]]; then
pass "admin1 login returns JWT"
else
log "ERROR: admin1 login failed."
log "Response: ${admin_json:-<empty>}"
exit 1
fi
}
verification_fixture_ready() {
local heart_rate_count
heart_rate_count="$(psql_query "
SELECT count(*) FROM clinical.observations o
JOIN clinical.patients p ON p.id = o.patient_id
WHERE p.mrn = 'VCR-000001' AND o.observation_code = 'HEART_RATE';
" 2>/dev/null || echo "0")"
[[ "${heart_rate_count:-0}" -ge 1 ]]
}
seed_verification_fixture() {
local patient_id encounter_id batch_id heart_rate_count
patient_id="$(psql_query "SELECT id FROM clinical.patients WHERE mrn = 'VCR-000001' LIMIT 1;" 2>/dev/null || true)"
if [[ -z "$patient_id" ]]; then
psql_exec "
INSERT INTO clinical.patients (
id, mrn, full_name, date_of_birth, sex, blood_type,
emergency_contact, allergies_json, no_known_allergies, created_at, updated_at
) VALUES
('${PATIENT1_ID}', 'VCR-000001', 'MARIA SANTOS', '1978-03-15', 'female', 'A+',
'Juan Santos - 555-0101', '[\"Penicillin\", \"Sulfa drugs\"]', false,
NOW() - interval '3 days', NOW() - interval '12 hours'),
('${PATIENT2_ID}', 'VCR-000002', 'KENJI NAKAMURA', '1952-11-08', 'male', 'O-',
'Yuki Nakamura - 555-0202', NULL, true,
NOW() - interval '1 day', NOW() - interval '1 day')
ON CONFLICT (id) DO NOTHING;
" || return 1
patient_id="$PATIENT1_ID"
else
psql_exec "
UPDATE clinical.patients SET
full_name = 'MARIA SANTOS',
date_of_birth = '1978-03-15',
sex = 'female',
blood_type = 'A+',
emergency_contact = 'Juan Santos - 555-0101',
allergies_json = '[\"Penicillin\", \"Sulfa drugs\"]',
no_known_allergies = false,
updated_at = NOW()
WHERE id = '${patient_id}';
" || return 1
fi
encounter_id="$(psql_query "
SELECT id FROM clinical.encounters
WHERE patient_id = '${patient_id}'
ORDER BY created_at
LIMIT 1;
" 2>/dev/null || true)"
if [[ -z "$encounter_id" ]]; then
batch_id="$(psql_query "SELECT id FROM digitization_batches ORDER BY created_at LIMIT 1;" 2>/dev/null || true)"
batch_id="${batch_id:-$BATCH1_ID}"
psql_exec "
INSERT INTO clinical.encounters (
id, patient_id, admission_date, department, room_bed, admission_reason,
status, source_batch_id, created_at, updated_at
) VALUES (
'${ENCOUNTER1_ID}', '${patient_id}', NOW() - interval '5 days',
'Internal Medicine', '2A-04', 'Pneumonia with elevated WBC',
'active', '${batch_id}', NOW() - interval '3 days', NOW() - interval '12 hours'
)
ON CONFLICT (id) DO NOTHING;
" || return 1
encounter_id="$ENCOUNTER1_ID"
fi
batch_id="$(psql_query "
SELECT source_batch_id FROM clinical.encounters
WHERE id = '${encounter_id}'
LIMIT 1;
" 2>/dev/null || true)"
batch_id="${batch_id:-$BATCH1_ID}"
heart_rate_count="$(psql_query "
SELECT count(*) FROM clinical.observations
WHERE patient_id = '${patient_id}' AND observation_code = 'HEART_RATE';
" 2>/dev/null || echo "0")"
if [[ "${heart_rate_count:-0}" -lt 1 ]]; then
psql_exec "
INSERT INTO clinical.observations (
id, encounter_id, patient_id, observation_code, value, unit,
recorded_at, source, source_batch_id, created_at
) VALUES
('${HEART_RATE_OBS_ID}', '${encounter_id}', '${patient_id}',
'HEART_RATE', 88.000, 'bpm', NOW() - interval '5 days',
'digitization_backfill', '${batch_id}', NOW() - interval '12 hours'),
('${WBC_OBS_ID}', '${encounter_id}', '${patient_id}',
'WBC_K_UL', 14.200, 'K/uL', NOW() - interval '5 days',
'digitization_backfill', '${batch_id}', NOW() - interval '12 hours')
ON CONFLICT (id) DO NOTHING;
" || return 1
fi
return 0
}
ensure_fhir_clinical_seed() {
section "1. Clinical seed data for FHIR endpoints"
if ! psql_available; then
log " SKIP: PostgreSQL checks (set VIGILCARE_SKIP_DB_CHECKS=0 and start postgres)"
log " NOTE: FHIR curl tests require clinical.patients with MRN VCR-000001"
log " Promote a batch or run with postgres available to auto-seed."
return
fi
if verification_fixture_ready; then
pass "FHIR verification fixture (VCR-000001 + HEART_RATE) already present"
return
fi
log " Ensuring FHIR verification fixture for VCR-000001..."
if seed_verification_fixture; then
if verification_fixture_ready; then
pass "FHIR verification fixture ready for VCR-000001"
else
fail "FHIR verification fixture ready for VCR-000001"
fi
else
fail "seed FHIR verification fixture for VCR-000001"
fi
}
test_fhir_metadata() {
section "2. FHIR metadata — GET /fhir/metadata"
local metadata types
metadata="$(fhir_get '/metadata')"
types="$(jq -r '.rest[0].resource[].type' <<<"$metadata" 2>/dev/null | sort | tr '\n' ' ')"
if jq -e '.resourceType == "CapabilityStatement"' <<<"$metadata" >/dev/null 2>&1; then
pass "metadata returns CapabilityStatement"
else
fail "metadata returns CapabilityStatement"
fi
if grep -q 'Patient' <<<"$types" && grep -q 'Encounter' <<<"$types" && grep -q 'Observation' <<<"$types"; then
pass "metadata lists Patient, Encounter, Observation resources"
else
fail "metadata lists Patient, Encounter, Observation resources (got: $types)"
fi
}
resolve_clinical_ids() {
section "3. Resolve FHIR clinical resource IDs"
local search_json encounter_search obs_search
if psql_available; then
RESOLVED_PATIENT_ID="$(psql_query "
SELECT id FROM clinical.patients WHERE mrn = 'VCR-000001' LIMIT 1;
" 2>/dev/null || true)"
if [[ -n "$RESOLVED_PATIENT_ID" ]]; then
RESOLVED_ENCOUNTER_ID="$(psql_query "
SELECT id FROM clinical.encounters
WHERE patient_id = '${RESOLVED_PATIENT_ID}'
ORDER BY created_at
LIMIT 1;
" 2>/dev/null || true)"
RESOLVED_HEART_RATE_OBS_ID="$(psql_query "
SELECT id FROM clinical.observations
WHERE patient_id = '${RESOLVED_PATIENT_ID}'
AND observation_code = 'HEART_RATE'
ORDER BY recorded_at DESC
LIMIT 1;
" 2>/dev/null || true)"
fi
fi
if [[ -z "$RESOLVED_PATIENT_ID" ]]; then
search_json="$(fhir_get "/Patient?identifier=VCR-000001" "$ADMIN_TOKEN")"
RESOLVED_PATIENT_ID="$(jq -er '.entry[0].resource.id // empty' <<<"$search_json" 2>/dev/null || true)"
fi
if [[ -z "$RESOLVED_PATIENT_ID" ]]; then
search_json="$(fhir_get "/Patient?name=Santos" "$ADMIN_TOKEN")"
RESOLVED_PATIENT_ID="$(jq -er '.entry[0].resource.id // empty' <<<"$search_json" 2>/dev/null || true)"
fi
if [[ -z "$RESOLVED_ENCOUNTER_ID" && -n "$RESOLVED_PATIENT_ID" ]]; then
encounter_search="$(fhir_get "/Encounter?patient=${RESOLVED_PATIENT_ID}" "$ADMIN_TOKEN")"
RESOLVED_ENCOUNTER_ID="$(jq -er '.entry[0].resource.id // empty' <<<"$encounter_search" 2>/dev/null || true)"
fi
if [[ -z "$RESOLVED_HEART_RATE_OBS_ID" && -n "$RESOLVED_PATIENT_ID" ]]; then
obs_search="$(fhir_get "/Observation?patient=${RESOLVED_PATIENT_ID}&code=8867-4" "$ADMIN_TOKEN")"
RESOLVED_HEART_RATE_OBS_ID="$(jq -er '.entry[0].resource.id // empty' <<<"$obs_search" 2>/dev/null || true)"
fi
if [[ -n "$RESOLVED_PATIENT_ID" ]]; then
pass "resolved patient ID ($RESOLVED_PATIENT_ID)"
else
fail "resolved patient ID"
fi
if [[ -n "$RESOLVED_ENCOUNTER_ID" ]]; then
pass "resolved encounter ID ($RESOLVED_ENCOUNTER_ID)"
else
fail "resolved encounter ID"
fi
if [[ -n "$RESOLVED_HEART_RATE_OBS_ID" ]]; then
pass "resolved HEART_RATE observation ID ($RESOLVED_HEART_RATE_OBS_ID)"
else
fail "resolved HEART_RATE observation ID"
fi
}
test_fhir_patient_read() {
section "4. FHIR Patient read & search"
local patient_json mrn gender birth_date identifier_json
patient_json="$(fhir_get "/Patient/${RESOLVED_PATIENT_ID}" "$ADMIN_TOKEN")"
if jq -e '.resourceType == "Patient"' <<<"$patient_json" >/dev/null 2>&1; then
pass "GET /fhir/Patient/{id} returns Patient resource"
else
fail "GET /fhir/Patient/{id} returns Patient resource"
fi
mrn="$(jq -er '.identifier[0].value // empty' <<<"$patient_json" 2>/dev/null || true)"
if [[ "$mrn" == "VCR-000001" ]]; then
pass "Patient read includes MRN identifier VCR-000001"
else
fail "Patient read includes MRN identifier VCR-000001 (got: ${mrn:-<empty>})"
fi
gender="$(jq -er '.gender // empty' <<<"$patient_json" 2>/dev/null || true)"
birth_date="$(jq -er '.birthDate // empty' <<<"$patient_json" 2>/dev/null || true)"
if [[ "$gender" == "female" && "$birth_date" == "1978-03-15" ]]; then
pass "Patient read includes gender and birthDate"
else
fail "Patient read includes gender and birthDate (gender=$gender birthDate=$birth_date)"
fi
identifier_json="$(fhir_get "/Patient?identifier=VCR-000001" "$ADMIN_TOKEN")"
if [[ "$(jq -er '.total // 0' <<<"$identifier_json")" -ge 1 ]]; then
pass "GET /fhir/Patient?identifier=VCR-000001 returns matches"
else
fail "GET /fhir/Patient?identifier=VCR-000001 returns matches"
fi
}
test_fhir_encounter() {
section "5. FHIR Encounter read & search"
local encounter_json status subject patient_search
encounter_json="$(fhir_get "/Encounter/${RESOLVED_ENCOUNTER_ID}" "$ADMIN_TOKEN")"
if jq -e '.resourceType == "Encounter"' <<<"$encounter_json" >/dev/null 2>&1; then
pass "GET /fhir/Encounter/{id} returns Encounter resource"
else
fail "GET /fhir/Encounter/{id} returns Encounter resource"
fi
status="$(jq -er '.status // empty' <<<"$encounter_json" 2>/dev/null || true)"
subject="$(jq -er '.subject.reference // empty' <<<"$encounter_json" 2>/dev/null || true)"
if [[ "$status" == "in-progress" && "$subject" == "Patient/${RESOLVED_PATIENT_ID}" ]]; then
pass "Encounter read has in-progress status and patient reference"
else
fail "Encounter read has in-progress status and patient reference"
fi
patient_search="$(fhir_get "/Encounter?patient=${RESOLVED_PATIENT_ID}" "$ADMIN_TOKEN")"
if [[ "$(jq -er '.total // 0' <<<"$patient_search")" -ge 1 ]]; then
pass "GET /fhir/Encounter?patient={id} returns matches"
else
fail "GET /fhir/Encounter?patient={id} returns matches"
fi
}
test_fhir_observation() {
section "6. FHIR Observation read, LOINC search, category & date"
local obs_json loinc_code loinc_unit loinc_search category_search date_search date_filter
obs_json="$(fhir_get "/Observation/${RESOLVED_HEART_RATE_OBS_ID}" "$ADMIN_TOKEN")"
if jq -e '.resourceType == "Observation"' <<<"$obs_json" >/dev/null 2>&1; then
pass "GET /fhir/Observation/{id} returns Observation resource"
else
fail "GET /fhir/Observation/{id} returns Observation resource"
fi
loinc_code="$(jq -er '.code.coding[0].code // empty' <<<"$obs_json" 2>/dev/null || true)"
loinc_unit="$(jq -er '.valueQuantity.unit // empty' <<<"$obs_json" 2>/dev/null || true)"
if [[ "$loinc_code" == "8867-4" && "$loinc_unit" == "bpm" ]] \
&& jq -e '.valueQuantity.value == 88' <<<"$obs_json" >/dev/null 2>&1; then
pass "Observation read maps HEART_RATE to LOINC 8867-4 with value 88 bpm"
else
fail "Observation read maps HEART_RATE to LOINC 8867-4 with value 88 bpm"
fi
loinc_search="$(fhir_get "/Observation?patient=${RESOLVED_PATIENT_ID}&code=8867-4" "$ADMIN_TOKEN")"
if [[ "$(jq -er '.entry | length' <<<"$loinc_search" 2>/dev/null || echo 0)" -ge 1 ]]; then
pass "GET /fhir/Observation?code=8867-4 resolves LOINC to HEART_RATE"
else
fail "GET /fhir/Observation?code=8867-4 resolves LOINC to HEART_RATE"
fi
category_search="$(fhir_get "/Observation?patient=${RESOLVED_PATIENT_ID}&category=vital-signs" "$ADMIN_TOKEN")"
if [[ "$(jq -er '.entry | length' <<<"$category_search" 2>/dev/null || echo 0)" -ge 1 ]]; then
pass "GET /fhir/Observation?category=vital-signs returns vital sign observations"
else
fail "GET /fhir/Observation?category=vital-signs returns vital sign observations"
fi
date_filter="$(jq -er '.effectiveDateTime // empty' <<<"$obs_json" 2>/dev/null | cut -c1-10 || true)"
if [[ -n "$date_filter" ]]; then
date_filter="$(date -d "${date_filter} - 1 day" +%Y-%m-%d 2>/dev/null || echo "2026-06-20")"
else
date_filter="2026-06-20"
fi
date_search="$(fhir_get "/Observation?patient=${RESOLVED_PATIENT_ID}&date=ge${date_filter}" "$ADMIN_TOKEN")"
if [[ "$(jq -er '.total // 0' <<<"$date_search" 2>/dev/null || echo 0)" -ge 1 ]]; then
pass "GET /fhir/Observation?date=ge${date_filter} filters by recordedAt"
else
fail "GET /fhir/Observation?date=ge${date_filter} filters by recordedAt"
fi
}
test_fhir_patient_everything() {
section "7. FHIR Patient \$everything"
local bundle_json total resource_types has_patient has_encounter has_observation
bundle_json="$(fhir_get "/Patient/${RESOLVED_PATIENT_ID}/\$everything" "$ADMIN_TOKEN")"
total="$(jq -er '.total // 0' <<<"$bundle_json" 2>/dev/null || echo 0)"
resource_types="$(jq -r '[.entry[]?.resource.resourceType] | join(",")' <<<"$bundle_json" 2>/dev/null || true)"
if [[ "$total" -gt 0 ]]; then
pass "GET /fhir/Patient/{id}/\$everything returns non-empty Bundle"
else
fail "GET /fhir/Patient/{id}/\$everything returns non-empty Bundle"
fi
has_patient="$(grep -c 'Patient' <<<"$resource_types" || true)"
has_encounter="$(grep -c 'Encounter' <<<"$resource_types" || true)"
has_observation="$(grep -c 'Observation' <<<"$resource_types" || true)"
if [[ "$has_patient" -ge 1 && "$has_encounter" -ge 1 && "$has_observation" -ge 1 ]]; then
pass "\$everything Bundle contains Patient, Encounter, and Observation"
else
fail "\$everything Bundle contains Patient, Encounter, and Observation (types: $resource_types)"
fi
}
test_fhir_content_type() {
section "8. Content-Type negotiation"
local content_type
content_type="$(curl -sS -o /dev/null -w '%{content_type}' \
"${FHIR_URL}/Patient/${RESOLVED_PATIENT_ID}" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Accept: application/fhir+json')"
if [[ "$content_type" == application/fhir+json* ]]; then
pass "Patient read Content-Type is application/fhir+json"
else
fail "Patient read Content-Type is application/fhir+json (got: $content_type)"
fi
}
test_fhir_error_handling() {
section "9. FHIR error handling — 404 OperationOutcome"
local response http_status issue_code issue_severity
response="$(fhir_get_status "/Patient/00000000-0000-0000-0000-000000000000" "$ADMIN_TOKEN")"
http_status="${response##*__HTTP_STATUS__:}"
response="${response%__HTTP_STATUS__:*}"
issue_code="$(jq -er '.issue[0].code // empty' <<<"$response" 2>/dev/null || true)"
issue_severity="$(jq -er '.issue[0].severity // empty' <<<"$response" 2>/dev/null || true)"
if [[ "$http_status" == "404" ]]; then
pass "unknown Patient returns HTTP 404"
else
fail "unknown Patient returns HTTP 404 (got HTTP $http_status)"
fi
if [[ "$issue_code" == "not-found" && "$issue_severity" == "error" ]]; then
pass "404 response is OperationOutcome with not-found issue"
else
fail "404 response is OperationOutcome with not-found issue"
fi
}
test_bundle_pagination() {
section "10. Bundle pagination links"
local bundle_json
bundle_json="$(fhir_get "/Patient?_count=1&_offset=0" "$ADMIN_TOKEN")"
if [[ "$(jq -er '.total // 0' <<<"$bundle_json")" -ge 2 ]]; then
pass "Patient search total >= 2 for pagination test"
else
fail "Patient search total >= 2 for pagination test"
return
fi
if jq -e '.link[] | select(.relation == "self")' <<<"$bundle_json" >/dev/null 2>&1; then
pass "search Bundle includes self link"
else
fail "search Bundle includes self link"
fi
if jq -e '.link[] | select(.relation == "next")' <<<"$bundle_json" >/dev/null 2>&1; then
pass "search Bundle includes next link"
else
fail "search Bundle includes next link"
fi
}
print_manual_ui_checklist() {
section "11. Manual Vue UI checks (plan §7)"
log " Login as admin1 → http://localhost:3028/fhir-explorer"
log " - Select Patient resource type; search by name Santos"
log " - Results table shows matching patients"
log " - Click a row to see full FHIR JSON"
log " - Patient \$everything: select patient, Load All Data"
log " - Grouped Patient summary, Encounters list, Observations timeline"
log " - Open CapabilityStatement link opens metadata JSON in new tab"
}
main() {
require_cmd curl
require_cmd jq
log "VigilCare Records — Phase 11 verification"
log "API: $API_URL"
log "FHIR: $FHIR_URL"
assert_api_reachable
test_authentication
ensure_fhir_clinical_seed
test_fhir_metadata
resolve_clinical_ids
test_fhir_patient_read
test_fhir_encounter
test_fhir_observation
test_fhir_patient_everything
test_fhir_content_type
test_fhir_error_handling
test_bundle_pagination
print_manual_ui_checklist
log ""
log "Results: $PASS_COUNT passed, $FAIL_COUNT failed"
if (( FAIL_COUNT > 0 )); then
log "Failed checks:"
for item in "${FAILED_TESTS[@]}"; do
log " - $item"
done
exit 1
fi
log "All Phase 11 API verification checks passed."
log "Complete the manual Vue FHIR Explorer checklist above if not already done."
}
main "$@"
@@ -0,0 +1,670 @@
#!/usr/bin/env bash
# Runs Phase 13 verification checks from docs/plans/phase-13-plan.md.
#
# Covers OCR schema/config, draft ocrConfidence API field, manual-entry
# non-interference, and optional live OCR polling when the API runs with OCR enabled.
#
# Prerequisites:
# docker compose up -d (PostgreSQL, Redis, MinIO)
# dotnet ef database update --project VigilCareRecordsAPI
# dotnet run --project VigilCareRecordsAPI (Ocr:Enabled=false by default)
# Phase 112 seed data (intake1, entry1)
#
# Optional live OCR checks (plan §46):
# Restart API with OCR enabled, e.g.:
# Ocr__Enabled=true Ocr__Provider=tesseract dotnet run --project VigilCareRecordsAPI
# Ensure Tesseract data is installed (e.g. /usr/share/tessdata/eng.traineddata)
# VIGILCARE_OCR_LIVE=1 ./scripts/run-vigilcare-records-phase-13-verification.sh
#
# Environment overrides:
# VIGILCARE_API_URL default: http://localhost:5217
# VIGILCARE_COMPOSE_FILE default: <repo>/docker-compose.yml
# VIGILCARE_PG_HOST default: localhost
# VIGILCARE_PG_PORT default: 5437
# VIGILCARE_PG_DB default: vigilcare_records
# VIGILCARE_PG_USER default: postgres
# VIGILCARE_PG_PASSWORD default: password
# VIGILCARE_SKIP_DB_CHECKS set to 1 to skip PostgreSQL assertions
# VIGILCARE_SKIP_BUILD_CHECKS set to 1 to skip dotnet build/test
# VIGILCARE_SKIP_API_CHECKS set to 1 to skip HTTP API checks
# VIGILCARE_OCR_LIVE set to 1 to run live OCR polling tests
# VIGILCARE_OCR_POLL_WAIT_SEC default: 20 (should exceed Ocr:PollIntervalSeconds)
#
# Usage:
# chmod +x scripts/run-vigilcare-records-phase-13-verification.sh
# ./scripts/run-vigilcare-records-phase-13-verification.sh
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
FIXTURE_PDF="$SCRIPT_DIR/fixtures/test-scan.pdf"
APPSETTINGS="$REPO_ROOT/VigilCareRecordsAPI/appsettings.json"
API_URL="${VIGILCARE_API_URL:-http://localhost:5217}"
COMPOSE_FILE="${VIGILCARE_COMPOSE_FILE:-$REPO_ROOT/docker-compose.yml}"
COMPOSE=(docker compose -f "$COMPOSE_FILE")
PG_HOST="${VIGILCARE_PG_HOST:-localhost}"
PG_PORT="${VIGILCARE_PG_PORT:-5437}"
PG_DB="${VIGILCARE_PG_DB:-vigilcare_records}"
PG_USER="${VIGILCARE_PG_USER:-postgres}"
PG_PASSWORD="${VIGILCARE_PG_PASSWORD:-password}"
SKIP_DB_CHECKS="${VIGILCARE_SKIP_DB_CHECKS:-0}"
SKIP_BUILD_CHECKS="${VIGILCARE_SKIP_BUILD_CHECKS:-0}"
SKIP_API_CHECKS="${VIGILCARE_SKIP_API_CHECKS:-0}"
OCR_LIVE="${VIGILCARE_OCR_LIVE:-0}"
OCR_POLL_WAIT_SEC="${VIGILCARE_OCR_POLL_WAIT_SEC:-20}"
INTAKE_TOKEN=""
ENTRY_TOKEN=""
ENTRY_CLERK1_ID=""
PASS_COUNT=0
FAIL_COUNT=0
FAILED_TESTS=()
log() {
printf '%s\n' "$*"
}
section() {
log ""
log "== $1 =="
}
pass() {
PASS_COUNT=$((PASS_COUNT + 1))
log " PASS: $1"
}
fail() {
FAIL_COUNT=$((FAIL_COUNT + 1))
FAILED_TESTS+=("$1")
log " FAIL: $1"
}
require_cmd() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
log "ERROR: required command not found: $cmd"
exit 1
fi
}
ensure_fixture_pdf() {
if [[ -f "$FIXTURE_PDF" ]]; then
return 0
fi
mkdir -p "$(dirname "$FIXTURE_PDF")"
cat >"$FIXTURE_PDF" <<'EOF'
%PDF-1.0
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R/Resources<<>>>>endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
trailer<</Size 4/Root 1 0 R>>
startxref
206
%%EOF
EOF
}
unique_pdf_path() {
local suffix="$1"
local path="/tmp/vigilcare-p13-${suffix}-${RANDOM}.pdf"
cp "$FIXTURE_PDF" "$path"
printf '%s' "$path"
}
http_code() {
curl -sS -o /dev/null -w '%{http_code}' "$@"
}
json_post() {
local url="$1"
local body="$2"
local token="${3:-}"
if [[ -n "$token" ]]; then
curl -sS -X POST "$url" \
-H "Authorization: Bearer $token" \
-H 'Content-Type: application/json' \
-d "$body"
else
curl -sS -X POST "$url" \
-H 'Content-Type: application/json' \
-d "$body"
fi
}
json_get() {
local url="$1"
local token="$2"
curl -sS "$url" -H "Authorization: Bearer $token"
}
json_put() {
local url="$1"
local body="$2"
local token="$3"
curl -sS -X PUT "$url" \
-H "Authorization: Bearer $token" \
-H 'Content-Type: application/json' \
-d "$body"
}
login() {
local username="$1"
local password="${2:-password}"
json_post "$API_URL/api/v1/auth/login" \
"{\"username\":\"$username\",\"password\":\"$password\"}"
}
extract_data_field() {
local json="$1"
local field="$2"
jq -er ".data.$field // empty" <<<"$json"
}
assert_api_reachable() {
local code
code="$(http_code "$API_URL/swagger/index.html" || true)"
if [[ "$code" != "200" ]]; then
log "ERROR: API not reachable at $API_URL (HTTP $code)."
log "Start infrastructure with: docker compose up -d"
log "Apply migrations with: dotnet ef database update --project VigilCareRecordsAPI"
log "Start API with: dotnet run --project VigilCareRecordsAPI"
exit 1
fi
}
compose_service_running() {
local service="$1"
"${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -qx "$service"
}
psql_available() {
[[ "$SKIP_DB_CHECKS" == "1" ]] && return 1
compose_service_running postgres && return 0
command -v psql >/dev/null 2>&1 && return 0
return 1
}
psql_query() {
if [[ "$SKIP_DB_CHECKS" == "1" ]]; then
return 1
fi
if compose_service_running postgres; then
"${COMPOSE[@]}" exec -T postgres \
psql -U "$PG_USER" -d "$PG_DB" -Atqc "$1"
elif command -v psql >/dev/null 2>&1; then
PGPASSWORD="$PG_PASSWORD" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -Atqc "$1"
else
return 1
fi
}
upload_batch() {
local token="$1"
local pdf="$2"
local batch_type="${3:-VITALS_SHEET}"
local track="${4:-BACKFILL}"
curl -sS -X POST "$API_URL/api/v1/digitization-batches" \
-H "Authorization: Bearer $token" \
-F "file=@${pdf};type=application/pdf" \
-F "batchType=$batch_type" \
-F "track=$track"
}
assign_batch() {
local token="$1"
local batch_id="$2"
local entry_clerk_id="$3"
curl -sS -X PATCH "$API_URL/api/v1/digitization-batches/$batch_id/assign" \
-H "Authorization: Bearer $token" \
-H 'Content-Type: application/json' \
-d "{\"entryClerkId\":\"$entry_clerk_id\"}"
}
test_build_and_unit_tests() {
section "1. Backend compiles and tests pass (plan §1)"
if [[ "$SKIP_BUILD_CHECKS" == "1" ]]; then
log " SKIP: dotnet build/test (VIGILCARE_SKIP_BUILD_CHECKS=1)"
return
fi
if ! command -v dotnet >/dev/null 2>&1; then
fail "dotnet SDK available for build"
return
fi
if dotnet build "$REPO_ROOT/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj" \
--nologo -v q >/tmp/vigilcare-p13-build.log 2>&1; then
pass "dotnet build succeeds"
else
fail "dotnet build succeeds"
log " see /tmp/vigilcare-p13-build.log"
return
fi
if dotnet test "$REPO_ROOT/VigilCareRecordsAPI.Tests/VigilCareRecordsAPI.Tests.csproj" \
--no-build --nologo >/tmp/vigilcare-p13-tests.log 2>&1; then
pass "dotnet test passes"
else
fail "dotnet test passes"
log " see /tmp/vigilcare-p13-tests.log"
fi
}
test_database_schema() {
section "2. Migration — ocr_results and OCR event types (plan §2, §4)"
if ! psql_available; then
log " SKIP: PostgreSQL checks (set VIGILCARE_SKIP_DB_CHECKS=0 and start postgres)"
return
fi
local table_exists unique_batch constraint_ok migration_ok
table_exists="$(psql_query "
SELECT count(*)
FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'ocr_results';
")"
unique_batch="$(psql_query "
SELECT count(*)
FROM pg_indexes
WHERE tablename = 'ocr_results' AND indexdef LIKE '%UNIQUE%' AND indexdef LIKE '%batch_id%';
")"
constraint_ok="$(psql_query "
SELECT count(*)
FROM pg_constraint c
JOIN pg_class t ON c.conrelid = t.oid
WHERE t.relname = 'digitization_events'
AND c.conname = 'chk_digitization_events_event_type'
AND pg_get_constraintdef(c.oid) LIKE '%ocr_started%'
AND pg_get_constraintdef(c.oid) LIKE '%ocr_completed%'
AND pg_get_constraintdef(c.oid) LIKE '%ocr_failed%';
")"
migration_ok="$(psql_query "
SELECT count(*)
FROM public.\"__EFMigrationsHistory\"
WHERE \"MigrationId\" LIKE '%AddOcrResult%';
" 2>/dev/null || echo "0")"
if [[ "$table_exists" == "1" ]]; then
pass "ocr_results table exists"
else
fail "ocr_results table exists (run: dotnet ef database update --project VigilCareRecordsAPI)"
fi
if [[ "$unique_batch" == "1" ]]; then
pass "unique index on ocr_results.batch_id exists"
else
fail "unique index on ocr_results.batch_id exists"
fi
if [[ "$constraint_ok" == "1" ]]; then
pass "digitization_events check constraint includes OCR event types"
else
fail "digitization_events check constraint includes OCR event types"
fi
if [[ "$migration_ok" == "1" ]]; then
pass "AddOcrResult migration applied"
else
fail "AddOcrResult migration applied"
fi
}
test_ocr_disabled_by_default_config() {
section "3. OCR disabled by default — appsettings (plan §3)"
if [[ ! -f "$APPSETTINGS" ]]; then
fail "appsettings.json exists"
return
fi
local enabled provider
enabled="$(jq -er '.Ocr.Enabled' <<<"$(cat "$APPSETTINGS")")"
provider="$(jq -er '.Ocr.Provider // empty' <<<"$(cat "$APPSETTINGS")")"
if [[ "$enabled" == "false" ]]; then
pass "appsettings Ocr.Enabled is false by default"
else
fail "appsettings Ocr.Enabled is false by default (got: $enabled)"
fi
if [[ -n "$provider" ]]; then
pass "appsettings defines Ocr.Provider"
else
fail "appsettings defines Ocr.Provider"
fi
if [[ -f "$REPO_ROOT/VigilCareRecordsAPI/BackgroundServices/OcrProcessingService.cs" ]]; then
pass "OcrProcessingService source present"
else
fail "OcrProcessingService source present"
fi
if grep -q 'OcrConfidenceMap' "$REPO_ROOT/VigilCareRecordsAPI/Models/Records/Batch/DraftPayloadResponse.cs" 2>/dev/null; then
pass "DraftPayloadResponse includes OcrConfidenceMap"
else
fail "DraftPayloadResponse includes OcrConfidenceMap"
fi
}
test_authentication() {
section "4. Authentication"
local intake_json entry_json users_json
intake_json="$(login intake1)"
entry_json="$(login entry1)"
INTAKE_TOKEN="$(extract_data_field "$intake_json" token)"
ENTRY_TOKEN="$(extract_data_field "$entry_json" token)"
if [[ -n "$INTAKE_TOKEN" ]]; then
pass "intake1 login returns JWT"
else
fail "intake1 login returns JWT"
fi
if [[ -n "$ENTRY_TOKEN" ]]; then
pass "entry1 login returns JWT"
else
fail "entry1 login returns JWT"
fi
users_json="$(json_get "$API_URL/api/v1/users?role=DATA_ENTRY_CLERK" "$INTAKE_TOKEN")"
ENTRY_CLERK1_ID="$(jq -er '.data[0].id // empty' <<<"$users_json" 2>/dev/null || true)"
if [[ -n "$ENTRY_CLERK1_ID" ]]; then
pass "resolved entry clerk ID for assign test"
else
fail "resolved entry clerk ID for assign test"
fi
}
test_draft_ocr_confidence_null_when_ocr_off() {
section "5. Draft API — ocrConfidence null without OCR run (plan §3, §8)"
if [[ -z "$INTAKE_TOKEN" ]]; then
fail "draft ocrConfidence test skipped — no intake token"
return
fi
local pdf upload_json batch_id draft_json ocr_conf
pdf="$(unique_pdf_path draft-null)"
upload_json="$(upload_batch "$INTAKE_TOKEN" "$pdf")"
rm -f "$pdf"
batch_id="$(extract_data_field "$upload_json" id)"
if [[ -z "$batch_id" ]]; then
fail "upload batch for draft ocrConfidence test"
return
fi
draft_json="$(json_get "$API_URL/api/v1/digitization-batches/$batch_id/draft" "$ENTRY_TOKEN")"
if jq -e '.success == true' <<<"$draft_json" >/dev/null 2>&1; then
pass "GET /draft returns success for UPLOADED batch"
else
fail "GET /draft returns success for UPLOADED batch"
return
fi
if jq -e '.data | has("ocrConfidence")' <<<"$draft_json" >/dev/null 2>&1; then
pass "draft payload includes ocrConfidence field"
else
fail "draft payload includes ocrConfidence field"
fi
ocr_conf="$(jq -r '.data.ocrConfidence // "missing"' <<<"$draft_json")"
if [[ "$ocr_conf" == "null" || "$ocr_conf" == "missing" ]]; then
pass "ocrConfidence is null when OCR has not processed batch"
else
fail "ocrConfidence is null when OCR has not processed batch (got: $ocr_conf)"
fi
local status
status="$(extract_data_field "$upload_json" status)"
if [[ "$status" == "UPLOADED" ]]; then
pass "uploaded batch remains UPLOADED before entry"
else
fail "uploaded batch remains UPLOADED before entry (status=$status)"
fi
}
test_manual_entry_skips_ocr() {
section "6. No interference with manual entry (plan §7)"
if [[ -z "$INTAKE_TOKEN" || -z "$ENTRY_TOKEN" || -z "$ENTRY_CLERK1_ID" ]]; then
fail "manual-entry interference test skipped — missing auth IDs"
return
fi
local pdf upload_json batch_id assign_json put_json status ocr_row event_count
pdf="$(unique_pdf_path manual-entry)"
upload_json="$(upload_batch "$INTAKE_TOKEN" "$pdf")"
rm -f "$pdf"
batch_id="$(extract_data_field "$upload_json" id)"
if [[ -z "$batch_id" ]]; then
fail "upload batch for manual-entry test"
return
fi
assign_json="$(assign_batch "$INTAKE_TOKEN" "$batch_id" "$ENTRY_CLERK1_ID")"
if [[ "$(jq -er '.success // false' <<<"$assign_json")" == "true" ]]; then
pass "batch assigned to entry clerk"
else
fail "batch assigned to entry clerk"
return
fi
put_json="$(json_put \
"$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \
'{"fullName":"Manual Entry Patient","dateOfBirth":"1990-01-15","sex":"female"}' \
"$ENTRY_TOKEN")"
if [[ "$(jq -er '.success // false' <<<"$put_json")" == "true" ]]; then
pass "entry clerk saves draft patient before OCR can run"
else
fail "entry clerk saves draft patient before OCR can run"
return
fi
status="$(json_get "$API_URL/api/v1/digitization-batches/$batch_id" "$ENTRY_TOKEN" \
| jq -r '.data.status // empty')"
if [[ "$status" == "IN_ENTRY" ]]; then
pass "batch transitions to IN_ENTRY after first draft save"
else
fail "batch transitions to IN_ENTRY after first draft save (status=$status)"
fi
if psql_available; then
ocr_row="$(psql_query "
SELECT count(*) FROM ocr_results WHERE batch_id = '$batch_id';
")"
event_count="$(psql_query "
SELECT count(*)
FROM digitization_events
WHERE batch_id = '$batch_id'
AND event_type IN ('ocr_started', 'ocr_completed', 'ocr_failed');
")"
if [[ "$ocr_row" == "0" ]]; then
pass "no ocr_results row for IN_ENTRY batch"
else
fail "no ocr_results row for IN_ENTRY batch (count=$ocr_row)"
fi
if [[ "$event_count" == "0" ]]; then
pass "no OCR lifecycle events for IN_ENTRY batch"
else
fail "no OCR lifecycle events for IN_ENTRY batch (count=$event_count)"
fi
else
log " SKIP: DB assertions for manual-entry test (postgres unavailable)"
fi
}
test_live_ocr_polling() {
section "7. Live OCR polling (optional — plan §46)"
if [[ "$OCR_LIVE" != "1" ]]; then
log " SKIP: live OCR tests (set VIGILCARE_OCR_LIVE=1 and restart API with Ocr__Enabled=true)"
log " Example: Ocr__Enabled=true Ocr__Provider=tesseract dotnet run --project VigilCareRecordsAPI"
return
fi
if [[ -z "$INTAKE_TOKEN" ]]; then
fail "live OCR test skipped — no intake token"
return
fi
if ! psql_available; then
fail "live OCR test requires PostgreSQL for event/result assertions"
return
fi
local pdf upload_json batch_id draft_json provider ocr_conf event_completed event_failed
pdf="$(unique_pdf_path ocr-live)"
upload_json="$(upload_batch "$INTAKE_TOKEN" "$pdf")"
rm -f "$pdf"
batch_id="$(extract_data_field "$upload_json" id)"
if [[ -z "$batch_id" ]]; then
fail "upload batch for live OCR test"
return
fi
log " Waiting ${OCR_POLL_WAIT_SEC}s for OcrProcessingService poll cycle..."
sleep "$OCR_POLL_WAIT_SEC"
event_completed="$(psql_query "
SELECT count(*) FROM digitization_events
WHERE batch_id = '$batch_id' AND event_type = 'ocr_completed';
")"
event_failed="$(psql_query "
SELECT count(*) FROM digitization_events
WHERE batch_id = '$batch_id' AND event_type = 'ocr_failed';
")"
local ocr_started
ocr_started="$(psql_query "
SELECT count(*) FROM digitization_events
WHERE batch_id = '$batch_id' AND event_type = 'ocr_started';
")"
if [[ "$ocr_started" -ge 1 ]]; then
pass "OcrStarted event written for uploaded batch"
else
fail "OcrStarted event written for uploaded batch (is API running with Ocr__Enabled=true?)"
fi
if [[ "$event_completed" == "1" ]]; then
pass "OcrCompleted event written"
elif [[ "$event_failed" == "1" ]]; then
pass "OcrFailed event written (OCR failure is non-blocking — plan §6)"
local batch_status
batch_status="$(psql_query "
SELECT status FROM digitization_batches WHERE id = '$batch_id';
")"
if [[ "$batch_status" == "UPLOADED" ]]; then
pass "batch remains UPLOADED after OCR failure"
else
fail "batch remains UPLOADED after OCR failure (status=$batch_status)"
fi
return
else
fail "OcrCompleted or OcrFailed event written after poll wait"
return
fi
draft_json="$(json_get "$API_URL/api/v1/digitization-batches/$batch_id/draft" "$ENTRY_TOKEN")"
ocr_conf="$(jq -c '.data.ocrConfidence // null' <<<"$draft_json")"
provider="$(jq -r '.data.ocrConfidence.provider // empty' <<<"$draft_json")"
if [[ "$ocr_conf" != "null" && -n "$provider" ]]; then
pass "GET /draft returns ocrConfidence with provider ($provider)"
else
fail "GET /draft returns ocrConfidence with provider (got: $ocr_conf)"
fi
if jq -e '.data.ocrConfidence.fieldConfidences | type == "object"' <<<"$draft_json" >/dev/null 2>&1; then
pass "ocrConfidence.fieldConfidences is an object"
else
fail "ocrConfidence.fieldConfidences is an object"
fi
local ocr_row
ocr_row="$(psql_query "
SELECT count(*) FROM ocr_results WHERE batch_id = '$batch_id';
")"
if [[ "$ocr_row" == "1" ]]; then
pass "ocr_results row persisted for batch"
else
fail "ocr_results row persisted for batch"
fi
}
print_manual_ui_checklist() {
section "8. Manual Vue UI checks (plan §8)"
log " Login as entry1 → open an OCR-processed batch in the entry form"
log " - Blue OCR banner shows provider name"
log " - Pre-filled fields show green/yellow/red left border by confidence"
log " - Fields below confidence threshold remain empty"
log " Edit a pre-filled field → save → confirm draft_field_updated audit event"
log " Login as verifier → open batch in verification form"
log " - OCR banner and confidence borders visible in read-only mode"
log " OCR disabled (default): confirm no OcrProcessingService started in API logs"
log " Azure provider: restart with Ocr__Provider=azure and valid credentials (plan §5)"
log " OCR failure: restart with invalid Azure endpoint → OcrFailed logged, manual entry works (plan §6)"
}
main() {
require_cmd curl
require_cmd jq
ensure_fixture_pdf
log "VigilCare Records — Phase 13 verification (Optional OCR-Assisted Draft Pre-Fill)"
log "API: $API_URL"
log "OCR live tests: $([[ "$OCR_LIVE" == "1" ]] && echo enabled || echo disabled)"
test_build_and_unit_tests
test_database_schema
test_ocr_disabled_by_default_config
if [[ "$SKIP_API_CHECKS" == "1" ]]; then
log ""
log "SKIP: HTTP API checks (VIGILCARE_SKIP_API_CHECKS=1)"
else
assert_api_reachable
test_authentication
test_draft_ocr_confidence_null_when_ocr_off
test_manual_entry_skips_ocr
test_live_ocr_polling
fi
print_manual_ui_checklist
log ""
log "Results: $PASS_COUNT passed, $FAIL_COUNT failed"
if (( FAIL_COUNT > 0 )); then
log "Failed checks:"
for item in "${FAILED_TESTS[@]}"; do
log " - $item"
done
exit 1
fi
log "All Phase 13 automated verification checks passed."
log "Complete the manual Vue UI checklist above if not already done."
if [[ "$OCR_LIVE" != "1" ]]; then
log "For live OCR polling tests, restart the API with OCR enabled and re-run with VIGILCARE_OCR_LIVE=1."
fi
}
main "$@"
View File
View File
View File
View File
View File
View File
View File
+23
View File
@@ -0,0 +1,23 @@
# Build context is vigilcare-records-web/ (package.json, nginx.conf live here):
# docker build -f vigilcare-records-web/Dockerfile -t vigilcare-records-dashboard vigilcare-records-web
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# No VITE_API_URL build-arg needed: src/api/client.ts and src/api/fhirClient.ts
# use relative base URLs ("/api/v1", "/fhir") and rely on the nginx reverse proxy
# below to reach the api container at runtime — same origin in every environment.
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:80/ >/dev/null || exit 1
EXPOSE 80
+36
View File
@@ -0,0 +1,36 @@
# Serves the built Vue SPA and reverse-proxies API/FHIR calls to the api
# container so the frontend's relative-URL Axios clients (baseURL "/api/v1" and
# "/fhir") work unmodified in production mirrors the Vite dev server proxy in
# vite.config.ts, just pointed at the "api" service name instead of localhost.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 30m; # scanned document uploads (25 MB max) plus overhead
location /api/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /fhir/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA history-mode fallback
location / {
try_files $uri $uri/ /index.html;
}
}
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -6,7 +6,9 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
}, },
"dependencies": { "dependencies": {
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.3.0",
@@ -16,14 +18,18 @@
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@types/jsdom": "^28.0.3",
"@types/node": "^24.13.2", "@types/node": "^24.13.2",
"@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue": "^6.0.7",
"@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"autoprefixer": "^10.5.2", "autoprefixer": "^10.5.2",
"jsdom": "^29.1.1",
"postcss": "^8.5.15", "postcss": "^8.5.15",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.1.0", "vite": "^8.1.0",
"vitest": "^4.1.9",
"vue-tsc": "^3.3.5" "vue-tsc": "^3.3.5"
} }
} }
@@ -0,0 +1,343 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import EntryForm from '@/components/EntryForm.vue'
import { useBatchStore } from '@/stores/batches'
import type { BatchDetailResponse } from '@/types'
import {
emptyDraft,
fieldRequirementsForBatchType,
} from '@/__tests__/helpers/fieldRequirements'
vi.mock('@/api/client', () => ({
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
del: vi.fn(),
patch: vi.fn(),
uploadFile: vi.fn(),
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}),
}))
function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailResponse {
const batchType = overrides.batchType ?? 'VITALS'
return {
id: 'b1',
status: 'IN_ENTRY',
batchType,
track: 'TRACK_A',
fieldRequirements: fieldRequirementsForBatchType(batchType),
patientId: null,
documentRef: 'docs/scan.pdf',
documentUrl: null,
enableRetroactiveAlerts: false,
enteredByUserId: 'u1',
verifiedByUserId: null,
approvedByUserId: null,
rejectionReason: null,
promotedAt: null,
promotionEncounterId: null,
supersedesBatchId: null,
clinicianAttestation: false,
isCorrection: false,
supersession: null,
createdAt: '2026-06-27T10:00:00Z',
updatedAt: '2026-06-27T10:00:00Z',
...overrides,
}
}
function mountWithDraft(
batchOverrides: Partial<BatchDetailResponse> = {},
) {
const batch = makeBatch(batchOverrides)
const store = useBatchStore()
store.currentDraft = emptyDraft(batch.batchType)
return mount(EntryForm, {
props: { batch, batchId: 'b1' },
})
}
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('EntryForm', () => {
it('renders patient demographics fields', () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
expect(wrapper.text()).toContain('Patient Demographics')
expect(wrapper.text()).toContain('Full Name')
expect(wrapper.text()).toContain('Date of Birth')
expect(wrapper.text()).toContain('Sex')
expect(wrapper.text()).toContain('Blood Type')
expect(wrapper.text()).toContain('Emergency Contact')
})
it('renders encounter context fields', () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
expect(wrapper.text()).toContain('Encounter Context')
expect(wrapper.text()).toContain('Admission Date')
expect(wrapper.text()).toContain('Department')
expect(wrapper.text()).toContain('Room / Bed')
expect(wrapper.text()).toContain('Admission Reason')
})
it('renders observations section with add button', () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
expect(wrapper.text()).toContain('Observations')
expect(wrapper.text()).toContain('+ Add Observation')
})
it('renders submit button', () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
const submitBtn = wrapper.find('button')
const buttons = wrapper.findAll('button')
const submitButton = buttons.find((b) => b.text().includes('Submit for Verification'))
expect(submitButton).toBeTruthy()
})
it('displays batch status', () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch({ status: 'IN_ENTRY' }), batchId: 'b1' },
})
expect(wrapper.text()).toContain('IN ENTRY')
})
describe('conditional sections by batch type', () => {
it('hides allergies section for VITALS batch', () => {
const wrapper = mountWithDraft({ batchType: 'VITALS' })
expect(wrapper.text()).not.toContain('Allergies')
})
it('shows allergies section for ALLERGY_UPDATE batch', () => {
const wrapper = mountWithDraft({ batchType: 'ALLERGY_UPDATE' })
expect(wrapper.text()).toContain('Allergies')
expect(wrapper.text()).toContain('No known allergies')
})
it('hides medications section for VITALS batch', () => {
const wrapper = mountWithDraft({ batchType: 'VITALS' })
expect(wrapper.text()).not.toContain('Medications')
})
it('shows medications section for MEDICATION_LIST batch', () => {
const wrapper = mountWithDraft({ batchType: 'MEDICATION_LIST' })
expect(wrapper.text()).toContain('Medications')
expect(wrapper.text()).toContain('No active medications')
})
it('shows both allergies and medications for MIXED batch', () => {
const wrapper = mountWithDraft({ batchType: 'MIXED' })
expect(wrapper.text()).toContain('Allergies')
expect(wrapper.text()).toContain('Medications')
})
it('hides encounter summary fields for VITALS batch', () => {
const wrapper = mountWithDraft({ batchType: 'VITALS' })
expect(wrapper.text()).not.toContain('Encounter Status')
expect(wrapper.text()).not.toContain('Discharge Diagnosis')
})
it('shows encounter summary fields for ENCOUNTER_SUMMARY batch', () => {
const wrapper = mountWithDraft({ batchType: 'ENCOUNTER_SUMMARY' })
expect(wrapper.text()).toContain('Encounter Status')
expect(wrapper.text()).toContain('Discharge Diagnosis')
})
})
describe('draft loading', () => {
it('populates patient fields from draft', async () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
const store = useBatchStore()
store.currentDraft = emptyDraft('VITALS', {
patient: {
id: 'dp1',
batchId: 'b1',
fullName: 'John Doe',
dateOfBirth: '1990-05-15',
sex: 'male',
bloodType: 'O+',
emergencyContact: '555-1234',
allergies: null,
noKnownAllergies: false,
medications: null,
noActiveMedications: false,
},
encounter: null,
observations: [],
})
await wrapper.vm.$nextTick()
const nameInput = wrapper.find('input[type="text"]')
expect(nameInput.element.value).toBe('John Doe')
})
})
describe('save on blur', () => {
it('calls saveDraftPatient on patient field blur', async () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
const store = useBatchStore()
store.saveDraftPatient = vi.fn().mockResolvedValue(undefined)
const nameInput = wrapper.find('input[type="text"]')
await nameInput.setValue('Jane Doe')
await nameInput.trigger('blur')
expect(store.saveDraftPatient).toHaveBeenCalledWith(
'b1',
expect.objectContaining({ fullName: 'Jane Doe' }),
)
})
it('calls saveDraftEncounter on encounter field blur', async () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
const store = useBatchStore()
store.saveDraftEncounter = vi.fn().mockResolvedValue(undefined)
const roomInput = wrapper.findAll('input[type="text"]').find((i) => {
const label = i.element.closest('div')?.querySelector('label')
return label?.textContent?.includes('Room')
})
if (roomInput) {
await roomInput.setValue('4B-01')
await roomInput.trigger('blur')
expect(store.saveDraftEncounter).toHaveBeenCalled()
}
})
})
describe('submit for verification', () => {
it('calls submitForVerification on click', async () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
const store = useBatchStore()
store.submitForVerification = vi.fn().mockResolvedValue(undefined)
const submitBtn = wrapper.findAll('button').find((b) => b.text().includes('Submit for Verification'))
await submitBtn!.trigger('click')
await flushPromises()
expect(store.submitForVerification).toHaveBeenCalledWith('b1')
})
it('displays error message on submit failure', async () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
const store = useBatchStore()
store.submitForVerification = vi.fn().mockRejectedValue(new Error('BATCH_INCOMPLETE'))
const submitBtn = wrapper.findAll('button').find((b) => b.text().includes('Submit for Verification'))
await submitBtn!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('BATCH_INCOMPLETE')
})
it('shows submitting state on button', async () => {
let resolvePromise: () => void
const wrapper = mount(EntryForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
const store = useBatchStore()
store.submitForVerification = vi.fn().mockReturnValue(
new Promise((resolve) => {
resolvePromise = resolve
}),
)
const submitBtn = wrapper.findAll('button').find((b) => b.text().includes('Submit for Verification'))
await submitBtn!.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Submitting...')
resolvePromise!()
await flushPromises()
expect(wrapper.text()).toContain('Submit for Verification')
})
})
describe('observations', () => {
it('calls addObservation on add button click', async () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
const store = useBatchStore()
store.addObservation = vi.fn().mockResolvedValue(undefined)
const addBtn = wrapper.findAll('button').find((b) => b.text().includes('+ Add Observation'))
await addBtn!.trigger('click')
expect(store.addObservation).toHaveBeenCalledWith('b1', expect.objectContaining({
observationCode: '',
value: 0,
unit: '',
}))
})
})
describe('blood type options', () => {
it('renders all 8 blood type options plus empty', () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
const selects = wrapper.findAll('select')
const bloodTypeSelect = selects.find((s) => {
const opts = s.findAll('option')
return opts.some((o) => o.text() === 'A+')
})
expect(bloodTypeSelect).toBeTruthy()
const options = bloodTypeSelect!.findAll('option')
expect(options.length).toBe(9) // "Unknown" + 8 blood types
})
})
describe('department options', () => {
it('renders department dropdown with clinical departments', () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
const selects = wrapper.findAll('select')
const deptSelect = selects.find((s) => {
const opts = s.findAll('option')
return opts.some((o) => o.text() === 'ICU')
})
expect(deptSelect).toBeTruthy()
const options = deptSelect!.findAll('option')
expect(options.length).toBeGreaterThan(10)
})
})
})
@@ -0,0 +1,158 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ObservationRow from '@/components/ObservationRow.vue'
import type { DraftObservation } from '@/types'
function makeObservation(overrides: Partial<DraftObservation> = {}): DraftObservation {
return {
id: 'obs-1',
batchId: 'b1',
observationCode: 'HEART_RATE',
value: 72,
unit: 'bpm',
recordedAt: '2026-06-27T10:00:00Z',
note: null,
...overrides,
}
}
describe('ObservationRow', () => {
it('renders observation code options', () => {
const wrapper = mount(ObservationRow, {
props: { observation: makeObservation() },
})
const options = wrapper.findAll('select option')
const values = options.map((o) => o.element.value)
expect(values).toContain('HEART_RATE')
expect(values).toContain('TEMP_C')
expect(values).toContain('BP_SYSTOLIC')
expect(values).toContain('SPO2')
expect(values).toContain('POTASSIUM_MEQ_L')
})
it('renders observation values in inputs', () => {
const wrapper = mount(ObservationRow, {
props: { observation: makeObservation({ value: 98.6, unit: '°F' }) },
})
const numberInput = wrapper.find('input[type="number"]')
expect(numberInput.element.value).toBe('98.6')
const textInputs = wrapper.findAll('input[type="text"]')
const unitInput = textInputs[0]
expect(unitInput.element.value).toBe('°F')
})
it('emits update event on code change', async () => {
const wrapper = mount(ObservationRow, {
props: { observation: makeObservation() },
})
const select = wrapper.find('select')
await select.setValue('TEMP_C')
expect(wrapper.emitted('update')).toBeTruthy()
expect(wrapper.emitted('update')![0]).toEqual(['observationCode', 'TEMP_C'])
})
it('emits update event on value change', async () => {
const wrapper = mount(ObservationRow, {
props: { observation: makeObservation() },
})
const numberInput = wrapper.find('input[type="number"]')
await numberInput.setValue('80')
await numberInput.trigger('change')
expect(wrapper.emitted('update')).toBeTruthy()
const emitted = wrapper.emitted('update')![0]
expect(emitted[0]).toBe('value')
expect(emitted[1]).toBe(80)
})
it('shows delete button in entry mode (not readonly)', () => {
const wrapper = mount(ObservationRow, {
props: { observation: makeObservation() },
})
const deleteBtn = wrapper.find('button')
expect(deleteBtn.exists()).toBe(true)
expect(deleteBtn.text()).toBe('Remove')
})
it('emits delete event on remove click', async () => {
const wrapper = mount(ObservationRow, {
props: { observation: makeObservation({ id: 'obs-42' }) },
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('delete')).toBeTruthy()
expect(wrapper.emitted('delete')![0]).toEqual(['obs-42'])
})
it('disables inputs in readonly mode', () => {
const wrapper = mount(ObservationRow, {
props: { observation: makeObservation(), readonly: true },
})
const select = wrapper.find('select')
expect(select.element.disabled).toBe(true)
const numberInput = wrapper.find('input[type="number"]')
expect(numberInput.element.disabled).toBe(true)
})
it('hides delete button in readonly mode', () => {
const wrapper = mount(ObservationRow, {
props: { observation: makeObservation(), readonly: true },
})
const button = wrapper.find('button')
expect(button.exists()).toBe(false)
})
it('shows verification checkbox when showVerified is true', () => {
const wrapper = mount(ObservationRow, {
props: {
observation: makeObservation(),
readonly: true,
showVerified: true,
verified: false,
},
})
const checkbox = wrapper.find('input[type="checkbox"]')
expect(checkbox.exists()).toBe(true)
expect(checkbox.element.checked).toBe(false)
})
it('reflects verified state in checkbox', () => {
const wrapper = mount(ObservationRow, {
props: {
observation: makeObservation(),
readonly: true,
showVerified: true,
verified: true,
},
})
const checkbox = wrapper.find('input[type="checkbox"]')
expect(checkbox.element.checked).toBe(true)
})
it('emits verify event when checkbox is toggled', async () => {
const wrapper = mount(ObservationRow, {
props: {
observation: makeObservation({ id: 'obs-99' }),
readonly: true,
showVerified: true,
verified: false,
},
})
const checkbox = wrapper.find('input[type="checkbox"]')
await checkbox.setValue(true)
expect(wrapper.emitted('verify')).toBeTruthy()
expect(wrapper.emitted('verify')![0]).toEqual(['obs-99', true])
})
it('hides verification checkbox when showVerified is false', () => {
const wrapper = mount(ObservationRow, {
props: { observation: makeObservation(), showVerified: false },
})
const checkbox = wrapper.find('input[type="checkbox"]')
expect(checkbox.exists()).toBe(false)
})
})
@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import PatientSearch from '@/components/PatientSearch.vue'
vi.mock('@/api/client', () => ({
get: vi.fn(),
}))
import { get } from '@/api/client'
const mockedGet = vi.mocked(get)
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
describe('PatientSearch', () => {
it('renders a search input', () => {
const wrapper = mount(PatientSearch)
const input = wrapper.find('input')
expect(input.exists()).toBe(true)
expect(input.attributes('placeholder')).toContain('Search')
})
it('does not search when query is less than 2 characters', async () => {
const wrapper = mount(PatientSearch)
const input = wrapper.find('input')
await input.setValue('J')
await input.trigger('input')
vi.advanceTimersByTime(400)
expect(mockedGet).not.toHaveBeenCalled()
})
it('searches after debounce when query is 2+ characters', async () => {
mockedGet.mockResolvedValueOnce({
success: true,
statusCode: 200,
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
error: null,
})
const wrapper = mount(PatientSearch)
const input = wrapper.find('input')
await input.setValue('Jane')
await input.trigger('input')
expect(mockedGet).not.toHaveBeenCalled()
vi.advanceTimersByTime(300)
await vi.runAllTimersAsync()
expect(mockedGet).toHaveBeenCalledWith('patients/search', { q: 'Jane' })
})
it('shows results dropdown after search', async () => {
mockedGet.mockResolvedValueOnce({
success: true,
statusCode: 200,
data: [
{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' },
{ id: 'p2', fullName: 'Jane Smith', mrn: 'MRN-002' },
],
error: null,
})
const wrapper = mount(PatientSearch)
await wrapper.find('input').setValue('Jane')
await wrapper.find('input').trigger('input')
vi.advanceTimersByTime(300)
await vi.runAllTimersAsync()
await wrapper.vm.$nextTick()
const items = wrapper.findAll('li')
expect(items.length).toBe(2)
expect(items[0].text()).toContain('Jane Doe')
expect(items[0].text()).toContain('MRN-001')
})
it('emits update:modelValue on patient selection', async () => {
mockedGet.mockResolvedValueOnce({
success: true,
statusCode: 200,
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
error: null,
})
const wrapper = mount(PatientSearch)
await wrapper.find('input').setValue('Jane')
await wrapper.find('input').trigger('input')
vi.advanceTimersByTime(300)
await vi.runAllTimersAsync()
await wrapper.vm.$nextTick()
await wrapper.find('li').trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['p1'])
})
it('shows selected patient name after selection', async () => {
mockedGet.mockResolvedValueOnce({
success: true,
statusCode: 200,
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
error: null,
})
const wrapper = mount(PatientSearch)
await wrapper.find('input').setValue('Jane')
await wrapper.find('input').trigger('input')
vi.advanceTimersByTime(300)
await vi.runAllTimersAsync()
await wrapper.vm.$nextTick()
await wrapper.find('li').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Selected: Jane Doe')
})
it('clears results list on patient selection', async () => {
mockedGet.mockResolvedValueOnce({
success: true,
statusCode: 200,
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
error: null,
})
const wrapper = mount(PatientSearch)
await wrapper.find('input').setValue('Jane')
await wrapper.find('input').trigger('input')
vi.advanceTimersByTime(300)
await vi.runAllTimersAsync()
await wrapper.vm.$nextTick()
await wrapper.find('li').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.findAll('li').length).toBe(0)
})
it('sets input value to patient name on selection', async () => {
mockedGet.mockResolvedValueOnce({
success: true,
statusCode: 200,
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
error: null,
})
const wrapper = mount(PatientSearch)
const input = wrapper.find('input')
await input.setValue('Jane')
await input.trigger('input')
vi.advanceTimersByTime(300)
await vi.runAllTimersAsync()
await wrapper.vm.$nextTick()
await wrapper.find('li').trigger('click')
await wrapper.vm.$nextTick()
expect(input.element.value).toBe('Jane Doe')
})
it('clears results on API error', async () => {
mockedGet.mockRejectedValueOnce(new Error('Network error'))
const wrapper = mount(PatientSearch)
await wrapper.find('input').setValue('Jane')
await wrapper.find('input').trigger('input')
vi.advanceTimersByTime(300)
await vi.runAllTimersAsync()
await wrapper.vm.$nextTick()
expect(wrapper.findAll('li').length).toBe(0)
})
it('debounces multiple rapid inputs', async () => {
mockedGet.mockResolvedValue({
success: true,
statusCode: 200,
data: [],
error: null,
})
const wrapper = mount(PatientSearch)
const input = wrapper.find('input')
await input.setValue('Ja')
await input.trigger('input')
vi.advanceTimersByTime(100)
await input.setValue('Jan')
await input.trigger('input')
vi.advanceTimersByTime(100)
await input.setValue('Jane')
await input.trigger('input')
vi.advanceTimersByTime(300)
await vi.runAllTimersAsync()
expect(mockedGet).toHaveBeenCalledTimes(1)
expect(mockedGet).toHaveBeenCalledWith('patients/search', { q: 'Jane' })
})
})
@@ -0,0 +1,415 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import VerificationForm from '@/components/VerificationForm.vue'
import { useBatchStore } from '@/stores/batches'
import type { BatchDetailResponse } from '@/types'
import {
emptyDraft,
fieldRequirementsForBatchType,
} from '@/__tests__/helpers/fieldRequirements'
vi.mock('@/api/client', () => ({
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
del: vi.fn(),
patch: vi.fn(),
uploadFile: vi.fn(),
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}),
}))
vi.mock('vue-router', () => ({
useRouter: () => ({
push: vi.fn(),
}),
}))
function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailResponse {
const batchType = overrides.batchType ?? 'VITALS'
return {
id: 'b1',
status: 'PENDING_VERIFICATION',
batchType,
track: 'TRACK_A',
fieldRequirements: fieldRequirementsForBatchType(batchType),
patientId: 'p1',
documentRef: 'docs/scan.pdf',
documentUrl: null,
enableRetroactiveAlerts: false,
enteredByUserId: 'u1',
verifiedByUserId: null,
approvedByUserId: null,
rejectionReason: null,
promotedAt: null,
promotionEncounterId: null,
supersedesBatchId: null,
clinicianAttestation: false,
isCorrection: false,
supersession: null,
createdAt: '2026-06-27T10:00:00Z',
updatedAt: '2026-06-27T10:00:00Z',
...overrides,
}
}
function mountWithDraft(batchOverrides: Partial<BatchDetailResponse> = {}) {
const batch = makeBatch(batchOverrides)
const wrapper = mount(VerificationForm, {
props: { batch, batchId: 'b1' },
})
const store = useBatchStore()
store.currentDraft = emptyDraft(batch.batchType, {
patient: {
id: 'dp1',
batchId: 'b1',
fullName: 'Jane Doe',
dateOfBirth: '1990-05-15',
sex: 'female',
bloodType: 'A+',
emergencyContact: '555-1234',
allergies: null,
noKnownAllergies: false,
medications: null,
noActiveMedications: false,
},
encounter: {
id: 'de1',
batchId: 'b1',
admissionDate: '2026-06-20T08:00:00',
department: 'ICU',
roomBed: '3A-12',
admissionReason: 'Chest pain',
dischargeDiagnosis: null,
status: null,
},
observations: [
{
id: 'obs-1',
batchId: 'b1',
observationCode: 'HEART_RATE',
value: 72,
unit: 'bpm',
recordedAt: '2026-06-27T10:00:00Z',
note: null,
},
{
id: 'obs-2',
batchId: 'b1',
observationCode: 'TEMP_C',
value: 37.2,
unit: 'C',
recordedAt: '2026-06-27T10:00:00Z',
note: null,
},
],
})
return { wrapper, store }
}
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('VerificationForm', () => {
it('renders verification header', () => {
const wrapper = mount(VerificationForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
expect(wrapper.text()).toContain('Verification Review')
expect(wrapper.text()).toContain('Pending Verification')
})
it('renders patient fields with checkboxes after draft loads', async () => {
const { wrapper } = mountWithDraft()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Patient Demographics')
expect(wrapper.text()).toContain('Jane Doe')
expect(wrapper.text()).toContain('1990-05-15')
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes.length).toBeGreaterThan(0)
})
it('renders encounter fields after draft loads', async () => {
const { wrapper } = mountWithDraft()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Encounter Context')
expect(wrapper.text()).toContain('ICU')
expect(wrapper.text()).toContain('3A-12')
expect(wrapper.text()).toContain('Chest pain')
})
it('shows rejection reason banner when present', () => {
const wrapper = mount(VerificationForm, {
props: {
batch: makeBatch({ rejectionReason: 'Temperature seems incorrect' }),
batchId: 'b1',
},
})
expect(wrapper.text()).toContain('Previous Rejection Reason')
expect(wrapper.text()).toContain('Temperature seems incorrect')
})
it('does not show rejection banner when no reason', () => {
const wrapper = mount(VerificationForm, {
props: { batch: makeBatch({ rejectionReason: null }), batchId: 'b1' },
})
expect(wrapper.text()).not.toContain('Previous Rejection Reason')
})
describe('field check progress', () => {
it('shows 0/N checked initially', async () => {
const { wrapper } = mountWithDraft()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Fields verified:')
expect(wrapper.text()).toMatch(/0\s*\/\s*\d+/)
})
it('updates count when checkboxes are toggled', async () => {
const { wrapper } = mountWithDraft()
await wrapper.vm.$nextTick()
const checkboxes = wrapper.findAll('input[type="checkbox"]')
await checkboxes[0].setValue(true)
await wrapper.vm.$nextTick()
expect(wrapper.text()).toMatch(/1\s*\/\s*\d+/)
})
})
describe('approve button', () => {
it('is disabled when not all fields are checked', async () => {
const { wrapper } = mountWithDraft()
await wrapper.vm.$nextTick()
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
expect(approveBtn!.element.disabled).toBe(true)
})
it('is enabled when all fields are checked', async () => {
const { wrapper } = mountWithDraft()
await wrapper.vm.$nextTick()
const checkboxes = wrapper.findAll('input[type="checkbox"]')
for (const cb of checkboxes) {
await cb.setValue(true)
}
await wrapper.vm.$nextTick()
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
expect(approveBtn!.element.disabled).toBe(false)
})
it('calls verifyBatch with all field checks on approve', async () => {
const { wrapper, store } = mountWithDraft()
await wrapper.vm.$nextTick()
store.verifyBatch = vi.fn().mockResolvedValue(undefined)
const checkboxes = wrapper.findAll('input[type="checkbox"]')
for (const cb of checkboxes) {
await cb.setValue(true)
}
await wrapper.vm.$nextTick()
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
await approveBtn!.trigger('click')
await flushPromises()
expect(store.verifyBatch).toHaveBeenCalledWith(
'b1',
expect.arrayContaining([
expect.objectContaining({ passed: true }),
]),
true,
)
})
})
describe('reject flow', () => {
it('shows reject dialog when reject button is clicked', async () => {
const wrapper = mount(VerificationForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
await rejectBtn!.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Reject Batch')
expect(wrapper.text()).toContain('Confirm Rejection')
})
it('disables confirm button when reason is empty', async () => {
const wrapper = mount(VerificationForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
await rejectBtn!.trigger('click')
await wrapper.vm.$nextTick()
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection')
expect(confirmBtn!.element.disabled).toBe(true)
})
it('enables confirm button when reason is entered', async () => {
const wrapper = mount(VerificationForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
await rejectBtn!.trigger('click')
await wrapper.vm.$nextTick()
const textarea = wrapper.find('textarea')
await textarea.setValue('Temperature value appears incorrect')
await wrapper.vm.$nextTick()
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection')
expect(confirmBtn!.element.disabled).toBe(false)
})
it('calls rejectBatch on confirm', async () => {
const wrapper = mount(VerificationForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
const store = useBatchStore()
store.rejectBatch = vi.fn().mockResolvedValue(undefined)
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
await rejectBtn!.trigger('click')
await wrapper.vm.$nextTick()
const textarea = wrapper.find('textarea')
await textarea.setValue('Value incorrect')
await wrapper.vm.$nextTick()
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection')
await confirmBtn!.trigger('click')
await flushPromises()
expect(store.rejectBatch).toHaveBeenCalledWith('b1', 'Value incorrect')
})
it('closes reject dialog on cancel', async () => {
const wrapper = mount(VerificationForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
await rejectBtn!.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Reject Batch')
const cancelBtn = wrapper.findAll('button').find((b) => b.text() === 'Cancel')
await cancelBtn!.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('Reject Batch')
})
})
describe('observations display', () => {
it('shows observation count in legend', async () => {
const { wrapper } = mountWithDraft()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Observations (2)')
})
})
describe('allergy and medication verification', () => {
it('shows allergy fields for ALLERGY_UPDATE batch', async () => {
const { wrapper } = mountWithDraft({ batchType: 'ALLERGY_UPDATE' })
const store = useBatchStore()
store.currentDraft!.patient!.allergies = ['Penicillin', 'Latex']
await wrapper.vm.$nextTick()
// Re-trigger the watcher by resetting draft
const draft = { ...store.currentDraft! }
store.currentDraft = null
await wrapper.vm.$nextTick()
store.currentDraft = draft
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Allergies')
})
it('shows NKA for noKnownAllergies', async () => {
const wrapper = mount(VerificationForm, {
props: { batch: makeBatch({ batchType: 'ALLERGY_UPDATE' }), batchId: 'b1' },
})
const store = useBatchStore()
store.currentDraft = emptyDraft('ALLERGY_UPDATE', {
patient: {
id: 'dp1',
batchId: 'b1',
fullName: 'Jane',
dateOfBirth: '1990-01-01',
sex: 'female',
bloodType: null,
emergencyContact: null,
allergies: null,
noKnownAllergies: true,
medications: null,
noActiveMedications: false,
},
encounter: {
id: 'de1',
batchId: 'b1',
admissionDate: null,
department: null,
roomBed: null,
admissionReason: null,
dischargeDiagnosis: null,
status: null,
},
observations: [],
})
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('No Known Allergies')
expect(wrapper.text()).toContain('Yes (NKA)')
})
})
describe('error handling', () => {
it('shows error message on approve failure', async () => {
const { wrapper, store } = mountWithDraft()
await wrapper.vm.$nextTick()
store.verifyBatch = vi.fn().mockRejectedValue(new Error('Server error'))
const checkboxes = wrapper.findAll('input[type="checkbox"]')
for (const cb of checkboxes) {
await cb.setValue(true)
}
await wrapper.vm.$nextTick()
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
await approveBtn!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Server error')
})
})
})
@@ -0,0 +1,88 @@
import type { BatchDraft, BatchTypeFieldRequirements } from '@/types'
export function fieldRequirementsForBatchType(
batchType: string,
): BatchTypeFieldRequirements {
switch (batchType) {
case 'PATIENT_REGISTRATION':
return {
showPatientDemographics: true,
showEncounterContext: false,
showEncounterSummaryFields: false,
showObservations: false,
showAllergies: false,
showMedications: false,
}
case 'ALLERGY_UPDATE':
return {
showPatientDemographics: true,
showEncounterContext: false,
showEncounterSummaryFields: false,
showObservations: false,
showAllergies: true,
showMedications: false,
}
case 'ENCOUNTER_SUMMARY':
return {
showPatientDemographics: true,
showEncounterContext: true,
showEncounterSummaryFields: true,
showObservations: false,
showAllergies: false,
showMedications: false,
}
case 'MEDICATION_LIST':
return {
showPatientDemographics: true,
showEncounterContext: false,
showEncounterSummaryFields: false,
showObservations: false,
showAllergies: false,
showMedications: true,
}
case 'MIXED':
return {
showPatientDemographics: true,
showEncounterContext: true,
showEncounterSummaryFields: true,
showObservations: true,
showAllergies: true,
showMedications: true,
}
case 'LAB_RESULTS':
return {
showPatientDemographics: true,
showEncounterContext: true,
showEncounterSummaryFields: false,
showObservations: true,
showAllergies: false,
showMedications: false,
}
default:
return {
showPatientDemographics: true,
showEncounterContext: true,
showEncounterSummaryFields: false,
showObservations: true,
showAllergies: false,
showMedications: false,
}
}
}
export function emptyDraft(
batchType: string,
overrides: Partial<BatchDraft> = {},
): BatchDraft {
return {
batchId: 'b1',
status: 'IN_ENTRY',
batchType,
fieldRequirements: fieldRequirementsForBatchType(batchType),
ocrConfidence: null,
patient: null,
encounter: null,
observations: [],
...overrides,
}
}

Some files were not shown because too many files have changed in this diff Show More