Compare commits

..
27 Commits
Author SHA1 Message Date
Trent 4fb9c95dfb Add walkthrough
CI / backend (push) Canceled after 15s
CI / frontend (push) Successful in 1m36s
2026-08-12 07:05:37 +08:00
Trent 3680dc5ea7 Fix issue with OCR fixture
CI / backend (push) Canceled after 11s
CI / frontend (push) Successful in 1m27s
2026-08-12 06:27:08 +08:00
Trent 48c5c93358 Fix the frontend test because of new additions
CI / backend (push) Canceled after 22s
CI / frontend (push) Failing after 1m33s
2026-08-12 06:21:23 +08:00
Trent 53338906aa make addition ui fixes for cramped features
CI / backend (push) Successful in 6m6s
CI / frontend (push) Failing after 1m8s
2026-08-12 06:11:30 +08:00
Trent 4c5aafe591 Fix responsiveness
CI / backend (push) Successful in 6m14s
CI / frontend (push) Failing after 59s
2026-08-12 05:45:12 +08:00
Trent 00f832aa73 feature: Surface Existing Unused APIs
CI / backend (push) Successful in 5m58s
CI / frontend (push) Failing after 1m15s
2026-08-12 04:56:06 +08:00
Trent 4085aaa549 feature: Supporting Screens
CI / backend (push) Successful in 6m0s
CI / frontend (push) Failing after 1m7s
2026-08-12 04:39:32 +08:00
Trent 5caf928787 feature: Core Digitization Workstation (Entry, Verification, Approval)
CI / backend (push) Successful in 6m11s
CI / frontend (push) Failing after 1m7s
2026-08-12 04:22:25 +08:00
Trent 9811f2a2ed feature: Shared UX Primitives
CI / backend (push) Successful in 6m16s
CI / frontend (push) Failing after 1m4s
2026-08-12 03:57:43 +08:00
Trent 56e100a495 feature: Design System, App Shell, and Login Redesign
CI / backend (push) Successful in 6m31s
CI / frontend (push) Successful in 58s
2026-08-12 03:37:17 +08:00
Trent 6e06717b82 Update cd
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 4s
2026-08-12 01:47:12 +08:00
Trent 0c31291b69 Fix: Missing using
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 10s
2026-08-12 01:40:19 +08:00
Trent 572cef7b1f Add code to create minio bucket
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 5s
2026-08-12 01:36:39 +08:00
Trent 89da4d094f fix AppDbContext migrate issue
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 6s
2026-08-12 01:12:13 +08:00
Trent 4d95108ac5 Fix docker file issue
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 10s
2026-08-12 00:56:55 +08:00
voltsrage e25b57c779 fix frontend issues
CI / backend (push) Successful in 6m10s
CI / frontend (push) Successful in 55s
2026-08-11 20:54:59 +08:00
voltsrage f2eab26ad0 fix: fix ci issues and update styles to remove ui elements
CI / backend (push) Successful in 6m11s
CI / frontend (push) Failing after 54s
2026-08-11 20:33:23 +08:00
voltsrage c871dc4842 Add deployment files
CI / backend (push) Failing after 2m26s
CI / frontend (push) Failing after 53s
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
199 changed files with 28311 additions and 1922 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
+51
View File
@@ -0,0 +1,51 @@
# 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).
# CD copies migrate-api to the deploy host and runs it in a one-shot container on
# shared-services, so use the same Docker DNS name as PG_CONNECTION (Host=postgres),
# not an external hostname. Store this as Gitea secret PG_CONNECTION_DDL.
# Never put this credential in the API container environment.
PG_CONNECTION_DDL="Host=postgres;Port=5432;Database=vigilcare_records;Username=vigilcare_records_migrator;Password=CHANGE_ME;SSL Mode=Disable"
# ---- 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
+207
View File
@@ -0,0 +1,207 @@
# 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.
# Postgres lives on the deploy host's shared-services network and is not
# reachable from act_runner, so the bundle is copied there and executed in
# a one-shot container joined to that network (Host=postgres resolves).
# 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
- 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
# Runs while the previous release is still serving traffic, so every
# migration must be backwards-compatible with the outgoing image
# (expand-then-contract). Connection strings contain `;` / spaces
# ("SSL Mode=...") — never pass them as a bare ssh remote argv; the
# remote shell splits on `;` and drops the variable (set -u → unbound).
- name: Apply migrations
env:
PG_CONNECTION_DDL: ${{ secrets.PG_CONNECTION_DDL }}
run: |
REMOTE="${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}"
scp -i ~/.ssh/id_ed25519 ./migrate-api \
"$REMOTE:/tmp/vigilcare-records-migrate-api"
umask 077
printf '%s\n' "$PG_CONNECTION_DDL" > /tmp/vigilcare-records-pg-ddl
scp -i ~/.ssh/id_ed25519 /tmp/vigilcare-records-pg-ddl \
"$REMOTE:/tmp/vigilcare-records-pg-ddl"
rm -f /tmp/vigilcare-records-pg-ddl
ssh -i ~/.ssh/id_ed25519 "$REMOTE" bash -euo pipefail <<'EOF'
chmod +x /tmp/vigilcare-records-migrate-api
chmod 600 /tmp/vigilcare-records-pg-ddl
PG_CONNECTION_DDL="$(cat /tmp/vigilcare-records-pg-ddl)"
docker run --rm \
--network shared-services \
-v /tmp/vigilcare-records-migrate-api:/migrate-api:ro \
--entrypoint /migrate-api \
mcr.microsoft.com/dotnet/runtime-deps:8.0 \
--connection "$PG_CONNECTION_DDL"
rm -f /tmp/vigilcare-records-migrate-api /tmp/vigilcare-records-pg-ddl
EOF
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
+109
View File
@@ -0,0 +1,109 @@
# 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
# act_runner's docker executor runs steps inside their own job container — a
# sibling of the "docker compose" containers below — so "localhost" from inside
# the job container is NOT the Docker host and can't reach the published
# Postgres/Redis ports (this is what "Connection refused" on 127.0.0.1:5437
# in the Test step means). The Test step below points at host.docker.internal
# instead; that hostname must resolve inside the job container, which on Linux
# requires the runner itself (not this workflow) to add
# `--add-host=host.docker.internal:host-gateway` — set
# `container: { options: "--add-host=host.docker.internal:host-gateway" }` in the
# act_runner's config.yaml. Docker Desktop runners already resolve this hostname
# out of the box. See docs/25-gitea-cicd-docker-deploy.md ("Runner requirement
# for Compose-based tests").
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"
CONNECTIONSTRINGS__DEFAULTCONNECTION: "Host=host.docker.internal;Port=5437;Database=vigilcare_records_test;Username=postgres;Password=password"
REDIS__CONNECTIONSTRING: "host.docker.internal:6383,defaultDatabase=1,allowAdmin=true"
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
+32
View File
@@ -0,0 +1,32 @@
Use Playwright MCP as a visual QA and frontend polish tool for this page.
Inspect the page at mobile, tablet, and desktop breakpoints, identify styling/layout issues, and fix them directly in the code.
Focus on:
- spacing
- sizing
- alignment
- element positions
- typography scaling
- card proportions
- image/text balance
- overflow/clipping
- responsiveness
Requirements:
- keep Tailwind CSS
- preserve the current design intent
- improve the page until it looks polished and production-ready
- verify each fix visually with Playwright MCP
- iterate until no obvious visual issues remain
Test at:
- 375x812
- 768x1024
- 1280x800
Use VigilCareRecordsAPI/Data/Seed/DataSeeder.cs to get the logins
Do not only report problems.
Make the fixes, re-test, and then provide a short summary of what was improved.
+84
View File
@@ -0,0 +1,84 @@
Here's the updated prompt:
---
## Role
Act as a senior QA engineer testing a Vue 3 + Node.js web application for logic errors, bugs, and edge cases.
## Output Format
A plain text report of all issues found, grouped by file or feature domain, with severity level per issue (Critical / High / Medium / Low).
## Goal
Test the provided functionality, section, or domain by reading the code and mentally executing it across normal, boundary, and failure scenarios. Report every defect found. When testing a view or feature, trace execution downward through all underlying components, composables, and backend routes and controllers that the feature depends on.
## Grounding Rules
- Stay within the confines of the provided code — do not invent features, routes, or behaviors that are not present.
- Do not hallucinate API responses, database states, or UI interactions not inferable from the code.
- Do not suggest third-party testing tools or libraries unless already present in the codebase.
- If a behavior is ambiguous, flag it as a question rather than assuming intent.
- When a view or component calls a composable, follow that composable's logic as part of the same test pass.
- When a composable or service makes an API call, follow the corresponding backend route, middleware, and controller as part of the same test pass.
## Instructions
Test each provided file or domain in this order of priority:
1. **Logic correctness** — Does the code do what it is clearly intended to do?
2. **Edge cases** — Empty inputs, null/undefined values, empty arrays, zero, negative numbers, max-length strings, concurrent calls.
3. **Error handling** — Are errors caught? Are failure states handled gracefully? Do error messages leak sensitive data?
4. **Reactivity correctness** (frontend) — Does state update when it should? Can stale state be observed?
5. **Data flow** — Are values passed, transformed, or mutated in ways that could produce unexpected results downstream? Trace data from the frontend input all the way to the database query and back.
6. **Boundary conditions** — Off-by-one errors, pagination limits, permission boundaries, rate limits.
7. **Race conditions** — Async operations that could resolve out of order or leave state inconsistent.
8. **Contract mismatches** — Does the frontend expect a response shape the backend does not guarantee? Are required fields missing, optional fields assumed present, or error codes unhandled?
9. **Login/Auth Requirements** - If auth or login is required use the following credentials email: bradleystorm.sevt@mockinbox.com and password: Password123!
**Conflict resolution:** If a behavior could be either a bug or an intentional design choice, report it as a flagged ambiguity rather than a confirmed defect. Do not silently assume either way.
**Priority hierarchy:** Logic correctness > Error handling > Edge cases > Data flow > Contract mismatches > Boundary conditions > Race conditions > Reactivity.
## Trace Depth
When a file is provided as the entry point for testing, automatically include in scope:
- All composables imported and called by that file
- All child components rendered by that file
- All backend routes, middleware, and controllers called by those composables or services
- All database queries executed by those controllers
Report issues at the layer where they originate, not just where their effect is observed.
## Examples
**Bad output (do not produce this):**
```
- The login form might have issues.
- Consider adding more validation.
```
**Good output (produce this):**
```
FILE: src/composables/useAuth.js
SEVERITY: Critical
ISSUE: If `refreshToken()` is called while a refresh is already in flight, two concurrent requests are fired. The second response overwrites the token set by the first, leaving the app in a potentially invalid auth state.
REPRODUCTION: Trigger two API calls simultaneously on a near-expired token.
FIX RECOMMENDATION: Guard the refresh call with an in-flight flag or return the existing promise if one is pending.
FILE: backend/controllers/authController.js
SEVERITY: High
ISSUE: The refresh token is not invalidated after use. A leaked token can be replayed indefinitely until expiry.
REPRODUCTION: Capture the refresh token from a valid session and reuse it after the session has been refreshed.
FIX RECOMMENDATION: Implement refresh token rotation — invalidate the used token and issue a new one on each refresh.
```
## Context / Input
Paste files in this order, highest reliability first:
1. Backend routes, middleware, and controllers
2. Composables and services
3. Components and views
## Final Reminder
- Do not fabricate bugs. Every reported issue must be traceable to a specific line or code path in the provided files.
- Do not skip files because they look simple — shallow files are common sources of silent failures.
- Ambiguity is a valid finding. Flag it rather than resolve it silently.
- Always trace execution through the full stack — frontend to composable to backend to database — before closing a test pass on any feature.
## Output
Plain text only. No markdown formatting, no bullet symbols, no headers with hashes. Group findings by file. For each issue state: FILE, SEVERITY, ISSUE, REPRODUCTION STEPS, FIX RECOMMENDATION. If a file has no issues, write the filename followed by "No issues found." Restate this format requirement if the session resets mid-task.
+160 -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,14 @@ 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 1418 UI redesign guides (113 historical; not in repo)
├── designs/ # UI/UX design-doc + mockup PNGs
├── 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 +379,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 +405,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 +423,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 +441,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 +521,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 +531,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 +543,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 +561,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 +597,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 +641,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 +906,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 +988,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 +1274,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 +1284,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;
}
}
@@ -9,16 +9,30 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
{ {
// Override configuration to point at a test database — never run tests against // Override configuration to point at a test database — never run tests against
// the development database; a botched rollback could corrupt seed data. // the development database; a botched rollback could corrupt seed data.
//
// Defaults assume Postgres/Redis are reachable on the host's loopback address
// (e.g. `docker compose up -d postgres redis` on a dev machine). CI runners that
// execute the test step inside its own job container (act_runner's default docker
// executor) can't reach ports published on the Docker *host* via "localhost" —
// set CONNECTIONSTRINGS__DEFAULTCONNECTION / REDIS__CONNECTIONSTRING (e.g. to
// host.docker.internal) in that environment to override.
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
builder.UseEnvironment("Testing"); builder.UseEnvironment("Testing");
builder.ConfigureAppConfiguration((_, config) => builder.ConfigureAppConfiguration((_, config) =>
{ {
var connectionString = Environment.GetEnvironmentVariable("CONNECTIONSTRINGS__DEFAULTCONNECTION")
?? "Host=localhost;Port=5437;Database=vigilcare_records_test;Username=postgres;Password=password";
var redisConnectionString = Environment.GetEnvironmentVariable("REDIS__CONNECTIONSTRING")
?? "localhost:6383,defaultDatabase=1,allowAdmin=true";
var fhirBaseUrl = Environment.GetEnvironmentVariable("FHIR__BASEURL")
?? "http://localhost/fhir";
config.AddInMemoryCollection(new Dictionary<string, string?> config.AddInMemoryCollection(new Dictionary<string, string?>
{ {
["ConnectionStrings:DefaultConnection"] = ["ConnectionStrings:DefaultConnection"] = connectionString,
"Host=localhost;Port=5437;Database=vigilcare_records_test;Username=postgres;Password=password", ["Redis:ConnectionString"] = redisConnectionString,
["Redis:ConnectionString"] = "localhost:6383,defaultDatabase=1,allowAdmin=true" ["Fhir:BaseUrl"] = fhirBaseUrl
}); });
}); });
} }
@@ -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>
@@ -199,7 +252,7 @@ public class DigitizationBatchesController : ControllerBase
/// <param name="after">Cursor: ISO-8601 timestamp from the previous page's nextCursor field.</param> /// <param name="after">Cursor: ISO-8601 timestamp from the previous page's nextCursor field.</param>
/// <param name="pageSize">Number of events per page. Default 50, max 200.</param> /// <param name="pageSize">Number of events per page. Default 50, max 200.</param>
[HttpGet("{id:guid}/events")] [HttpGet("{id:guid}/events")]
[Authorize(Roles = "ADMINISTRATOR,VERIFIER,CLINICAL_APPROVER")] [Authorize(Roles = "ADMINISTRATOR,DATA_ENTRY_CLERK,VERIFIER,CLINICAL_APPROVER")]
[ProducesResponseType(typeof(ApiResponse<CursorPagedResult<BatchEventResponse>>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<CursorPagedResult<BatchEventResponse>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
@@ -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);
}
}
+3 -1
View File
@@ -23,7 +23,9 @@ 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)
{ {
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
/// <summary>
/// Design-time factory for EF tools and the CD migrations bundle.
/// Without this, the bundle tries to bootstrap Program.cs (JWT/MinIO/etc.) and fails
/// when those settings are absent next to ./migrate-api; --connection then never applies.
/// </summary>
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
optionsBuilder.UseNpgsql(ResolveConnectionString());
return new AppDbContext(optionsBuilder.Options);
}
private static string ResolveConnectionString()
{
var fromEnv = Environment.GetEnvironmentVariable("ConnectionStrings__DefaultConnection");
if (!string.IsNullOrWhiteSpace(fromEnv))
return fromEnv;
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Development.json", optional: true)
.AddEnvironmentVariables()
.Build();
var fromConfig = config.GetConnectionString("DefaultConnection");
if (!string.IsNullOrWhiteSpace(fromConfig))
return fromConfig;
// Placeholder so the factory can construct a context; migrate-api --connection
// replaces this when applying migrations in CD.
return "Host=127.0.0.1;Database=ef_design;Username=ef;Password=ef";
}
}
@@ -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 .
# aspnet:8.0 already ships non-root user `app` (UID/GID 1654).
RUN chown -R app:app /app
USER app
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; }
); }
+33 -2
View File
@@ -6,8 +6,10 @@ using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Minio; using Minio;
using Minio.DataModel.Args;
using Prometheus; using Prometheus;
using Serilog; using Serilog;
using StackExchange.Redis; using StackExchange.Redis;
@@ -51,6 +53,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,7 +142,9 @@ 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 +165,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();
@@ -209,6 +235,11 @@ try
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();
await DataSeeder.SeedAsync(db); await DataSeeder.SeedAsync(db);
var minio = scope.ServiceProvider.GetRequiredService<IMinioClient>();
var minioOpts = scope.ServiceProvider.GetRequiredService<IOptions<MinioOptions>>().Value;
if (!await minio.BucketExistsAsync(new BucketExistsArgs().WithBucket(minioOpts.BucketName)))
await minio.MakeBucketAsync(new MakeBucketArgs().WithBucket(minioOpts.BucketName));
} }
app.Run(); app.Run();
+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, SCPs it to the deploy host, and runs it on the `shared-services` Docker network 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. `scp` the binary to the deploy host and `docker run --network shared-services` it with `--connection "${{ secrets.PG_CONNECTION_DDL }}"` (Postgres is on that network — not reachable from `act_runner`)
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.
+97
View File
@@ -0,0 +1,97 @@
# 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.
Create the Records MinIO bucket once (the API also creates it on startup if missing):
```bash
ssh deploy@YOUR_HOST bash -euo pipefail <<'EOF'
cd /opt/vigilcare-records
env_val() { sed -n "s/^${1}=//p" .env | tail -n1 | tr -d '\r'; }
ACCESS="$(env_val MINIO_ACCESS_KEY)"
SECRET="$(env_val MINIO_SECRET_KEY)"
BUCKET="$(env_val MINIO_BUCKET_NAME)"; BUCKET="${BUCKET:-vigilcare-records-scans}"
docker run --rm --network shared-services --entrypoint /bin/sh minio/mc \
-c "mc alias set local http://minio:9000 '${ACCESS}' '${SECRET}' && mc mb --ignore-existing local/${BUCKET}"
EOF
```
## 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 (`Host=postgres` on `shared-services`), 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 → SSH the EF migrations bundle onto the deploy host and run it on `shared-services` with `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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+6
View File
@@ -0,0 +1,6 @@
1, Create folder and change permissions
ssh vectur45@35.201.217.185 "sudo mkdir -p /opt/<project-name> && sudo chown -R vectur45:vectur45 /opt/<project-name> && sudo chmod 755 /opt/<project-name>"
2. Copy .env to folder
scp -i ~/.ssh/vectur45 .env vectur45@35.201.217.185:/opt/<project-name>/.env
+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)
+212 -30
View File
@@ -2,7 +2,9 @@
## 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.
**Phases 1418 (UI redesign)** are planned: restyle and re-layout `vigilcare-records-web` against [designs/design-doc.md](designs/design-doc.md) without new backend behavior. Implementation guides: [plans/](plans/).
| Phase | Scope | Status | | Phase | Scope | Status |
|---|---|---| |---|---|---|
@@ -14,13 +16,20 @@
| 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 |
| 14 | UI redesign: design tokens, app shell (sidebar), login | Planned |
| 15 | UI redesign: shared UX primitives (status, OCR badges, SoD, sticky actions) | Planned |
| 16 | UI redesign: Entry / Verification / Clinical Approval workstation layouts | Planned |
| 17 | UI redesign: Intake, Cover Sheets, Live Capture, History, Dashboard, FHIR Explorer | Planned |
| 18 | UI redesign: surface unused APIs (batch events, work queues, Users admin) | 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`. Phases 1418 verify via Vitest + manual checklists in each plan.
**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 +37,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 +57,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 +66,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 +340,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 +357,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 +503,110 @@ 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); redesign Planned (Phases 1418)*
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` |
| **Users** | Administrator | Create/update users, deactivate, reset passwords |
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.
### UI redesign roadmap (Phases 1418)
Constrained to **existing API capabilities**. Design source: [designs/design-doc.md](designs/design-doc.md) (section 36 corrections override mockups). Plans: [plans/README.md](plans/README.md).
| Phase | Focus |
|---|---|
| 14 | Navy/blue design tokens, role-filtered sidebar shell, split login (no role picker); keep Public Sans |
| 15 | Shared primitives: status badges, empty/loading/error, sticky action bar, OCR % badges, SoD banner, confirm dialogs |
| 16 | Dense Entry / Verification / Clinical Approval workstation layouts |
| 17 | Restyle Intake, Cover Sheets, Live Capture, Patient History, Queue Dashboard, FHIR Explorer |
| 18 | Wire unused APIs: batch events panel, `work-queue/*` lists, administrator Users page |
Deferred mockup items (global search, notifications, Reports/Master Data, SSO, OCR region highlight, infra health widgets) are listed in [plans/README.md](plans/README.md).
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 +661,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,13 +683,22 @@ 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 |
| 14 | UI redesign: tokens, app shell, login | Planned |
| 15 | UI redesign: shared UX primitives | Planned |
| 16 | UI redesign: Entry / Verification / Approval workstation | Planned |
| 17 | UI redesign: supporting screens + dashboard | Planned |
| 18 | UI redesign: surface unused existing APIs | Done |
--- ---
## Step-by-Step Guide ## Step-by-Step Guide
Complete phases in order. Promotion (Phase 4) must not be built until the draft state machine and separation of duties are correct — debugging promotion bugs alongside workflow bugs is painful. Complete phases in order. Promotion (Phase 4) must not be built until the draft state machine and separation of duties are correct — debugging promotion bugs alongside workflow bugs is painful. For Phases 1418, complete each UI plan before starting the next; do not invent backend features to match mockups.
--- ---
@@ -677,9 +777,89 @@ 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.
---
### Phase 14 — UI Design System, Shell, Login
**What to do:** Map design-doc color tokens into Tailwind (keep Public Sans); add role-filtered `AppShell` sidebar for existing routes; redesign login as split brand + form without a role selector.
**Plan:** [plans/phase-14-plan.md](plans/phase-14-plan.md)
---
### Phase 15 — Shared UX Primitives
**What to do:** Status badges, empty/loading/error patterns, sticky workstation action bar, OCR percentage badges, separation-of-duties banner, confirmation dialogs for irreversible actions.
**Plan:** [plans/phase-15-plan.md](plans/phase-15-plan.md)
---
### Phase 16 — Workstation Layouts (Entry, Verification, Approval)
**What to do:** Dense scan-first layouts with sticky CTAs and design-doc action labels; apply SoD and OCR primitives; no API contract changes.
**Plan:** [plans/phase-16-plan.md](plans/phase-16-plan.md)
---
### Phase 17 — Supporting Screens
**What to do:** Restyle Intake, Cover Sheets, Live Capture, Patient History, Queue Dashboard (overview metrics only — no infra health), and FHIR Explorer.
**Plan:** [plans/phase-17-plan.md](plans/phase-17-plan.md)
---
### Phase 18 — Surface Unused APIs in the UI
**What to do:** Batch events audit panel; prefer `work-queue/entry|verification|clinical-approval` for queues; administrator Users page via existing `UsersController`.
**Plan:** [plans/phase-18-plan.md](plans/phase-18-plan.md)
--- ---
@@ -725,7 +905,9 @@ 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 implementation guides (Phases 1418 UI redesign; Phase 113 plans not in repo)
- [designs/design-doc.md](designs/design-doc.md) — UI/UX design specification for the workstation redesign
- [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

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